Scopes: Persist selected scopes when searching (#89758)

This commit is contained in:
Bogdan Matei 2024-07-01 13:46:45 +03:00 committed by GitHub
parent c0058f9c7e
commit 4c9fef6183
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 672 additions and 358 deletions

View File

@ -17,9 +17,9 @@ import { t, Trans } from 'app/core/internationalization';
import { ScopesInput } from './ScopesInput';
import { ScopesScene } from './ScopesScene';
import { ScopesTreeLevel } from './ScopesTreeLevel';
import { ScopesTree } from './ScopesTree';
import { fetchNodes, fetchScope, fetchSelectedScopes } from './api';
import { NodesMap, SelectedScope, TreeScope } from './types';
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types';
import { getBasicScope } from './utils';
export interface ScopesFiltersSceneState extends SceneObjectState {
@ -47,6 +47,7 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
nodes: {
'': {
name: '',
reason: NodeReason.Result,
nodeType: 'container',
title: '',
isExpandable: true,
@ -119,7 +120,19 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
})
)
.subscribe((childNodes) => {
currentNode.nodes = childNodes;
const persistedNodes = this.state.treeScopes
.map(({ path }) => path[path.length - 1])
.filter((nodeName) => nodeName in currentNode.nodes && !(nodeName in childNodes))
.reduce<NodesMap>((acc, nodeName) => {
acc[nodeName] = {
...currentNode.nodes[nodeName],
reason: NodeReason.Persisted,
};
return acc;
}, {});
currentNode.nodes = { ...persistedNodes, ...childNodes };
this.setState({ nodes });
@ -284,7 +297,7 @@ export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<Scopes
{isLoadingScopes ? (
<Spinner data-testid="scopes-filters-loading" />
) : (
<ScopesTreeLevel
<ScopesTree
nodes={nodes}
nodePath={['']}
loadingNodeName={loadingNodeName}

View File

@ -1,10 +1,9 @@
import { css } from '@emotion/css';
import { groupBy } from 'lodash';
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, Input, Tooltip } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui/';
import { IconButton, Input, Tooltip, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { NodesMap, SelectedScope } from './types';
@ -28,6 +27,12 @@ export function ScopesInput({
}: ScopesInputProps) {
const styles = useStyles2(getStyles);
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
useEffect(() => {
setIsTooltipVisible(false);
}, [scopes]);
const scopesPaths = useMemo(() => {
const pathsTitles = scopes.map(({ scope, path }) => {
let currentLevel = nodes;
@ -64,7 +69,7 @@ export function ScopesInput({
const groupedByPath = groupBy(pathsTitles, ([path]) => path);
return Object.entries(groupedByPath)
const scopesPaths = Object.entries(groupedByPath)
.map(([path, pathScopes]) => {
const scopesTitles = pathScopes.map(([, scopeTitle]) => scopeTitle).join(', ');
@ -75,41 +80,44 @@ export function ScopesInput({
{path}
</p>
));
return <>{scopesPaths}</>;
}, [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();
const input = useMemo(
() => (
<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
}
}}
/>
onMouseOver={() => setIsTooltipVisible(true)}
onMouseOut={() => setIsTooltipVisible(false)}
onClick={() => {
if (!isDisabled) {
onInputClick();
}
}}
/>
),
[isDisabled, isLoading, onInputClick, onRemoveAllClick, scopes, scopesTitles]
);
if (scopes.length === 0) {
return input;
}
return (
<Tooltip content={<>{scopesPaths}</>} interactive={true}>
<Tooltip content={scopesPaths} show={scopes.length === 0 ? false : isTooltipVisible}>
{input}
</Tooltip>
);

View File

@ -10,47 +10,54 @@ import { ScopesFiltersScene } from './ScopesFiltersScene';
import { ScopesScene } from './ScopesScene';
import {
buildTestScene,
fetchSuggestedDashboardsSpy,
fetchNodesSpy,
fetchScopeSpy,
fetchSelectedScopesSpy,
getApplicationsClustersExpand,
getApplicationsClustersSelect,
getApplicationsClustersSlothClusterNorthSelect,
getApplicationsClustersSlothClusterSouthSelect,
getApplicationsExpand,
getApplicationsSearch,
getApplicationsSlothPictureFactorySelect,
getApplicationsSlothPictureFactoryTitle,
getApplicationsSlothVoteTrackerSelect,
getFiltersApply,
getFiltersCancel,
getFiltersInput,
getClustersExpand,
getClustersSelect,
getClustersSlothClusterNorthRadio,
getClustersSlothClusterSouthRadio,
fetchSuggestedDashboardsSpy,
getDashboard,
getDashboardsContainer,
getDashboardsExpand,
getDashboardsSearch,
getFiltersApply,
getFiltersCancel,
getFiltersInput,
getMock,
getNotFoundForFilter,
getNotFoundForFilterClear,
getNotFoundForScope,
getNotFoundNoScopes,
getPersistedApplicationsSlothPictureFactorySelect,
getPersistedApplicationsSlothPictureFactoryTitle,
getPersistedApplicationsSlothVoteTrackerTitle,
getResultApplicationsClustersExpand,
getResultApplicationsClustersSelect,
getResultApplicationsClustersSlothClusterNorthSelect,
getResultApplicationsClustersSlothClusterSouthSelect,
getResultApplicationsExpand,
getResultApplicationsSlothPictureFactorySelect,
getResultApplicationsSlothPictureFactoryTitle,
getResultApplicationsSlothVoteTrackerSelect,
getResultApplicationsSlothVoteTrackerTitle,
getResultClustersExpand,
getResultClustersSelect,
getResultClustersSlothClusterEastRadio,
getResultClustersSlothClusterNorthRadio,
getResultClustersSlothClusterSouthRadio,
getTreeHeadline,
getTreeSearch,
mocksScopes,
queryAllDashboard,
queryFiltersApply,
queryApplicationsClustersTitle,
queryApplicationsSlothPictureFactoryTitle,
queryApplicationsSlothVoteTrackerTitle,
queryDashboard,
queryDashboardsContainer,
queryDashboardsExpand,
renderDashboard,
getNotFoundForScope,
queryDashboardsSearch,
getNotFoundForFilter,
getClustersSlothClusterEastRadio,
getNotFoundForFilterClear,
getNotFoundNoScopes,
queryFiltersApply,
queryPersistedApplicationsSlothPictureFactoryTitle,
queryPersistedApplicationsSlothVoteTrackerTitle,
queryResultApplicationsClustersTitle,
queryResultApplicationsSlothPictureFactoryTitle,
queryResultApplicationsSlothVoteTrackerTitle,
renderDashboard,
} from './testUtils';
jest.mock('@grafana/runtime', () => ({
@ -106,15 +113,15 @@ describe('ScopesScene', () => {
describe('Tree', () => {
it('Navigates through scopes nodes', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsClustersExpand());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsClustersExpand());
await userEvents.click(getResultApplicationsExpand());
});
it('Fetches scope details on select', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
await waitFor(() => expect(fetchScopeSpy).toHaveBeenCalledTimes(1));
});
@ -126,77 +133,167 @@ describe('ScopesScene', () => {
])
);
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
expect(getApplicationsSlothVoteTrackerSelect()).toBeChecked();
expect(getApplicationsSlothPictureFactorySelect()).toBeChecked();
await userEvents.click(getResultApplicationsExpand());
expect(getResultApplicationsSlothVoteTrackerSelect()).toBeChecked();
expect(getResultApplicationsSlothPictureFactorySelect()).toBeChecked();
});
it('Can select scopes from same level', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getApplicationsClustersSelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsClustersSelect());
await userEvents.click(getFiltersApply());
expect(getFiltersInput().value).toBe('slothVoteTracker, slothPictureFactory, Cluster Index Helper');
});
it('Can select a node from an inner level', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
await userEvents.click(getApplicationsClustersExpand());
await userEvents.click(getApplicationsClustersSlothClusterNorthSelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
await userEvents.click(getResultApplicationsClustersExpand());
await userEvents.click(getResultApplicationsClustersSlothClusterNorthSelect());
await userEvents.click(getFiltersApply());
expect(getFiltersInput().value).toBe('slothClusterNorth');
});
it('Can select a node from an upper level', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getClustersSelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultClustersSelect());
await userEvents.click(getFiltersApply());
expect(getFiltersInput().value).toBe('Cluster Index Helper');
});
it('Respects only one select per container', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getClustersExpand());
await userEvents.click(getClustersSlothClusterNorthRadio());
expect(getClustersSlothClusterNorthRadio().checked).toBe(true);
expect(getClustersSlothClusterSouthRadio().checked).toBe(false);
await userEvents.click(getClustersSlothClusterSouthRadio());
expect(getClustersSlothClusterNorthRadio().checked).toBe(false);
expect(getClustersSlothClusterSouthRadio().checked).toBe(true);
await userEvents.click(getResultClustersExpand());
await userEvents.click(getResultClustersSlothClusterNorthRadio());
expect(getResultClustersSlothClusterNorthRadio().checked).toBe(true);
expect(getResultClustersSlothClusterSouthRadio().checked).toBe(false);
await userEvents.click(getResultClustersSlothClusterSouthRadio());
expect(getResultClustersSlothClusterNorthRadio().checked).toBe(false);
expect(getResultClustersSlothClusterSouthRadio().checked).toBe(true);
});
it('Search works', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.type(getApplicationsSearch(), 'Clusters');
await userEvents.click(getResultApplicationsExpand());
await userEvents.type(getTreeSearch(), 'Clusters');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
expect(queryApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
expect(queryApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument();
expect(getApplicationsClustersSelect()).toBeInTheDocument();
await userEvents.clear(getApplicationsSearch());
await userEvents.type(getApplicationsSearch(), 'sloth');
expect(queryResultApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
expect(queryResultApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument();
expect(getResultApplicationsClustersSelect()).toBeInTheDocument();
await userEvents.clear(getTreeSearch());
await userEvents.type(getTreeSearch(), 'sloth');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4));
expect(getApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
expect(getApplicationsSlothVoteTrackerSelect()).toBeInTheDocument();
expect(queryApplicationsClustersTitle()).not.toBeInTheDocument();
expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
expect(getResultApplicationsSlothVoteTrackerSelect()).toBeInTheDocument();
expect(queryResultApplicationsClustersTitle()).not.toBeInTheDocument();
});
it('Opens to a selected scope', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getClustersExpand());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultClustersExpand());
await userEvents.click(getFiltersApply());
await userEvents.click(getFiltersInput());
expect(queryApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
expect(queryResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
});
it('Persists a scope', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
expect(getPersistedApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
expect(queryPersistedApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument();
expect(queryResultApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument();
});
it('Does not persist a retrieved scope', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.type(getTreeSearch(), 'slothPictureFactory');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
expect(queryPersistedApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
});
it('Removes persisted nodes', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
await userEvents.clear(getTreeSearch());
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4));
expect(queryPersistedApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
expect(queryPersistedApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument();
expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument();
});
it('Persists nodes from search', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.type(getTreeSearch(), 'sloth');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
await userEvents.type(getTreeSearch(), 'slothunknown');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4));
expect(getPersistedApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
expect(getPersistedApplicationsSlothVoteTrackerTitle()).toBeInTheDocument();
await userEvents.clear(getTreeSearch());
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(5));
expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument();
});
it('Selects a persisted scope', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
await userEvents.click(getFiltersApply());
expect(getFiltersInput().value).toBe('slothPictureFactory, slothVoteTracker');
});
it('Deselects a persisted scope', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
await userEvents.click(getFiltersApply());
expect(getFiltersInput().value).toBe('slothPictureFactory, slothVoteTracker');
await userEvents.click(getFiltersInput());
await userEvents.click(getPersistedApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
expect(getFiltersInput().value).toBe('slothVoteTracker');
});
it('Shows the proper headline', async () => {
await userEvents.click(getFiltersInput());
expect(getTreeHeadline()).toHaveTextContent('Recommended');
await userEvents.type(getTreeSearch(), 'Applications');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(2));
expect(getTreeHeadline()).toHaveTextContent('Results');
await userEvents.type(getTreeSearch(), 'unknown');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
expect(getTreeHeadline()).toHaveTextContent('No results found for your query');
});
});
@ -208,7 +305,7 @@ describe('ScopesScene', () => {
it('Fetches scope details on save', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getClustersSelect());
await userEvents.click(getResultClustersSelect());
await userEvents.click(getFiltersApply());
await waitFor(() => expect(fetchSelectedScopesSpy).toHaveBeenCalled());
expect(filtersScene.getSelectedScopes()).toEqual(
@ -218,7 +315,7 @@ describe('ScopesScene', () => {
it("Doesn't save the scopes on close", async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getClustersSelect());
await userEvents.click(getResultClustersSelect());
await userEvents.click(getFiltersCancel());
await waitFor(() => expect(fetchSelectedScopesSpy).not.toHaveBeenCalled());
expect(filtersScene.getSelectedScopes()).toEqual([]);
@ -226,7 +323,7 @@ describe('ScopesScene', () => {
it('Shows selected scopes', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getClustersSelect());
await userEvents.click(getResultClustersSelect());
await userEvents.click(getFiltersApply());
expect(getFiltersInput().value).toEqual('Cluster Index Helper');
});
@ -240,8 +337,8 @@ describe('ScopesScene', () => {
it('Does not fetch dashboards list when the list is not expanded', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
await waitFor(() => expect(fetchSuggestedDashboardsSpy).not.toHaveBeenCalled());
});
@ -249,16 +346,16 @@ describe('ScopesScene', () => {
it('Fetches dashboards list when the list is expanded', async () => {
await userEvents.click(getDashboardsExpand());
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled());
});
it('Fetches dashboards list when the list is expanded after scope selection', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
await userEvents.click(getDashboardsExpand());
await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled());
@ -267,22 +364,22 @@ describe('ScopesScene', () => {
it('Shows dashboards for multiple scopes', async () => {
await userEvents.click(getDashboardsExpand());
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
expect(getDashboard('1')).toBeInTheDocument();
expect(getDashboard('2')).toBeInTheDocument();
expect(queryDashboard('3')).not.toBeInTheDocument();
expect(queryDashboard('4')).not.toBeInTheDocument();
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
await userEvents.click(getFiltersApply());
expect(getDashboard('1')).toBeInTheDocument();
expect(getDashboard('2')).toBeInTheDocument();
expect(getDashboard('3')).toBeInTheDocument();
expect(getDashboard('4')).toBeInTheDocument();
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
expect(queryDashboard('1')).not.toBeInTheDocument();
expect(queryDashboard('2')).not.toBeInTheDocument();
@ -293,8 +390,8 @@ describe('ScopesScene', () => {
it('Filters the dashboards list', async () => {
await userEvents.click(getDashboardsExpand());
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
expect(getDashboard('1')).toBeInTheDocument();
expect(getDashboard('2')).toBeInTheDocument();
@ -305,10 +402,10 @@ describe('ScopesScene', () => {
it('Deduplicates the dashboards list', async () => {
await userEvents.click(getDashboardsExpand());
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsClustersExpand());
await userEvents.click(getApplicationsClustersSlothClusterNorthSelect());
await userEvents.click(getApplicationsClustersSlothClusterSouthSelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsClustersExpand());
await userEvents.click(getResultApplicationsClustersSlothClusterNorthSelect());
await userEvents.click(getResultApplicationsClustersSlothClusterSouthSelect());
await userEvents.click(getFiltersApply());
expect(queryAllDashboard('5')).toHaveLength(1);
expect(queryAllDashboard('6')).toHaveLength(1);
@ -325,8 +422,8 @@ describe('ScopesScene', () => {
it('Does not show the input when there are no dashboards found for scope', async () => {
await userEvents.click(getDashboardsExpand());
await userEvents.click(getFiltersInput());
await userEvents.click(getClustersExpand());
await userEvents.click(getClustersSlothClusterEastRadio());
await userEvents.click(getResultClustersExpand());
await userEvents.click(getResultClustersSlothClusterEastRadio());
await userEvents.click(getFiltersApply());
expect(getNotFoundForScope()).toBeInTheDocument();
expect(queryDashboardsSearch()).not.toBeInTheDocument();
@ -335,8 +432,8 @@ describe('ScopesScene', () => {
it('Does show the input and a message when there are no dashboards found for filter', async () => {
await userEvents.click(getDashboardsExpand());
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
await userEvents.type(getDashboardsSearch(), 'unknown');
expect(queryDashboardsSearch()).toBeInTheDocument();
@ -380,8 +477,8 @@ describe('ScopesScene', () => {
describe('Enrichers', () => {
it('Data requests', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
await waitFor(() => {
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
@ -391,7 +488,7 @@ describe('ScopesScene', () => {
});
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
await userEvents.click(getFiltersApply());
await waitFor(() => {
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
@ -403,7 +500,7 @@ describe('ScopesScene', () => {
});
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
await waitFor(() => {
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
@ -415,8 +512,8 @@ describe('ScopesScene', () => {
it('Filters requests', async () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
await waitFor(() => {
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(
@ -425,7 +522,7 @@ describe('ScopesScene', () => {
});
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
await userEvents.click(getFiltersApply());
await waitFor(() => {
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(
@ -436,7 +533,7 @@ describe('ScopesScene', () => {
});
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
await waitFor(() => {
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(

View File

@ -0,0 +1,81 @@
import { groupBy } from 'lodash';
import { useMemo } from 'react';
import { ScopesTreeHeadline } from './ScopesTreeHeadline';
import { ScopesTreeItem } from './ScopesTreeItem';
import { ScopesTreeLoading } from './ScopesTreeLoading';
import { ScopesTreeSearch } from './ScopesTreeSearch';
import { NodeReason, NodesMap, OnNodeSelectToggle, OnNodeUpdate, TreeScope } from './types';
export interface ScopesTreeProps {
nodes: NodesMap;
nodePath: string[];
loadingNodeName: string | undefined;
scopes: TreeScope[];
onNodeUpdate: OnNodeUpdate;
onNodeSelectToggle: OnNodeSelectToggle;
}
export function ScopesTree({
nodes,
nodePath,
loadingNodeName,
scopes,
onNodeUpdate,
onNodeSelectToggle,
}: ScopesTreeProps) {
const nodeId = nodePath[nodePath.length - 1];
const node = nodes[nodeId];
const childNodes = Object.values(node.nodes);
const isNodeLoading = loadingNodeName === nodeId;
const scopeNames = scopes.map(({ scopeName }) => scopeName);
const anyChildExpanded = childNodes.some(({ isExpanded }) => isExpanded);
const groupedNodes = useMemo(() => groupBy(childNodes, 'reason'), [childNodes]);
return (
<>
<ScopesTreeSearch
anyChildExpanded={anyChildExpanded}
nodePath={nodePath}
query={node.query}
onNodeUpdate={onNodeUpdate}
/>
<ScopesTreeLoading isNodeLoading={isNodeLoading}>
<ScopesTreeItem
anyChildExpanded={anyChildExpanded}
isNodeLoading={isNodeLoading}
loadingNodeName={loadingNodeName}
node={node}
nodePath={nodePath}
nodes={groupedNodes[NodeReason.Persisted] ?? []}
scopes={scopes}
scopeNames={scopeNames}
type="persisted"
onNodeSelectToggle={onNodeSelectToggle}
onNodeUpdate={onNodeUpdate}
/>
<ScopesTreeHeadline
anyChildExpanded={anyChildExpanded}
query={node.query}
resultsNodes={groupedNodes[NodeReason.Result] ?? []}
/>
<ScopesTreeItem
anyChildExpanded={anyChildExpanded}
isNodeLoading={isNodeLoading}
loadingNodeName={loadingNodeName}
node={node}
nodePath={nodePath}
nodes={groupedNodes[NodeReason.Result] ?? []}
scopes={scopes}
scopeNames={scopeNames}
type="result"
onNodeSelectToggle={onNodeSelectToggle}
onNodeUpdate={onNodeUpdate}
/>
</ScopesTreeLoading>
</>
);
}

View File

@ -0,0 +1,42 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { Node } from './types';
export interface ScopesTreeHeadlineProps {
anyChildExpanded: boolean;
query: string;
resultsNodes: Node[];
}
export function ScopesTreeHeadline({ anyChildExpanded, query, resultsNodes }: ScopesTreeHeadlineProps) {
const styles = useStyles2(getStyles);
if (anyChildExpanded) {
return null;
}
return (
<h6 className={styles.container} data-testid="scopes-tree-headline">
{!query ? (
<Trans i18nKey="scopes.tree.headline.recommended">Recommended</Trans>
) : resultsNodes.length === 0 ? (
<Trans i18nKey="scopes.tree.headline.noResults">No results found for your query</Trans>
) : (
<Trans i18nKey="scopes.tree.headline.results">Results</Trans>
)}
</h6>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
color: theme.colors.text.secondary,
margin: theme.spacing(1, 0),
}),
};
};

View File

@ -0,0 +1,143 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Checkbox, Icon, RadioButtonDot, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { ScopesTree } from './ScopesTree';
import { Node, OnNodeSelectToggle, OnNodeUpdate, TreeScope } from './types';
export interface ScopesTreeItemProps {
anyChildExpanded: boolean;
isNodeLoading: boolean;
loadingNodeName: string | undefined;
node: Node;
nodePath: string[];
nodes: Node[];
scopeNames: string[];
scopes: TreeScope[];
type: 'persisted' | 'result';
onNodeUpdate: OnNodeUpdate;
onNodeSelectToggle: OnNodeSelectToggle;
}
export function ScopesTreeItem({
anyChildExpanded,
loadingNodeName,
node,
nodePath,
nodes,
scopeNames,
scopes,
type,
onNodeSelectToggle,
onNodeUpdate,
}: ScopesTreeItemProps) {
const styles = useStyles2(getStyles);
return (
<div role="tree">
{nodes.map((childNode) => {
const isSelected = childNode.isSelectable && scopeNames.includes(childNode.linkId!);
if (anyChildExpanded && !childNode.isExpanded) {
return null;
}
const childNodePath = [...nodePath, childNode.name];
const radioName = childNodePath.join('.');
return (
<div key={childNode.name} role="treeitem" aria-selected={childNode.isExpanded}>
<div className={styles.title}>
{childNode.isSelectable && !childNode.isExpanded ? (
node.disableMultiSelect ? (
<RadioButtonDot
id={radioName}
name={radioName}
checked={isSelected}
label=""
data-testid={`scopes-tree-${type}-${childNode.name}-radio`}
onClick={() => {
onNodeSelectToggle(childNodePath);
}}
/>
) : (
<Checkbox
checked={isSelected}
data-testid={`scopes-tree-${type}-${childNode.name}-checkbox`}
onChange={() => {
onNodeSelectToggle(childNodePath);
}}
/>
)
) : null}
{childNode.isExpandable ? (
<button
className={styles.expand}
data-testid={`scopes-tree-${type}-${childNode.name}-expand`}
aria-label={
childNode.isExpanded ? t('scopes.tree.collapse', 'Collapse') : t('scopes.tree.expand', 'Expand')
}
onClick={() => {
onNodeUpdate(childNodePath, !childNode.isExpanded, childNode.query);
}}
>
<Icon name={!childNode.isExpanded ? 'angle-right' : 'angle-down'} />
{childNode.title}
</button>
) : (
<span data-testid={`scopes-tree-${type}-${childNode.name}-title`}>{childNode.title}</span>
)}
</div>
<div className={styles.children}>
{childNode.isExpanded && (
<ScopesTree
nodes={node.nodes}
nodePath={childNodePath}
loadingNodeName={loadingNodeName}
scopes={scopes}
onNodeUpdate={onNodeUpdate}
onNodeSelectToggle={onNodeSelectToggle}
/>
)}
</div>
</div>
);
})}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
title: css({
alignItems: 'center',
display: 'flex',
gap: theme.spacing(1),
fontSize: theme.typography.pxToRem(14),
lineHeight: theme.typography.pxToRem(22),
padding: theme.spacing(0.5, 0),
'& > label': css({
gap: 0,
}),
}),
expand: css({
alignItems: 'center',
background: 'none',
border: 0,
display: 'flex',
gap: theme.spacing(1),
margin: 0,
padding: 0,
}),
children: css({
paddingLeft: theme.spacing(4),
}),
};
};

View File

@ -1,185 +0,0 @@
import { css } from '@emotion/css';
import { debounce } from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { Checkbox, FilterInput, Icon, RadioButtonDot, useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { NodesMap, TreeScope } from './types';
export interface ScopesTreeLevelProps {
nodes: NodesMap;
nodePath: string[];
loadingNodeName: string | undefined;
scopes: TreeScope[];
onNodeUpdate: (path: string[], isExpanded: boolean, query: string) => void;
onNodeSelectToggle: (path: string[]) => void;
}
export function ScopesTreeLevel({
nodes,
nodePath,
loadingNodeName,
scopes,
onNodeUpdate,
onNodeSelectToggle,
}: ScopesTreeLevelProps) {
const styles = useStyles2(getStyles);
const nodeId = nodePath[nodePath.length - 1];
const node = nodes[nodeId];
const childNodes = node.nodes;
const childNodesArr = Object.values(childNodes);
const isNodeLoading = loadingNodeName === nodeId;
const scopeNames = scopes.map(({ scopeName }) => scopeName);
const anyChildExpanded = childNodesArr.some(({ isExpanded }) => isExpanded);
const [queryValue, setQueryValue] = useState(node.query);
useEffect(() => {
setQueryValue(node.query);
}, [node.query]);
const onQueryUpdate = useMemo(() => debounce(onNodeUpdate, 500), [onNodeUpdate]);
return (
<>
{!anyChildExpanded && (
<FilterInput
placeholder={t('scopes.tree.search', 'Search')}
value={queryValue}
className={styles.searchInput}
data-testid={`scopes-tree-${nodeId}-search`}
onChange={(value) => {
setQueryValue(value);
onQueryUpdate(nodePath, true, 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} />}
{!isNodeLoading &&
childNodesArr.map((childNode) => {
const isSelected = childNode.isSelectable && scopeNames.includes(childNode.linkId!);
if (anyChildExpanded && !childNode.isExpanded && !isSelected) {
return null;
}
const childNodePath = [...nodePath, childNode.name];
const radioName = childNodePath.join('.');
return (
<div key={childNode.name} role="treeitem" aria-selected={childNode.isExpanded}>
<div className={styles.itemTitle}>
{childNode.isSelectable && !childNode.isExpanded ? (
node.disableMultiSelect ? (
<RadioButtonDot
id={radioName}
name={radioName}
checked={isSelected}
label=""
data-testid={`scopes-tree-${childNode.name}-radio`}
onClick={() => {
onNodeSelectToggle(childNodePath);
}}
/>
) : (
<Checkbox
checked={isSelected}
data-testid={`scopes-tree-${childNode.name}-checkbox`}
onChange={() => {
onNodeSelectToggle(childNodePath);
}}
/>
)
) : null}
{childNode.isExpandable ? (
<button
className={styles.itemExpand}
data-testid={`scopes-tree-${childNode.name}-expand`}
aria-label={
childNode.isExpanded ? t('scopes.tree.collapse', 'Collapse') : t('scopes.tree.expand', 'Expand')
}
onClick={() => {
onNodeUpdate(childNodePath, !childNode.isExpanded, childNode.query);
}}
>
<Icon name={!childNode.isExpanded ? 'angle-right' : 'angle-down'} />
{childNode.title}
</button>
) : (
<span data-testid={`scopes-tree-${childNode.name}-title`}>{childNode.title}</span>
)}
</div>
<div className={styles.itemChildren}>
{childNode.isExpanded && (
<ScopesTreeLevel
nodes={node.nodes}
nodePath={childNodePath}
loadingNodeName={loadingNodeName}
scopes={scopes}
onNodeUpdate={onNodeUpdate}
onNodeSelectToggle={onNodeSelectToggle}
/>
)}
</div>
</div>
);
})}
</div>
</>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
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),
}),
itemTitle: css({
alignItems: 'center',
display: 'flex',
gap: theme.spacing(1),
fontSize: theme.typography.pxToRem(14),
lineHeight: theme.typography.pxToRem(22),
padding: theme.spacing(0.5, 0),
'& > label': css({
gap: 0,
}),
}),
itemExpand: css({
alignItems: 'center',
background: 'none',
border: 0,
display: 'flex',
gap: theme.spacing(1),
margin: 0,
padding: 0,
}),
itemChildren: css({
paddingLeft: theme.spacing(4),
}),
};
};

View File

@ -0,0 +1,29 @@
import { css } from '@emotion/css';
import { ReactNode } from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
export interface ScopesTreeLoadingProps {
children: ReactNode;
isNodeLoading: boolean;
}
export function ScopesTreeLoading({ children, isNodeLoading }: ScopesTreeLoadingProps) {
const styles = useStyles2(getStyles);
if (isNodeLoading) {
return <Skeleton count={5} className={styles.loader} />;
}
return children;
}
const getStyles = (theme: GrafanaTheme2) => {
return {
loader: css({
margin: theme.spacing(0.5, 0),
}),
};
};

View File

@ -0,0 +1,53 @@
import { css } from '@emotion/css';
import { debounce } from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { FilterInput, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { OnNodeUpdate } from './types';
export interface ScopesTreeSearchProps {
anyChildExpanded: boolean;
nodePath: string[];
query: string;
onNodeUpdate: OnNodeUpdate;
}
export function ScopesTreeSearch({ anyChildExpanded, nodePath, query, onNodeUpdate }: ScopesTreeSearchProps) {
const styles = useStyles2(getStyles);
const [queryValue, setQueryValue] = useState(query);
useEffect(() => {
setQueryValue(query);
}, [query]);
const onQueryUpdate = useMemo(() => debounce(onNodeUpdate, 500), [onNodeUpdate]);
if (anyChildExpanded) {
return null;
}
return (
<FilterInput
placeholder={t('scopes.tree.search', 'Search')}
value={queryValue}
className={styles.input}
data-testid="scopes-tree-search"
onChange={(value) => {
setQueryValue(value);
onQueryUpdate(nodePath, true, value);
}}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
input: css({
margin: theme.spacing(1, 0),
}),
};
};

View File

@ -1,8 +1,8 @@
import { Scope, ScopeSpec, ScopeNode, ScopeDashboardBinding } from '@grafana/data';
import { Scope, ScopeDashboardBinding, ScopeNode, ScopeSpec } from '@grafana/data';
import { config, getBackendSrv } from '@grafana/runtime';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import { NodesMap, SelectedScope, SuggestedDashboard, TreeScope } from './types';
import { NodeReason, NodesMap, SelectedScope, SuggestedDashboard, TreeScope } from './types';
import { getBasicScope, mergeScopes } from './utils';
const group = 'scope.grafana.app';
@ -37,6 +37,7 @@ export async function fetchNodes(parent: string, query: string): Promise<NodesMa
isSelectable: spec.linkType === 'scope',
isExpanded: false,
query: '',
reason: NodeReason.Result,
nodes: {},
};
return acc;

View File

@ -314,11 +314,12 @@ export const getMock = jest
const selectors = {
tree: {
search: (nodeId: string) => `scopes-tree-${nodeId}-search`,
select: (nodeId: string) => `scopes-tree-${nodeId}-checkbox`,
radio: (nodeId: string) => `scopes-tree-${nodeId}-radio`,
expand: (nodeId: string) => `scopes-tree-${nodeId}-expand`,
title: (nodeId: string) => `scopes-tree-${nodeId}-title`,
search: 'scopes-tree-search',
headline: 'scopes-tree-headline',
select: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-checkbox`,
radio: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-radio`,
expand: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-expand`,
title: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-title`,
},
filters: {
input: 'scopes-filters-input',
@ -359,36 +360,50 @@ export const getNotFoundForScope = () => screen.getByTestId(selectors.dashboards
export const getNotFoundForFilter = () => screen.getByTestId(selectors.dashboards.notFoundForFilter);
export const getNotFoundForFilterClear = () => screen.getByTestId(selectors.dashboards.notFoundForFilterClear);
export const getApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications'));
export const getApplicationsSearch = () => screen.getByTestId<HTMLInputElement>(selectors.tree.search('applications'));
export const queryApplicationsSlothPictureFactoryTitle = () =>
screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory'));
export const getApplicationsSlothPictureFactoryTitle = () =>
screen.getByTestId(selectors.tree.title('applications-slothPictureFactory'));
export const getApplicationsSlothPictureFactorySelect = () =>
screen.getByTestId(selectors.tree.select('applications-slothPictureFactory'));
export const queryApplicationsSlothVoteTrackerTitle = () =>
screen.queryByTestId(selectors.tree.title('applications-slothVoteTracker'));
export const getApplicationsSlothVoteTrackerSelect = () =>
screen.getByTestId(selectors.tree.select('applications-slothVoteTracker'));
export const queryApplicationsClustersTitle = () => screen.queryByTestId(selectors.tree.title('applications.clusters'));
export const getApplicationsClustersSelect = () => screen.getByTestId(selectors.tree.select('applications.clusters'));
export const getApplicationsClustersExpand = () => screen.getByTestId(selectors.tree.expand('applications.clusters'));
export const queryApplicationsClustersSlothClusterNorthTitle = () =>
screen.queryByTestId(selectors.tree.title('applications.clusters-slothClusterNorth'));
export const getApplicationsClustersSlothClusterNorthSelect = () =>
screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterNorth'));
export const getApplicationsClustersSlothClusterSouthSelect = () =>
screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterSouth'));
export const getTreeSearch = () => screen.getByTestId<HTMLInputElement>(selectors.tree.search);
export const getTreeHeadline = () => screen.getByTestId(selectors.tree.headline);
export const getResultApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications', 'result'));
export const queryResultApplicationsSlothPictureFactoryTitle = () =>
screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory', 'result'));
export const getResultApplicationsSlothPictureFactoryTitle = () =>
screen.getByTestId(selectors.tree.title('applications-slothPictureFactory', 'result'));
export const getResultApplicationsSlothPictureFactorySelect = () =>
screen.getByTestId(selectors.tree.select('applications-slothPictureFactory', 'result'));
export const queryPersistedApplicationsSlothPictureFactoryTitle = () =>
screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory', 'persisted'));
export const getPersistedApplicationsSlothPictureFactoryTitle = () =>
screen.getByTestId(selectors.tree.title('applications-slothPictureFactory', 'persisted'));
export const getPersistedApplicationsSlothPictureFactorySelect = () =>
screen.getByTestId(selectors.tree.select('applications-slothPictureFactory', 'persisted'));
export const queryResultApplicationsSlothVoteTrackerTitle = () =>
screen.queryByTestId(selectors.tree.title('applications-slothVoteTracker', 'result'));
export const getResultApplicationsSlothVoteTrackerTitle = () =>
screen.getByTestId(selectors.tree.title('applications-slothVoteTracker', 'result'));
export const getResultApplicationsSlothVoteTrackerSelect = () =>
screen.getByTestId(selectors.tree.select('applications-slothVoteTracker', 'result'));
export const queryPersistedApplicationsSlothVoteTrackerTitle = () =>
screen.queryByTestId(selectors.tree.title('applications-slothVoteTracker', 'persisted'));
export const getPersistedApplicationsSlothVoteTrackerTitle = () =>
screen.getByTestId(selectors.tree.title('applications-slothVoteTracker', 'persisted'));
export const queryResultApplicationsClustersTitle = () =>
screen.queryByTestId(selectors.tree.title('applications.clusters', 'result'));
export const getResultApplicationsClustersSelect = () =>
screen.getByTestId(selectors.tree.select('applications.clusters', 'result'));
export const getResultApplicationsClustersExpand = () =>
screen.getByTestId(selectors.tree.expand('applications.clusters', 'result'));
export const getResultApplicationsClustersSlothClusterNorthSelect = () =>
screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterNorth', 'result'));
export const getResultApplicationsClustersSlothClusterSouthSelect = () =>
screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterSouth', 'result'));
export const getClustersSelect = () => screen.getByTestId(selectors.tree.select('clusters'));
export const getClustersExpand = () => screen.getByTestId(selectors.tree.expand('clusters'));
export const getClustersSlothClusterNorthRadio = () =>
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterNorth'));
export const getClustersSlothClusterSouthRadio = () =>
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterSouth'));
export const getClustersSlothClusterEastRadio = () =>
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterEast'));
export const getResultClustersSelect = () => screen.getByTestId(selectors.tree.select('clusters', 'result'));
export const getResultClustersExpand = () => screen.getByTestId(selectors.tree.expand('clusters', 'result'));
export const getResultClustersSlothClusterNorthRadio = () =>
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterNorth', 'result'));
export const getResultClustersSlothClusterSouthRadio = () =>
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterSouth', 'result'));
export const getResultClustersSlothClusterEastRadio = () =>
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterEast', 'result'));
export function buildTestScene(overrides: Partial<DashboardScene> = {}) {
return new DashboardScene({

View File

@ -1,7 +1,13 @@
import { Scope, ScopeDashboardBinding, ScopeNodeSpec } from '@grafana/data';
export enum NodeReason {
Persisted,
Result,
}
export interface Node extends ScopeNodeSpec {
name: string;
reason: NodeReason;
isExpandable: boolean;
isSelectable: boolean;
isExpanded: boolean;
@ -26,3 +32,6 @@ export interface SuggestedDashboard {
dashboardTitle: string;
items: ScopeDashboardBinding[];
}
export type OnNodeUpdate = (path: string[], isExpanded: boolean, query: string) => void;
export type OnNodeSelectToggle = (path: string[]) => void;

View File

@ -1698,7 +1698,11 @@
"tree": {
"collapse": "Collapse",
"expand": "Expand",
"headline": "Recommended",
"headline": {
"noResults": "No results found for your query",
"recommended": "Recommended",
"results": "Results"
},
"search": "Search"
}
},

View File

@ -1698,7 +1698,11 @@
"tree": {
"collapse": "Cőľľäpşę",
"expand": "Ēχpäʼnđ",
"headline": "Ŗęčőmmęʼnđęđ",
"headline": {
"noResults": "Ńő řęşūľŧş ƒőūʼnđ ƒőř yőūř qūęřy",
"recommended": "Ŗęčőmmęʼnđęđ",
"results": "Ŗęşūľŧş"
},
"search": "Ŝęäřčĥ"
}
},