Scopes: QoL UI fixes (#89158)

This commit is contained in:
Bogdan Matei 2024-06-17 13:00:20 +03:00 committed by GitHub
parent ab2af9b8f7
commit 67f2d93281
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 228 additions and 68 deletions

View File

@ -74,7 +74,7 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<Sco
<div className={styles.searchInputContainer}> <div className={styles.searchInputContainer}>
<Input <Input
prefix={<Icon name="search" />} prefix={<Icon name="search" />}
placeholder={t('scopes.suggestedDashboards.search', 'Filter')} placeholder={t('scopes.suggestedDashboards.search', 'Search')}
disabled={isLoading} disabled={isLoading}
data-testid="scopes-dashboards-search" data-testid="scopes-dashboards-search"
onChange={(evt) => model.changeSearchQuery(evt.currentTarget.value)} onChange={(evt) => model.changeSearchQuery(evt.currentTarget.value)}

View File

@ -13,19 +13,20 @@ import {
SceneObjectUrlValues, SceneObjectUrlValues,
SceneObjectWithUrlSync, SceneObjectWithUrlSync,
} from '@grafana/scenes'; } 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 { t, Trans } from 'app/core/internationalization';
import { ScopesInput } from './ScopesInput';
import { ScopesScene } from './ScopesScene'; import { ScopesScene } from './ScopesScene';
import { ScopesTreeLevel } from './ScopesTreeLevel'; import { ScopesTreeLevel } from './ScopesTreeLevel';
import { fetchNodes, fetchScope, fetchScopes } from './api'; import { fetchNodes, fetchScope, fetchSelectedScopes } from './api';
import { NodesMap } from './types'; import { NodesMap, SelectedScope, TreeScope } from './types';
export interface ScopesFiltersSceneState extends SceneObjectState { export interface ScopesFiltersSceneState extends SceneObjectState {
nodes: NodesMap; nodes: NodesMap;
loadingNodeName: string | undefined; loadingNodeName: string | undefined;
scopes: Scope[]; scopes: SelectedScope[];
dirtyScopeNames: string[]; treeScopes: TreeScope[];
isLoadingScopes: boolean; isLoadingScopes: boolean;
isOpened: boolean; isOpened: boolean;
} }
@ -57,7 +58,7 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
}, },
loadingNodeName: undefined, loadingNodeName: undefined,
scopes: [], scopes: [],
dirtyScopeNames: [], treeScopes: [],
isLoadingScopes: false, isLoadingScopes: false,
isOpened: false, isOpened: false,
}); });
@ -72,14 +73,16 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
} }
public getUrlState() { public getUrlState() {
return { scopes: this.getScopeNames() }; return {
scopes: this.state.scopes.map(({ scope }) => scope.metadata.name),
};
} }
public updateFromUrl(values: SceneObjectUrlValues) { public updateFromUrl(values: SceneObjectUrlValues) {
let dirtyScopeNames = values.scopes ?? []; let scopeNames = values.scopes ?? [];
dirtyScopeNames = Array.isArray(dirtyScopeNames) ? dirtyScopeNames : [dirtyScopeNames]; scopeNames = Array.isArray(scopeNames) ? scopeNames : [scopeNames];
this.updateScopes(dirtyScopeNames); this.updateScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] })));
} }
public fetchBaseNodes() { public fetchBaseNodes() {
@ -126,7 +129,7 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
} }
public toggleNodeSelect(path: string[]) { public toggleNodeSelect(path: string[]) {
let dirtyScopeNames = [...this.state.dirtyScopeNames]; let treeScopes = [...this.state.treeScopes];
let siblings = this.state.nodes; let siblings = this.state.nodes;
@ -134,22 +137,27 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
siblings = siblings[path[idx]].nodes; siblings = siblings[path[idx]].nodes;
} }
const name = path[path.length - 1]; const nodeName = path[path.length - 1];
const { linkId } = siblings[name]; const { linkId } = siblings[nodeName];
const selectedIdx = dirtyScopeNames.findIndex((scopeName) => scopeName === linkId); const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId);
if (selectedIdx === -1) { if (selectedIdx === -1) {
fetchScope(linkId!); fetchScope(linkId!);
const selectedFromSameNode = 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 { } 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[] { public getSelectedScopes(): Scope[] {
return this.state.scopes; return this.state.scopes.map(({ scope }) => scope);
} }
public async updateScopes(dirtyScopeNames = this.state.dirtyScopeNames) { public async updateScopes(treeScopes = this.state.treeScopes) {
if (isEqual(dirtyScopeNames, this.getScopeNames())) { if (isEqual(treeScopes, this.getTreeScopes())) {
return; 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() { public resetDirtyScopeNames() {
this.setState({ dirtyScopeNames: this.getScopeNames() }); this.setState({ treeScopes: this.getTreeScopes() });
} }
public removeAllScopes() { public removeAllScopes() {
this.setState({ scopes: [], dirtyScopeNames: [], isLoadingScopes: false }); this.setState({ scopes: [], treeScopes: [], isLoadingScopes: false });
} }
public enterViewMode() { public enterViewMode() {
this.setState({ isOpened: false }); this.setState({ isOpened: false });
} }
private getScopeNames(): string[] { private getTreeScopes(): TreeScope[] {
return this.state.scopes.map(({ metadata: { name } }) => name); return this.state.scopes.map(({ scope, path }) => ({
scopeName: scope.metadata.name,
path,
}));
} }
} }
export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<ScopesFiltersScene>) { export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<ScopesFiltersScene>) {
const styles = useStyles2(getStyles); 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 { isViewing } = model.scopesParent.useState();
const scopesTitles = scopes.map(({ spec: { title } }) => title).join(', ');
return ( return (
<> <>
<Input <ScopesInput
readOnly nodes={nodes}
placeholder={t('scopes.filters.input.placeholder', 'Select scopes...')} scopes={scopes}
loading={isLoadingScopes} isDisabled={isViewing}
value={scopesTitles} isLoading={isLoadingScopes}
aria-label={t('scopes.filters.input.placeholder', 'Select scopes...')} onInputClick={() => model.open()}
data-testid="scopes-filters-input" onRemoveAllClick={() => model.removeAllScopes()}
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()}
/> />
{isOpened && ( {isOpened && (
@ -238,7 +237,7 @@ export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<Scopes
nodes={nodes} nodes={nodes}
nodePath={['']} nodePath={['']}
loadingNodeName={loadingNodeName} loadingNodeName={loadingNodeName}
scopeNames={dirtyScopeNames} scopes={treeScopes}
onNodeUpdate={(path, isExpanded, query) => model.updateNode(path, isExpanded, query)} onNodeUpdate={(path, isExpanded, query) => model.updateNode(path, isExpanded, query)}
onNodeSelectToggle={(path) => model.toggleNodeSelect(path)} onNodeSelectToggle={(path) => model.toggleNodeSelect(path)}
/> />

View 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),
}),
};
};

View File

@ -12,7 +12,7 @@ import {
fetchDashboardsSpy, fetchDashboardsSpy,
fetchNodesSpy, fetchNodesSpy,
fetchScopeSpy, fetchScopeSpy,
fetchScopesSpy, fetchSelectedScopesSpy,
getApplicationsClustersExpand, getApplicationsClustersExpand,
getApplicationsClustersSelect, getApplicationsClustersSelect,
getApplicationsExpand, getApplicationsExpand,
@ -104,7 +104,7 @@ describe('ScopesScene', () => {
fetchNodesSpy.mockClear(); fetchNodesSpy.mockClear();
fetchScopeSpy.mockClear(); fetchScopeSpy.mockClear();
fetchScopesSpy.mockClear(); fetchSelectedScopesSpy.mockClear();
fetchDashboardsSpy.mockClear(); fetchDashboardsSpy.mockClear();
dashboardScene = buildTestScene(); dashboardScene = buildTestScene();
@ -134,7 +134,12 @@ describe('ScopesScene', () => {
}); });
it('Selects the proper scopes', async () => { 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(getFiltersInput());
await userEvents.click(getApplicationsExpand()); await userEvents.click(getApplicationsExpand());
expect(getApplicationsSlothVoteTrackerSelect()).toBeChecked(); expect(getApplicationsSlothVoteTrackerSelect()).toBeChecked();
@ -203,7 +208,7 @@ describe('ScopesScene', () => {
await userEvents.click(getFiltersInput()); await userEvents.click(getFiltersInput());
await userEvents.click(getClustersSelect()); await userEvents.click(getClustersSelect());
await userEvents.click(getFiltersApply()); await userEvents.click(getFiltersApply());
await waitFor(() => expect(fetchScopesSpy).toHaveBeenCalled()); await waitFor(() => expect(fetchSelectedScopesSpy).toHaveBeenCalled());
expect(filtersScene.getSelectedScopes()).toEqual( expect(filtersScene.getSelectedScopes()).toEqual(
mocksScopes.filter(({ metadata: { name } }) => name === 'indexHelperCluster') mocksScopes.filter(({ metadata: { name } }) => name === 'indexHelperCluster')
); );
@ -213,7 +218,7 @@ describe('ScopesScene', () => {
await userEvents.click(getFiltersInput()); await userEvents.click(getFiltersInput());
await userEvents.click(getClustersSelect()); await userEvents.click(getClustersSelect());
await userEvents.click(getFiltersCancel()); await userEvents.click(getFiltersCancel());
await waitFor(() => expect(fetchScopesSpy).not.toHaveBeenCalled()); await waitFor(() => expect(fetchSelectedScopesSpy).not.toHaveBeenCalled());
expect(filtersScene.getSelectedScopes()).toEqual([]); expect(filtersScene.getSelectedScopes()).toEqual([]);
}); });

View File

@ -32,7 +32,7 @@ export class ScopesScene extends SceneObjectBase<ScopesSceneState> {
this.state.filters.subscribeToState((newState, prevState) => { this.state.filters.subscribeToState((newState, prevState) => {
if (newState.scopes !== prevState.scopes) { if (newState.scopes !== prevState.scopes) {
if (this.state.isExpanded) { if (this.state.isExpanded) {
this.state.dashboards.fetchDashboards(newState.scopes); this.state.dashboards.fetchDashboards(this.state.filters.getSelectedScopes());
} }
sceneGraph.getTimeRange(this.parent!).onRefresh(); sceneGraph.getTimeRange(this.parent!).onRefresh();

View File

@ -5,15 +5,15 @@ import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Checkbox, Icon, IconButton, Input, useStyles2 } from '@grafana/ui'; 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 { export interface ScopesTreeLevelProps {
nodes: NodesMap; nodes: NodesMap;
nodePath: string[]; nodePath: string[];
loadingNodeName: string | undefined; loadingNodeName: string | undefined;
scopeNames: string[]; scopes: TreeScope[];
onNodeUpdate: (path: string[], isExpanded: boolean, query: string) => void; onNodeUpdate: (path: string[], isExpanded: boolean, query: string) => void;
onNodeSelectToggle: (path: string[]) => void; onNodeSelectToggle: (path: string[]) => void;
} }
@ -22,7 +22,7 @@ export function ScopesTreeLevel({
nodes, nodes,
nodePath, nodePath,
loadingNodeName, loadingNodeName,
scopeNames, scopes,
onNodeUpdate, onNodeUpdate,
onNodeSelectToggle, onNodeSelectToggle,
}: ScopesTreeLevelProps) { }: ScopesTreeLevelProps) {
@ -34,6 +34,7 @@ export function ScopesTreeLevel({
const childNodesArr = Object.values(childNodes); const childNodesArr = Object.values(childNodes);
const isNodeLoading = loadingNodeName === nodeId; const isNodeLoading = loadingNodeName === nodeId;
const scopeNames = scopes.map(({ scopeName }) => scopeName);
const anyChildExpanded = childNodesArr.some(({ isExpanded }) => isExpanded); const anyChildExpanded = childNodesArr.some(({ isExpanded }) => isExpanded);
const anyChildSelected = childNodesArr.some(({ linkId }) => linkId && scopeNames.includes(linkId!)); const anyChildSelected = childNodesArr.some(({ linkId }) => linkId && scopeNames.includes(linkId!));
@ -45,13 +46,19 @@ export function ScopesTreeLevel({
<Input <Input
prefix={<Icon name="filter" />} prefix={<Icon name="filter" />}
className={styles.searchInput} className={styles.searchInput}
placeholder={t('scopes.tree.search', 'Filter')} placeholder={t('scopes.tree.search', 'Search')}
defaultValue={node.query} defaultValue={node.query}
data-testid={`scopes-tree-${nodeId}-search`} data-testid={`scopes-tree-${nodeId}-search`}
onInput={(evt) => onQueryUpdate(nodePath, true, evt.currentTarget.value)} 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"> <div role="tree">
{isNodeLoading && <Skeleton count={5} className={styles.loader} />} {isNodeLoading && <Skeleton count={5} className={styles.loader} />}
@ -102,7 +109,7 @@ export function ScopesTreeLevel({
nodes={node.nodes} nodes={node.nodes}
nodePath={childNodePath} nodePath={childNodePath}
loadingNodeName={loadingNodeName} loadingNodeName={loadingNodeName}
scopeNames={scopeNames} scopes={scopes}
onNodeUpdate={onNodeUpdate} onNodeUpdate={onNodeUpdate}
onNodeSelectToggle={onNodeSelectToggle} onNodeSelectToggle={onNodeSelectToggle}
/> />
@ -121,6 +128,10 @@ const getStyles = (theme: GrafanaTheme2) => {
searchInput: css({ searchInput: css({
margin: theme.spacing(1, 0), margin: theme.spacing(1, 0),
}), }),
headline: css({
color: theme.colors.text.secondary,
margin: theme.spacing(1, 0),
}),
loader: css({ loader: css({
margin: theme.spacing(0.5, 0), margin: theme.spacing(0.5, 0),
}), }),

View File

@ -1,7 +1,8 @@
import { Scope, ScopeSpec, ScopeNode, ScopeDashboardBinding } from '@grafana/data'; import { Scope, ScopeSpec, ScopeNode, ScopeDashboardBinding } from '@grafana/data';
import { config, getBackendSrv } from '@grafana/runtime'; import { config, getBackendSrv } from '@grafana/runtime';
import { ScopedResourceClient } from 'app/features/apiserver/client'; 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 group = 'scope.grafana.app';
const version = 'v0alpha1'; const version = 'v0alpha1';
@ -90,6 +91,19 @@ export async function fetchScopes(names: string[]): Promise<Scope[]> {
return await Promise.all(names.map(fetchScope)); 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[]> { export async function fetchDashboards(scopes: Scope[]): Promise<ScopeDashboardBinding[]> {
try { try {
const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(dashboardsEndpoint, { const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(dashboardsEndpoint, {

View File

@ -225,7 +225,7 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
export const fetchNodesSpy = jest.spyOn(api, 'fetchNodes'); export const fetchNodesSpy = jest.spyOn(api, 'fetchNodes');
export const fetchScopeSpy = jest.spyOn(api, 'fetchScope'); 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'); export const fetchDashboardsSpy = jest.spyOn(api, 'fetchDashboards');
const selectors = { const selectors = {

View File

@ -1,4 +1,4 @@
import { ScopeNodeSpec } from '@grafana/data'; import { Scope, ScopeNodeSpec } from '@grafana/data';
export interface Node extends ScopeNodeSpec { export interface Node extends ScopeNodeSpec {
name: string; name: string;
@ -10,3 +10,13 @@ export interface Node extends ScopeNodeSpec {
} }
export type NodesMap = Record<string, Node>; export type NodesMap = Record<string, Node>;
export interface SelectedScope {
scope: Scope;
path: string[];
}
export interface TreeScope {
scopeName: string;
path: string[];
}

View File

@ -1606,7 +1606,7 @@
}, },
"suggestedDashboards": { "suggestedDashboards": {
"loading": "Loading dashboards", "loading": "Loading dashboards",
"search": "Filter", "search": "Search",
"toggle": { "toggle": {
"collapse": "Collapse scope filters", "collapse": "Collapse scope filters",
"expand": "Expand scope filters" "expand": "Expand scope filters"
@ -1615,7 +1615,8 @@
"tree": { "tree": {
"collapse": "Collapse", "collapse": "Collapse",
"expand": "Expand", "expand": "Expand",
"search": "Filter" "headline": "Recommended",
"search": "Search"
} }
}, },
"search": { "search": {

View File

@ -1606,7 +1606,7 @@
}, },
"suggestedDashboards": { "suggestedDashboards": {
"loading": "Ŀőäđįʼnģ đäşĥþőäřđş", "loading": "Ŀőäđįʼnģ đäşĥþőäřđş",
"search": "Fįľŧęř", "search": "Ŝęäřčĥ",
"toggle": { "toggle": {
"collapse": "Cőľľäpşę şčőpę ƒįľŧęřş", "collapse": "Cőľľäpşę şčőpę ƒįľŧęřş",
"expand": "Ēχpäʼnđ şčőpę ƒįľŧęřş" "expand": "Ēχpäʼnđ şčőpę ƒįľŧęřş"
@ -1615,7 +1615,8 @@
"tree": { "tree": {
"collapse": "Cőľľäpşę", "collapse": "Cőľľäpşę",
"expand": "Ēχpäʼnđ", "expand": "Ēχpäʼnđ",
"search": "Fįľŧęř" "headline": "Ŗęčőmmęʼnđęđ",
"search": "Ŝęäřčĥ"
} }
}, },
"search": { "search": {