From 67f2d93281c4a9eda59b16b5be726b07b28f8b16 Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Mon, 17 Jun 2024 13:00:20 +0300 Subject: [PATCH] Scopes: QoL UI fixes (#89158) --- .../scene/Scopes/ScopesDashboardsScene.tsx | 2 +- .../scene/Scopes/ScopesFiltersScene.tsx | 95 +++++++------- .../scene/Scopes/ScopesInput.tsx | 119 ++++++++++++++++++ .../scene/Scopes/ScopesScene.test.tsx | 15 ++- .../scene/Scopes/ScopesScene.tsx | 2 +- .../scene/Scopes/ScopesTreeLevel.tsx | 23 +++- .../dashboard-scene/scene/Scopes/api.ts | 16 ++- .../scene/Scopes/testUtils.tsx | 2 +- .../dashboard-scene/scene/Scopes/types.ts | 12 +- public/locales/en-US/grafana.json | 5 +- public/locales/pseudo-LOCALE/grafana.json | 5 +- 11 files changed, 228 insertions(+), 68 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx index 72b6c60055a..35ab7ea9be6 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx @@ -74,7 +74,7 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps } - 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)} diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx index 341abd85e1e..cef966c5944 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx @@ -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 }, loadingNodeName: undefined, scopes: [], - dirtyScopeNames: [], + treeScopes: [], isLoadingScopes: false, isOpened: false, }); @@ -72,14 +73,16 @@ export class ScopesFiltersScene extends SceneObjectBase } 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 } 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 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 } 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) { 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 ( <> - 0 && !isViewing ? ( - model.removeAllScopes()} - /> - ) : undefined - } - onClick={() => model.open()} + model.open()} + onRemoveAllClick={() => model.removeAllScopes()} /> {isOpened && ( @@ -238,7 +237,7 @@ export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps model.updateNode(path, isExpanded, query)} onNodeSelectToggle={(path) => model.toggleNodeSelect(path)} /> diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx new file mode 100644 index 00000000000..b44d0ee4d11 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx @@ -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) => ( +

+ {path} +

+ )); + }, [nodes, scopes, styles]); + + const scopesTitles = useMemo(() => scopes.map(({ scope }) => scope.spec.title).join(', '), [scopes]); + + const input = ( + 0 && !isDisabled ? ( + onRemoveAllClick()} + /> + ) : undefined + } + onClick={() => { + if (!isDisabled) { + onInputClick(); + } + }} + /> + ); + + if (scopes.length === 0) { + return input; + } + + return ( + {scopesPaths}} interactive={true}> + {input} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + scopePath: css({ + color: theme.colors.text.primary, + fontSize: theme.typography.pxToRem(14), + margin: theme.spacing(1, 0), + }), + }; +}; diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx index e52e3fa0b24..1d38c5f51c5 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx @@ -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([]); }); diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx index 62e59eb4a3a..a7f991913c5 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx @@ -32,7 +32,7 @@ export class ScopesScene extends SceneObjectBase { 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(); diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx index 06566d270bc..787e089448c 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx @@ -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({ } 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 && ( +
+ Recommended +
+ )} +
{isNodeLoading && } @@ -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), }), diff --git a/public/app/features/dashboard-scene/scene/Scopes/api.ts b/public/app/features/dashboard-scene/scene/Scopes/api.ts index 43e1e7931ed..d35b19d277f 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/api.ts +++ b/public/app/features/dashboard-scene/scene/Scopes/api.ts @@ -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 { return await Promise.all(names.map(fetchScope)); } +export async function fetchSelectedScopes(treeScopes: TreeScope[]): Promise { + const scopes = await fetchScopes(treeScopes.map(({ scopeName }) => scopeName)); + + return scopes.reduce((acc, scope, idx) => { + acc.push({ + scope, + path: treeScopes[idx].path, + }); + + return acc; + }, []); +} + export async function fetchDashboards(scopes: Scope[]): Promise { try { const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(dashboardsEndpoint, { diff --git a/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx b/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx index f204bc9560f..faf5838bde6 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx +++ b/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx @@ -225,7 +225,7 @@ export const mocksNodes: Array = [ 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 = { diff --git a/public/app/features/dashboard-scene/scene/Scopes/types.ts b/public/app/features/dashboard-scene/scene/Scopes/types.ts index ed68e0c8ff6..1a556870ecf 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/types.ts +++ b/public/app/features/dashboard-scene/scene/Scopes/types.ts @@ -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; + +export interface SelectedScope { + scope: Scope; + path: string[]; +} + +export interface TreeScope { + scopeName: string; + path: string[]; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 16bfdda9b48..c83965e3c5a 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 474ee4c3b22..f99ab49211d 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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": {