mirror of
https://github.com/grafana/grafana.git
synced 2024-11-28 19:54:10 -06:00
Scopes: Persist selected scopes when searching (#89758)
This commit is contained in:
parent
c0058f9c7e
commit
4c9fef6183
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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(
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
@ -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;
|
||||
|
@ -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({
|
||||
|
@ -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;
|
||||
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
@ -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": "Ŝęäřčĥ"
|
||||
}
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user