mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Scopes: QoL UI fixes (#89158)
This commit is contained in:
parent
ab2af9b8f7
commit
67f2d93281
@ -74,7 +74,7 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<Sco
|
||||
<div className={styles.searchInputContainer}>
|
||||
<Input
|
||||
prefix={<Icon name="search" />}
|
||||
placeholder={t('scopes.suggestedDashboards.search', 'Filter')}
|
||||
placeholder={t('scopes.suggestedDashboards.search', 'Search')}
|
||||
disabled={isLoading}
|
||||
data-testid="scopes-dashboards-search"
|
||||
onChange={(evt) => model.changeSearchQuery(evt.currentTarget.value)}
|
||||
|
@ -13,19 +13,20 @@ import {
|
||||
SceneObjectUrlValues,
|
||||
SceneObjectWithUrlSync,
|
||||
} from '@grafana/scenes';
|
||||
import { Button, Drawer, IconButton, Input, Spinner, useStyles2 } from '@grafana/ui';
|
||||
import { Button, Drawer, Spinner, useStyles2 } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { ScopesInput } from './ScopesInput';
|
||||
import { ScopesScene } from './ScopesScene';
|
||||
import { ScopesTreeLevel } from './ScopesTreeLevel';
|
||||
import { fetchNodes, fetchScope, fetchScopes } from './api';
|
||||
import { NodesMap } from './types';
|
||||
import { fetchNodes, fetchScope, fetchSelectedScopes } from './api';
|
||||
import { NodesMap, SelectedScope, TreeScope } from './types';
|
||||
|
||||
export interface ScopesFiltersSceneState extends SceneObjectState {
|
||||
nodes: NodesMap;
|
||||
loadingNodeName: string | undefined;
|
||||
scopes: Scope[];
|
||||
dirtyScopeNames: string[];
|
||||
scopes: SelectedScope[];
|
||||
treeScopes: TreeScope[];
|
||||
isLoadingScopes: boolean;
|
||||
isOpened: boolean;
|
||||
}
|
||||
@ -57,7 +58,7 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
|
||||
},
|
||||
loadingNodeName: undefined,
|
||||
scopes: [],
|
||||
dirtyScopeNames: [],
|
||||
treeScopes: [],
|
||||
isLoadingScopes: false,
|
||||
isOpened: false,
|
||||
});
|
||||
@ -72,14 +73,16 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
|
||||
}
|
||||
|
||||
public getUrlState() {
|
||||
return { scopes: this.getScopeNames() };
|
||||
return {
|
||||
scopes: this.state.scopes.map(({ scope }) => scope.metadata.name),
|
||||
};
|
||||
}
|
||||
|
||||
public updateFromUrl(values: SceneObjectUrlValues) {
|
||||
let dirtyScopeNames = values.scopes ?? [];
|
||||
dirtyScopeNames = Array.isArray(dirtyScopeNames) ? dirtyScopeNames : [dirtyScopeNames];
|
||||
let scopeNames = values.scopes ?? [];
|
||||
scopeNames = Array.isArray(scopeNames) ? scopeNames : [scopeNames];
|
||||
|
||||
this.updateScopes(dirtyScopeNames);
|
||||
this.updateScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] })));
|
||||
}
|
||||
|
||||
public fetchBaseNodes() {
|
||||
@ -126,7 +129,7 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
|
||||
}
|
||||
|
||||
public toggleNodeSelect(path: string[]) {
|
||||
let dirtyScopeNames = [...this.state.dirtyScopeNames];
|
||||
let treeScopes = [...this.state.treeScopes];
|
||||
|
||||
let siblings = this.state.nodes;
|
||||
|
||||
@ -134,22 +137,27 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
|
||||
siblings = siblings[path[idx]].nodes;
|
||||
}
|
||||
|
||||
const name = path[path.length - 1];
|
||||
const { linkId } = siblings[name];
|
||||
const nodeName = path[path.length - 1];
|
||||
const { linkId } = siblings[nodeName];
|
||||
|
||||
const selectedIdx = dirtyScopeNames.findIndex((scopeName) => scopeName === linkId);
|
||||
const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId);
|
||||
|
||||
if (selectedIdx === -1) {
|
||||
fetchScope(linkId!);
|
||||
|
||||
const selectedFromSameNode =
|
||||
dirtyScopeNames.length === 0 || Object.values(siblings).some(({ linkId }) => linkId === dirtyScopeNames[0]);
|
||||
treeScopes.length === 0 || Object.values(siblings).some(({ linkId }) => linkId === treeScopes[0].scopeName);
|
||||
|
||||
this.setState({ dirtyScopeNames: !selectedFromSameNode ? [linkId!] : [...dirtyScopeNames, linkId!] });
|
||||
const treeScope = {
|
||||
scopeName: linkId!,
|
||||
path,
|
||||
};
|
||||
|
||||
this.setState({ treeScopes: !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope] });
|
||||
} else {
|
||||
dirtyScopeNames.splice(selectedIdx, 1);
|
||||
treeScopes.splice(selectedIdx, 1);
|
||||
|
||||
this.setState({ dirtyScopeNames });
|
||||
this.setState({ treeScopes });
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,62 +172,53 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
|
||||
}
|
||||
|
||||
public getSelectedScopes(): Scope[] {
|
||||
return this.state.scopes;
|
||||
return this.state.scopes.map(({ scope }) => scope);
|
||||
}
|
||||
|
||||
public async updateScopes(dirtyScopeNames = this.state.dirtyScopeNames) {
|
||||
if (isEqual(dirtyScopeNames, this.getScopeNames())) {
|
||||
public async updateScopes(treeScopes = this.state.treeScopes) {
|
||||
if (isEqual(treeScopes, this.getTreeScopes())) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ dirtyScopeNames, isLoadingScopes: true });
|
||||
this.setState({ treeScopes, isLoadingScopes: true });
|
||||
|
||||
this.setState({ scopes: await fetchScopes(dirtyScopeNames), isLoadingScopes: false });
|
||||
this.setState({ scopes: await fetchSelectedScopes(treeScopes), isLoadingScopes: false });
|
||||
}
|
||||
|
||||
public resetDirtyScopeNames() {
|
||||
this.setState({ dirtyScopeNames: this.getScopeNames() });
|
||||
this.setState({ treeScopes: this.getTreeScopes() });
|
||||
}
|
||||
|
||||
public removeAllScopes() {
|
||||
this.setState({ scopes: [], dirtyScopeNames: [], isLoadingScopes: false });
|
||||
this.setState({ scopes: [], treeScopes: [], isLoadingScopes: false });
|
||||
}
|
||||
|
||||
public enterViewMode() {
|
||||
this.setState({ isOpened: false });
|
||||
}
|
||||
|
||||
private getScopeNames(): string[] {
|
||||
return this.state.scopes.map(({ metadata: { name } }) => name);
|
||||
private getTreeScopes(): TreeScope[] {
|
||||
return this.state.scopes.map(({ scope, path }) => ({
|
||||
scopeName: scope.metadata.name,
|
||||
path,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<ScopesFiltersScene>) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { nodes, loadingNodeName, dirtyScopeNames, isLoadingScopes, isOpened, scopes } = model.useState();
|
||||
const { nodes, loadingNodeName, treeScopes, isLoadingScopes, isOpened, scopes } = model.useState();
|
||||
const { isViewing } = model.scopesParent.useState();
|
||||
|
||||
const scopesTitles = scopes.map(({ spec: { title } }) => title).join(', ');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
readOnly
|
||||
placeholder={t('scopes.filters.input.placeholder', 'Select scopes...')}
|
||||
loading={isLoadingScopes}
|
||||
value={scopesTitles}
|
||||
aria-label={t('scopes.filters.input.placeholder', 'Select scopes...')}
|
||||
data-testid="scopes-filters-input"
|
||||
suffix={
|
||||
scopes.length > 0 && !isViewing ? (
|
||||
<IconButton
|
||||
aria-label={t('scopes.filters.input.removeAll', 'Remove all scopes')}
|
||||
name="times"
|
||||
onClick={() => model.removeAllScopes()}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => model.open()}
|
||||
<ScopesInput
|
||||
nodes={nodes}
|
||||
scopes={scopes}
|
||||
isDisabled={isViewing}
|
||||
isLoading={isLoadingScopes}
|
||||
onInputClick={() => model.open()}
|
||||
onRemoveAllClick={() => model.removeAllScopes()}
|
||||
/>
|
||||
|
||||
{isOpened && (
|
||||
@ -238,7 +237,7 @@ export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<Scopes
|
||||
nodes={nodes}
|
||||
nodePath={['']}
|
||||
loadingNodeName={loadingNodeName}
|
||||
scopeNames={dirtyScopeNames}
|
||||
scopes={treeScopes}
|
||||
onNodeUpdate={(path, isExpanded, query) => model.updateNode(path, isExpanded, query)}
|
||||
onNodeSelectToggle={(path) => model.toggleNodeSelect(path)}
|
||||
/>
|
||||
|
119
public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx
Normal file
119
public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { groupBy } from 'lodash';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { IconButton, Input, Tooltip } from '@grafana/ui';
|
||||
import { useStyles2 } from '@grafana/ui/';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { NodesMap, SelectedScope } from './types';
|
||||
|
||||
export interface ScopesInputProps {
|
||||
nodes: NodesMap;
|
||||
scopes: SelectedScope[];
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
onInputClick: () => void;
|
||||
onRemoveAllClick: () => void;
|
||||
}
|
||||
|
||||
export function ScopesInput({
|
||||
nodes,
|
||||
scopes,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
onInputClick,
|
||||
onRemoveAllClick,
|
||||
}: ScopesInputProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const scopesPaths = useMemo(() => {
|
||||
const pathsTitles = scopes.map(({ scope, path }) => {
|
||||
let currentLevel = nodes;
|
||||
|
||||
let titles: string[];
|
||||
|
||||
if (path.length > 0) {
|
||||
titles = path.map((nodeName) => {
|
||||
const { title, nodes } = currentLevel[nodeName];
|
||||
|
||||
currentLevel = nodes;
|
||||
|
||||
return title;
|
||||
});
|
||||
|
||||
if (titles[0] === '') {
|
||||
titles.splice(0, 1);
|
||||
}
|
||||
} else {
|
||||
titles = [scope.spec.title];
|
||||
}
|
||||
|
||||
const scopeName = titles.pop();
|
||||
|
||||
return [titles.join(' > '), scopeName];
|
||||
});
|
||||
|
||||
const groupedByPath = groupBy(pathsTitles, ([path]) => path);
|
||||
|
||||
return Object.entries(groupedByPath)
|
||||
.map(([path, pathScopes]) => {
|
||||
const scopesTitles = pathScopes.map(([, scopeTitle]) => scopeTitle).join(', ');
|
||||
|
||||
return (path ? [path, scopesTitles] : [scopesTitles]).join(' > ');
|
||||
})
|
||||
.map((path) => (
|
||||
<p key={path} className={styles.scopePath}>
|
||||
{path}
|
||||
</p>
|
||||
));
|
||||
}, [nodes, scopes, styles]);
|
||||
|
||||
const scopesTitles = useMemo(() => scopes.map(({ scope }) => scope.spec.title).join(', '), [scopes]);
|
||||
|
||||
const input = (
|
||||
<Input
|
||||
readOnly
|
||||
placeholder={t('scopes.filters.input.placeholder', 'Select scopes...')}
|
||||
loading={isLoading}
|
||||
value={scopesTitles}
|
||||
aria-label={t('scopes.filters.input.placeholder', 'Select scopes...')}
|
||||
data-testid="scopes-filters-input"
|
||||
suffix={
|
||||
scopes.length > 0 && !isDisabled ? (
|
||||
<IconButton
|
||||
aria-label={t('scopes.filters.input.removeAll', 'Remove all scopes')}
|
||||
name="times"
|
||||
onClick={() => onRemoveAllClick()}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (!isDisabled) {
|
||||
onInputClick();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (scopes.length === 0) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={<>{scopesPaths}</>} interactive={true}>
|
||||
{input}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
scopePath: css({
|
||||
color: theme.colors.text.primary,
|
||||
fontSize: theme.typography.pxToRem(14),
|
||||
margin: theme.spacing(1, 0),
|
||||
}),
|
||||
};
|
||||
};
|
@ -12,7 +12,7 @@ import {
|
||||
fetchDashboardsSpy,
|
||||
fetchNodesSpy,
|
||||
fetchScopeSpy,
|
||||
fetchScopesSpy,
|
||||
fetchSelectedScopesSpy,
|
||||
getApplicationsClustersExpand,
|
||||
getApplicationsClustersSelect,
|
||||
getApplicationsExpand,
|
||||
@ -104,7 +104,7 @@ describe('ScopesScene', () => {
|
||||
|
||||
fetchNodesSpy.mockClear();
|
||||
fetchScopeSpy.mockClear();
|
||||
fetchScopesSpy.mockClear();
|
||||
fetchSelectedScopesSpy.mockClear();
|
||||
fetchDashboardsSpy.mockClear();
|
||||
|
||||
dashboardScene = buildTestScene();
|
||||
@ -134,7 +134,12 @@ describe('ScopesScene', () => {
|
||||
});
|
||||
|
||||
it('Selects the proper scopes', async () => {
|
||||
await act(async () => filtersScene.updateScopes(['slothPictureFactory', 'slothVoteTracker']));
|
||||
await act(async () =>
|
||||
filtersScene.updateScopes([
|
||||
{ scopeName: 'slothPictureFactory', path: [] },
|
||||
{ scopeName: 'slothVoteTracker', path: [] },
|
||||
])
|
||||
);
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
expect(getApplicationsSlothVoteTrackerSelect()).toBeChecked();
|
||||
@ -203,7 +208,7 @@ describe('ScopesScene', () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getClustersSelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
await waitFor(() => expect(fetchScopesSpy).toHaveBeenCalled());
|
||||
await waitFor(() => expect(fetchSelectedScopesSpy).toHaveBeenCalled());
|
||||
expect(filtersScene.getSelectedScopes()).toEqual(
|
||||
mocksScopes.filter(({ metadata: { name } }) => name === 'indexHelperCluster')
|
||||
);
|
||||
@ -213,7 +218,7 @@ describe('ScopesScene', () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getClustersSelect());
|
||||
await userEvents.click(getFiltersCancel());
|
||||
await waitFor(() => expect(fetchScopesSpy).not.toHaveBeenCalled());
|
||||
await waitFor(() => expect(fetchSelectedScopesSpy).not.toHaveBeenCalled());
|
||||
expect(filtersScene.getSelectedScopes()).toEqual([]);
|
||||
});
|
||||
|
||||
|
@ -32,7 +32,7 @@ export class ScopesScene extends SceneObjectBase<ScopesSceneState> {
|
||||
this.state.filters.subscribeToState((newState, prevState) => {
|
||||
if (newState.scopes !== prevState.scopes) {
|
||||
if (this.state.isExpanded) {
|
||||
this.state.dashboards.fetchDashboards(newState.scopes);
|
||||
this.state.dashboards.fetchDashboards(this.state.filters.getSelectedScopes());
|
||||
}
|
||||
|
||||
sceneGraph.getTimeRange(this.parent!).onRefresh();
|
||||
|
@ -5,15 +5,15 @@ import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Checkbox, Icon, IconButton, Input, useStyles2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { NodesMap } from './types';
|
||||
import { NodesMap, TreeScope } from './types';
|
||||
|
||||
export interface ScopesTreeLevelProps {
|
||||
nodes: NodesMap;
|
||||
nodePath: string[];
|
||||
loadingNodeName: string | undefined;
|
||||
scopeNames: string[];
|
||||
scopes: TreeScope[];
|
||||
onNodeUpdate: (path: string[], isExpanded: boolean, query: string) => void;
|
||||
onNodeSelectToggle: (path: string[]) => void;
|
||||
}
|
||||
@ -22,7 +22,7 @@ export function ScopesTreeLevel({
|
||||
nodes,
|
||||
nodePath,
|
||||
loadingNodeName,
|
||||
scopeNames,
|
||||
scopes,
|
||||
onNodeUpdate,
|
||||
onNodeSelectToggle,
|
||||
}: ScopesTreeLevelProps) {
|
||||
@ -34,6 +34,7 @@ export function ScopesTreeLevel({
|
||||
const childNodesArr = Object.values(childNodes);
|
||||
const isNodeLoading = loadingNodeName === nodeId;
|
||||
|
||||
const scopeNames = scopes.map(({ scopeName }) => scopeName);
|
||||
const anyChildExpanded = childNodesArr.some(({ isExpanded }) => isExpanded);
|
||||
const anyChildSelected = childNodesArr.some(({ linkId }) => linkId && scopeNames.includes(linkId!));
|
||||
|
||||
@ -45,13 +46,19 @@ export function ScopesTreeLevel({
|
||||
<Input
|
||||
prefix={<Icon name="filter" />}
|
||||
className={styles.searchInput}
|
||||
placeholder={t('scopes.tree.search', 'Filter')}
|
||||
placeholder={t('scopes.tree.search', 'Search')}
|
||||
defaultValue={node.query}
|
||||
data-testid={`scopes-tree-${nodeId}-search`}
|
||||
onInput={(evt) => onQueryUpdate(nodePath, true, evt.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!anyChildExpanded && !node.query && (
|
||||
<h6 className={styles.headline}>
|
||||
<Trans i18nKey="scopes.tree.headline">Recommended</Trans>
|
||||
</h6>
|
||||
)}
|
||||
|
||||
<div role="tree">
|
||||
{isNodeLoading && <Skeleton count={5} className={styles.loader} />}
|
||||
|
||||
@ -102,7 +109,7 @@ export function ScopesTreeLevel({
|
||||
nodes={node.nodes}
|
||||
nodePath={childNodePath}
|
||||
loadingNodeName={loadingNodeName}
|
||||
scopeNames={scopeNames}
|
||||
scopes={scopes}
|
||||
onNodeUpdate={onNodeUpdate}
|
||||
onNodeSelectToggle={onNodeSelectToggle}
|
||||
/>
|
||||
@ -121,6 +128,10 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
searchInput: css({
|
||||
margin: theme.spacing(1, 0),
|
||||
}),
|
||||
headline: css({
|
||||
color: theme.colors.text.secondary,
|
||||
margin: theme.spacing(1, 0),
|
||||
}),
|
||||
loader: css({
|
||||
margin: theme.spacing(0.5, 0),
|
||||
}),
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Scope, ScopeSpec, ScopeNode, ScopeDashboardBinding } from '@grafana/data';
|
||||
import { config, getBackendSrv } from '@grafana/runtime';
|
||||
import { ScopedResourceClient } from 'app/features/apiserver/client';
|
||||
import { NodesMap } from 'app/features/dashboard-scene/scene/Scopes/types';
|
||||
|
||||
import { NodesMap, SelectedScope, TreeScope } from './types';
|
||||
|
||||
const group = 'scope.grafana.app';
|
||||
const version = 'v0alpha1';
|
||||
@ -90,6 +91,19 @@ export async function fetchScopes(names: string[]): Promise<Scope[]> {
|
||||
return await Promise.all(names.map(fetchScope));
|
||||
}
|
||||
|
||||
export async function fetchSelectedScopes(treeScopes: TreeScope[]): Promise<SelectedScope[]> {
|
||||
const scopes = await fetchScopes(treeScopes.map(({ scopeName }) => scopeName));
|
||||
|
||||
return scopes.reduce<SelectedScope[]>((acc, scope, idx) => {
|
||||
acc.push({
|
||||
scope,
|
||||
path: treeScopes[idx].path,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export async function fetchDashboards(scopes: Scope[]): Promise<ScopeDashboardBinding[]> {
|
||||
try {
|
||||
const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(dashboardsEndpoint, {
|
||||
|
@ -225,7 +225,7 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
|
||||
|
||||
export const fetchNodesSpy = jest.spyOn(api, 'fetchNodes');
|
||||
export const fetchScopeSpy = jest.spyOn(api, 'fetchScope');
|
||||
export const fetchScopesSpy = jest.spyOn(api, 'fetchScopes');
|
||||
export const fetchSelectedScopesSpy = jest.spyOn(api, 'fetchSelectedScopes');
|
||||
export const fetchDashboardsSpy = jest.spyOn(api, 'fetchDashboards');
|
||||
|
||||
const selectors = {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ScopeNodeSpec } from '@grafana/data';
|
||||
import { Scope, ScopeNodeSpec } from '@grafana/data';
|
||||
|
||||
export interface Node extends ScopeNodeSpec {
|
||||
name: string;
|
||||
@ -10,3 +10,13 @@ export interface Node extends ScopeNodeSpec {
|
||||
}
|
||||
|
||||
export type NodesMap = Record<string, Node>;
|
||||
|
||||
export interface SelectedScope {
|
||||
scope: Scope;
|
||||
path: string[];
|
||||
}
|
||||
|
||||
export interface TreeScope {
|
||||
scopeName: string;
|
||||
path: string[];
|
||||
}
|
||||
|
@ -1606,7 +1606,7 @@
|
||||
},
|
||||
"suggestedDashboards": {
|
||||
"loading": "Loading dashboards",
|
||||
"search": "Filter",
|
||||
"search": "Search",
|
||||
"toggle": {
|
||||
"collapse": "Collapse scope filters",
|
||||
"expand": "Expand scope filters"
|
||||
@ -1615,7 +1615,8 @@
|
||||
"tree": {
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"search": "Filter"
|
||||
"headline": "Recommended",
|
||||
"search": "Search"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
|
@ -1606,7 +1606,7 @@
|
||||
},
|
||||
"suggestedDashboards": {
|
||||
"loading": "Ŀőäđįʼnģ đäşĥþőäřđş",
|
||||
"search": "Fįľŧęř",
|
||||
"search": "Ŝęäřčĥ",
|
||||
"toggle": {
|
||||
"collapse": "Cőľľäpşę şčőpę ƒįľŧęřş",
|
||||
"expand": "Ēχpäʼnđ şčőpę ƒįľŧęřş"
|
||||
@ -1615,7 +1615,8 @@
|
||||
"tree": {
|
||||
"collapse": "Cőľľäpşę",
|
||||
"expand": "Ēχpäʼnđ",
|
||||
"search": "Fįľŧęř"
|
||||
"headline": "Ŗęčőmmęʼnđęđ",
|
||||
"search": "Ŝęäřčĥ"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
|
Loading…
Reference in New Issue
Block a user