Scopes: QoL UI fixes (#89158)

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

View File

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

View File

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

View File

@ -0,0 +1,119 @@
import { css } from '@emotion/css';
import { groupBy } from 'lodash';
import React, { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, Input, Tooltip } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui/';
import { t } from 'app/core/internationalization';
import { NodesMap, SelectedScope } from './types';
export interface ScopesInputProps {
nodes: NodesMap;
scopes: SelectedScope[];
isDisabled: boolean;
isLoading: boolean;
onInputClick: () => void;
onRemoveAllClick: () => void;
}
export function ScopesInput({
nodes,
scopes,
isDisabled,
isLoading,
onInputClick,
onRemoveAllClick,
}: ScopesInputProps) {
const styles = useStyles2(getStyles);
const scopesPaths = useMemo(() => {
const pathsTitles = scopes.map(({ scope, path }) => {
let currentLevel = nodes;
let titles: string[];
if (path.length > 0) {
titles = path.map((nodeName) => {
const { title, nodes } = currentLevel[nodeName];
currentLevel = nodes;
return title;
});
if (titles[0] === '') {
titles.splice(0, 1);
}
} else {
titles = [scope.spec.title];
}
const scopeName = titles.pop();
return [titles.join(' > '), scopeName];
});
const groupedByPath = groupBy(pathsTitles, ([path]) => path);
return Object.entries(groupedByPath)
.map(([path, pathScopes]) => {
const scopesTitles = pathScopes.map(([, scopeTitle]) => scopeTitle).join(', ');
return (path ? [path, scopesTitles] : [scopesTitles]).join(' > ');
})
.map((path) => (
<p key={path} className={styles.scopePath}>
{path}
</p>
));
}, [nodes, scopes, styles]);
const scopesTitles = useMemo(() => scopes.map(({ scope }) => scope.spec.title).join(', '), [scopes]);
const input = (
<Input
readOnly
placeholder={t('scopes.filters.input.placeholder', 'Select scopes...')}
loading={isLoading}
value={scopesTitles}
aria-label={t('scopes.filters.input.placeholder', 'Select scopes...')}
data-testid="scopes-filters-input"
suffix={
scopes.length > 0 && !isDisabled ? (
<IconButton
aria-label={t('scopes.filters.input.removeAll', 'Remove all scopes')}
name="times"
onClick={() => onRemoveAllClick()}
/>
) : undefined
}
onClick={() => {
if (!isDisabled) {
onInputClick();
}
}}
/>
);
if (scopes.length === 0) {
return input;
}
return (
<Tooltip content={<>{scopesPaths}</>} interactive={true}>
{input}
</Tooltip>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
scopePath: css({
color: theme.colors.text.primary,
fontSize: theme.typography.pxToRem(14),
margin: theme.spacing(1, 0),
}),
};
};

View File

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

View File

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

View File

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

View File

@ -1,7 +1,8 @@
import { Scope, ScopeSpec, ScopeNode, ScopeDashboardBinding } from '@grafana/data';
import { config, getBackendSrv } from '@grafana/runtime';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import { NodesMap } from 'app/features/dashboard-scene/scene/Scopes/types';
import { NodesMap, SelectedScope, TreeScope } from './types';
const group = 'scope.grafana.app';
const version = 'v0alpha1';
@ -90,6 +91,19 @@ export async function fetchScopes(names: string[]): Promise<Scope[]> {
return await Promise.all(names.map(fetchScope));
}
export async function fetchSelectedScopes(treeScopes: TreeScope[]): Promise<SelectedScope[]> {
const scopes = await fetchScopes(treeScopes.map(({ scopeName }) => scopeName));
return scopes.reduce<SelectedScope[]>((acc, scope, idx) => {
acc.push({
scope,
path: treeScopes[idx].path,
});
return acc;
}, []);
}
export async function fetchDashboards(scopes: Scope[]): Promise<ScopeDashboardBinding[]> {
try {
const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(dashboardsEndpoint, {

View File

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

View File

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

View File

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

View File

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