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