From 8d36949f61c0158753029f6fb84c7548b2572b46 Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Thu, 6 Jun 2024 17:00:56 +0300 Subject: [PATCH] Scopes: UI Improvements (#88026) --- packages/grafana-data/src/types/scopes.ts | 29 +- .../dashboard-scene/scene/DashboardScene.tsx | 2 +- .../scene/Scopes/ScopesDashboardsScene.tsx | 125 ++++++ .../Scopes/ScopesFiltersAdvancedSelector.tsx | 76 ++++ .../Scopes/ScopesFiltersBasicSelector.tsx | 110 +++++ .../scene/Scopes/ScopesFiltersScene.tsx | 191 ++++++++ .../scene/Scopes/ScopesScene.test.tsx | 417 ++++++++++++++++++ .../scene/{ => Scopes}/ScopesScene.tsx | 69 +-- .../scene/Scopes/ScopesTreeLevel.tsx | 141 ++++++ .../dashboard-scene/scene/Scopes/api.ts | 103 +++++ .../scene/Scopes/testUtils.tsx | 330 ++++++++++++++ .../dashboard-scene/scene/Scopes/types.ts | 12 + .../scene/ScopesDashboardsScene.tsx | 167 ------- .../scene/ScopesFiltersScene.tsx | 309 ------------- .../scene/ScopesScene.test.tsx | 414 ----------------- public/locales/en-US/grafana.json | 25 ++ public/locales/pseudo-LOCALE/grafana.json | 25 ++ 17 files changed, 1620 insertions(+), 925 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx create mode 100644 public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersAdvancedSelector.tsx create mode 100644 public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersBasicSelector.tsx create mode 100644 public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx create mode 100644 public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx rename public/app/features/dashboard-scene/scene/{ => Scopes}/ScopesScene.tsx (67%) create mode 100644 public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx create mode 100644 public/app/features/dashboard-scene/scene/Scopes/api.ts create mode 100644 public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx create mode 100644 public/app/features/dashboard-scene/scene/Scopes/types.ts delete mode 100644 public/app/features/dashboard-scene/scene/ScopesDashboardsScene.tsx delete mode 100644 public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx delete mode 100644 public/app/features/dashboard-scene/scene/ScopesScene.test.tsx diff --git a/packages/grafana-data/src/types/scopes.ts b/packages/grafana-data/src/types/scopes.ts index b84f1872067..7c2b9fc01aa 100644 --- a/packages/grafana-data/src/types/scopes.ts +++ b/packages/grafana-data/src/types/scopes.ts @@ -1,8 +1,17 @@ export interface ScopeDashboardBindingSpec { dashboard: string; + dashboardTitle: string; scope: string; } +// TODO: Use Resource from apiserver when we export the types +export interface ScopeDashboardBinding { + metadata: { + name: string; + }; + spec: ScopeDashboardBindingSpec; +} + export type ScopeFilterOperator = 'equals' | 'not-equals' | 'regex-match' | 'regex-not-match'; export const scopeFilterOperatorMap: Record = { @@ -34,15 +43,23 @@ export interface Scope { spec: ScopeSpec; } -export type ScopeTreeItemNodeType = 'container' | 'leaf'; -export type ScopeTreeItemLinkType = 'scope'; +export type ScopeNodeNodeType = 'container' | 'leaf'; +export type ScopeNodeLinkType = 'scope'; -export interface ScopeTreeItemSpec { - nodeId: string; - nodeType: ScopeTreeItemNodeType; +export interface ScopeNodeSpec { + nodeType: ScopeNodeNodeType; title: string; description?: string; + disableMultiSelect?: boolean; linkId?: string; - linkType?: ScopeTreeItemLinkType; + linkType?: ScopeNodeLinkType; +} + +// TODO: Use Resource from apiserver when we export the types +export interface ScopeNode { + metadata: { + name: string; + }; + spec: ScopeNodeSpec; } diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 611db13dbac..f1b73973b63 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -66,7 +66,7 @@ import { DashboardSceneRenderer } from './DashboardSceneRenderer'; import { DashboardSceneUrlSync } from './DashboardSceneUrlSync'; import { LibraryVizPanel } from './LibraryVizPanel'; import { RowRepeaterBehavior } from './RowRepeaterBehavior'; -import { ScopesScene } from './ScopesScene'; +import { ScopesScene } from './Scopes/ScopesScene'; import { ViewPanelScene } from './ViewPanelScene'; import { setupKeyboardShortcuts } from './keyboardShortcuts'; diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx new file mode 100644 index 00000000000..72b6c60055a --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx @@ -0,0 +1,125 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { GrafanaTheme2, Scope, ScopeDashboardBinding, urlUtil } from '@grafana/data'; +import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { CustomScrollbar, Icon, Input, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { t } from 'app/core/internationalization'; + +import { fetchDashboards } from './api'; + +export interface ScopesDashboardsSceneState extends SceneObjectState { + dashboards: ScopeDashboardBinding[]; + filteredDashboards: ScopeDashboardBinding[]; + isLoading: boolean; + searchQuery: string; +} + +export class ScopesDashboardsScene extends SceneObjectBase { + static Component = ScopesDashboardsSceneRenderer; + + constructor() { + super({ + dashboards: [], + filteredDashboards: [], + isLoading: false, + searchQuery: '', + }); + } + + public async fetchDashboards(scopes: Scope[]) { + if (scopes.length === 0) { + return this.setState({ dashboards: [], filteredDashboards: [], isLoading: false }); + } + + this.setState({ isLoading: true }); + + const dashboards = await fetchDashboards(scopes); + + this.setState({ + dashboards, + filteredDashboards: this.filterDashboards(dashboards, this.state.searchQuery), + isLoading: false, + }); + } + + public changeSearchQuery(searchQuery: string) { + this.setState({ + filteredDashboards: searchQuery + ? this.filterDashboards(this.state.dashboards, searchQuery) + : this.state.dashboards, + searchQuery: searchQuery ?? '', + }); + } + + private filterDashboards(dashboards: ScopeDashboardBinding[], searchQuery: string) { + const lowerCasedSearchQuery = searchQuery.toLowerCase(); + + return dashboards.filter(({ spec: { dashboardTitle } }) => + dashboardTitle.toLowerCase().includes(lowerCasedSearchQuery) + ); + } +} + +export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps) { + const { filteredDashboards, isLoading } = model.useState(); + const styles = useStyles2(getStyles); + + const [queryParams] = useQueryParams(); + + return ( + <> +
+ } + placeholder={t('scopes.suggestedDashboards.search', 'Filter')} + disabled={isLoading} + data-testid="scopes-dashboards-search" + onChange={(evt) => model.changeSearchQuery(evt.currentTarget.value)} + /> +
+ + {isLoading ? ( + + ) : ( + + {filteredDashboards.map(({ spec: { dashboard, dashboardTitle } }) => ( + + {dashboardTitle} + + ))} + + )} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + searchInputContainer: css({ + flex: '0 1 auto', + }), + loadingIndicator: css({ + alignSelf: 'center', + }), + dashboardItem: css({ + padding: theme.spacing(1, 0), + borderBottom: `1px solid ${theme.colors.border.weak}`, + + '& :is(:first-child)': { + paddingTop: 0, + }, + }), + }; +}; diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersAdvancedSelector.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersAdvancedSelector.tsx new file mode 100644 index 00000000000..6163ecd73aa --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersAdvancedSelector.tsx @@ -0,0 +1,76 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { SceneComponentProps } from '@grafana/scenes'; +import { Button, Drawer, Spinner, useStyles2 } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { ScopesFiltersScene } from './ScopesFiltersScene'; +import { ScopesTreeLevel } from './ScopesTreeLevel'; + +export function ScopesFiltersAdvancedSelector({ model }: SceneComponentProps) { + const styles = useStyles2(getStyles); + const { nodes, loadingNodeName, dirtyScopeNames, isLoadingScopes, isAdvancedOpened } = model.useState(); + + if (!isAdvancedOpened) { + return null; + } + + return ( + { + model.closeAdvancedSelector(); + model.resetDirtyScopeNames(); + }} + > + {isLoadingScopes ? ( + + ) : ( + model.updateNode(path, isExpanded, query)} + onNodeSelectToggle={(path) => model.toggleNodeSelect(path)} + /> + )} +
+ + +
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + buttonGroup: css({ + display: 'flex', + gap: theme.spacing(1), + marginTop: theme.spacing(8), + }), + }; +}; diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersBasicSelector.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersBasicSelector.tsx new file mode 100644 index 00000000000..e55618410b0 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersBasicSelector.tsx @@ -0,0 +1,110 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { SceneComponentProps } from '@grafana/scenes'; +import { Icon, IconButton, Input, Spinner, Toggletip, useStyles2 } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { ScopesFiltersScene } from './ScopesFiltersScene'; +import { ScopesTreeLevel } from './ScopesTreeLevel'; + +export function ScopesFiltersBasicSelector({ model }: SceneComponentProps) { + const styles = useStyles2(getStyles); + const { nodes, loadingNodeName, scopes, dirtyScopeNames, isLoadingScopes, isBasicOpened } = model.useState(); + const { isViewing } = model.scopesParent.useState(); + + const scopesTitles = scopes.map(({ spec: { title } }) => title).join(', '); + + return ( +
+ + {isLoadingScopes ? ( + + ) : ( + model.updateNode(path, isExpanded, query)} + onNodeSelectToggle={(path) => model.toggleNodeSelect(path)} + /> + )} +
+ } + footer={ + + } + onOpen={() => model.openBasicSelector()} + onClose={() => { + model.closeBasicSelector(); + model.updateScopes(); + }} + > + 0 && !isViewing ? ( + model.removeAllScopes()} + /> + ) : undefined + } + /> + + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + width: '100%', + + '& > div': css({ + padding: 0, + + '& > div': css({ + padding: 0, + margin: 0, + }), + }), + }), + innerContainer: css({ + minWidth: 400, + padding: theme.spacing(0, 1), + }), + openAdvancedButton: css({ + backgroundColor: theme.colors.secondary.main, + border: 'none', + borderTop: `1px solid ${theme.colors.secondary.border}`, + display: 'block', + fontSize: theme.typography.pxToRem(12), + margin: 0, + padding: theme.spacing(1.5), + textAlign: 'right', + width: '100%', + }), + }; +}; diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx new file mode 100644 index 00000000000..3c048a0ae47 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx @@ -0,0 +1,191 @@ +import { isEqual } from 'lodash'; +import React from 'react'; + +import { Scope } from '@grafana/data'; +import { + SceneComponentProps, + sceneGraph, + SceneObjectBase, + SceneObjectState, + SceneObjectUrlSyncConfig, + SceneObjectUrlValues, + SceneObjectWithUrlSync, +} from '@grafana/scenes'; + +import { ScopesFiltersAdvancedSelector } from './ScopesFiltersAdvancedSelector'; +import { ScopesFiltersBasicSelector } from './ScopesFiltersBasicSelector'; +import { ScopesScene } from './ScopesScene'; +import { fetchNodes, fetchScope, fetchScopes } from './api'; +import { NodesMap } from './types'; + +export interface ScopesFiltersSceneState extends SceneObjectState { + nodes: NodesMap; + loadingNodeName: string | undefined; + scopes: Scope[]; + dirtyScopeNames: string[]; + isLoadingScopes: boolean; + isBasicOpened: boolean; + isAdvancedOpened: boolean; +} + +export class ScopesFiltersScene extends SceneObjectBase implements SceneObjectWithUrlSync { + static Component = ScopesFiltersSceneRenderer; + + protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scopes'] }); + + get scopesParent(): ScopesScene { + return sceneGraph.getAncestor(this, ScopesScene); + } + + constructor() { + super({ + nodes: { + '': { + name: '', + nodeType: 'container', + title: '', + isExpandable: true, + isSelectable: false, + isExpanded: true, + query: '', + nodes: {}, + }, + }, + loadingNodeName: undefined, + scopes: [], + dirtyScopeNames: [], + isLoadingScopes: false, + isBasicOpened: false, + isAdvancedOpened: false, + }); + + this.addActivationHandler(() => { + this.fetchBaseNodes(); + }); + } + + public getUrlState() { + return { scopes: this.getScopeNames() }; + } + + public updateFromUrl(values: SceneObjectUrlValues) { + let dirtyScopeNames = values.scopes ?? []; + dirtyScopeNames = Array.isArray(dirtyScopeNames) ? dirtyScopeNames : [dirtyScopeNames]; + + this.updateScopes(dirtyScopeNames); + } + + public fetchBaseNodes() { + return this.updateNode([''], true, ''); + } + + public async updateNode(path: string[], isExpanded: boolean, query: string) { + let nodes = { ...this.state.nodes }; + let currentLevel: NodesMap = nodes; + + for (let idx = 0; idx < path.length - 1; idx++) { + currentLevel = currentLevel[path[idx]].nodes; + } + + const name = path[path.length - 1]; + const currentNode = currentLevel[name]; + + if (isExpanded || currentNode.query !== query) { + this.setState({ loadingNodeName: name }); + + currentNode.nodes = await fetchNodes(name, query); + } + + currentNode.isExpanded = isExpanded; + currentNode.query = query; + + this.setState({ nodes, loadingNodeName: undefined }); + } + + public toggleNodeSelect(path: string[]) { + let dirtyScopeNames = [...this.state.dirtyScopeNames]; + + let siblings = this.state.nodes; + + for (let idx = 0; idx < path.length - 1; idx++) { + siblings = siblings[path[idx]].nodes; + } + + const name = path[path.length - 1]; + const { linkId } = siblings[name]; + + const selectedIdx = dirtyScopeNames.findIndex((scopeName) => scopeName === linkId); + + if (selectedIdx === -1) { + fetchScope(linkId!); + + const selectedFromSameNode = + dirtyScopeNames.length === 0 || Object.values(siblings).some(({ linkId }) => linkId === dirtyScopeNames[0]); + + this.setState({ dirtyScopeNames: !selectedFromSameNode ? [linkId!] : [...dirtyScopeNames, linkId!] }); + } else { + dirtyScopeNames.splice(selectedIdx, 1); + + this.setState({ dirtyScopeNames }); + } + } + + public openBasicSelector() { + if (!this.scopesParent.state.isViewing) { + this.setState({ isBasicOpened: true, isAdvancedOpened: false }); + } + } + + public closeBasicSelector() { + this.setState({ isBasicOpened: false }); + } + + public openAdvancedSelector() { + if (!this.scopesParent.state.isViewing) { + this.setState({ isBasicOpened: false, isAdvancedOpened: true }); + } + } + + public closeAdvancedSelector() { + this.setState({ isAdvancedOpened: false }); + } + + public getSelectedScopes(): Scope[] { + return this.state.scopes; + } + + public async updateScopes(dirtyScopeNames = this.state.dirtyScopeNames) { + if (isEqual(dirtyScopeNames, this.getScopeNames())) { + return; + } + + this.setState({ dirtyScopeNames, isLoadingScopes: true }); + + this.setState({ scopes: await fetchScopes(dirtyScopeNames), isLoadingScopes: false }); + } + + public resetDirtyScopeNames() { + this.setState({ dirtyScopeNames: this.getScopeNames() }); + } + + public removeAllScopes() { + this.setState({ scopes: [], dirtyScopeNames: [], isLoadingScopes: false }); + } + + public enterViewMode() { + this.setState({ isBasicOpened: false, isAdvancedOpened: false }); + } + + private getScopeNames(): string[] { + return this.state.scopes.map(({ metadata: { name } }) => name); + } +} + +export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps) { + return ( + <> + + + + ); +} diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx new file mode 100644 index 00000000000..d354e4276d7 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx @@ -0,0 +1,417 @@ +import { act, cleanup, waitFor } from '@testing-library/react'; +import userEvents from '@testing-library/user-event'; + +import { config } from '@grafana/runtime'; +import { sceneGraph } from '@grafana/scenes'; +import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; + +import { ScopesFiltersScene } from './ScopesFiltersScene'; +import { ScopesScene } from './ScopesScene'; +import { + buildTestScene, + fetchDashboardsSpy, + fetchNodesSpy, + fetchScopeSpy, + fetchScopesSpy, + getAdvancedApply, + getAdvancedCancel, + getApplicationsClustersExpand, + getApplicationsClustersSelect, + getApplicationsExpand, + getApplicationsSearch, + getApplicationsSlothPictureFactorySelect, + getApplicationsSlothPictureFactoryTitle, + getApplicationsSlothVoteTrackerSelect, + getBasicInnerContainer, + getBasicInput, + getBasicOpenAdvanced, + getClustersExpand, + getClustersSelect, + getClustersSlothClusterNorthSelect, + getClustersSlothClusterSouthSelect, + getDashboard, + getDashboardsContainer, + getDashboardsSearch, + getRootExpand, + mocksNodes, + mocksScopeDashboardBindings, + mocksScopes, + queryAdvancedApply, + queryApplicationsClustersSlothClusterNorthTitle, + queryApplicationsClustersTitle, + queryApplicationsSlothPictureFactoryTitle, + queryApplicationsSlothVoteTrackerTitle, + queryBasicInnerContainer, + queryDashboard, + queryDashboardsContainer, + queryRootExpand, + renderDashboard, +} from './testUtils'; + +jest.mock('@grafana/runtime', () => ({ + __esModule: true, + ...jest.requireActual('@grafana/runtime'), + getBackendSrv: () => ({ + get: jest.fn().mockImplementation((url: string, params: { parent: string; scope: string[]; query?: string }) => { + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_node_children')) { + return { + items: mocksNodes.filter( + ({ parent, spec: { title } }) => parent === params.parent && title.includes(params.query ?? '') + ), + }; + } + + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/')) { + const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/', ''); + + return mocksScopes.find((scope) => scope.metadata.name === name) ?? {}; + } + + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_dashboard_bindings')) { + return { + items: mocksScopeDashboardBindings.filter(({ spec: { scope: bindingScope } }) => + params.scope.includes(bindingScope) + ), + }; + } + + return {}; + }), + }), +})); + +describe('ScopesScene', () => { + describe('Feature flag off', () => { + beforeAll(() => { + config.featureToggles.scopeFilters = false; + }); + + it('Does not initialize', () => { + const dashboardScene = buildTestScene(); + dashboardScene.activate(); + expect(dashboardScene.state.scopes).toBeUndefined(); + }); + }); + + describe('Feature flag on', () => { + let dashboardScene: DashboardScene; + let scopesScene: ScopesScene; + let filtersScene: ScopesFiltersScene; + + beforeAll(() => { + config.featureToggles.scopeFilters = true; + }); + + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(jest.fn()); + + fetchNodesSpy.mockClear(); + fetchScopeSpy.mockClear(); + fetchScopesSpy.mockClear(); + fetchDashboardsSpy.mockClear(); + + dashboardScene = buildTestScene(); + scopesScene = dashboardScene.state.scopes!; + filtersScene = scopesScene.state.filters; + + renderDashboard(dashboardScene); + }); + + afterEach(() => { + cleanup(); + }); + + describe('Tree', () => { + it('Navigates through scopes nodes', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsExpand()); + await userEvents.click(getApplicationsClustersExpand()); + await userEvents.click(getApplicationsExpand()); + }); + + it('Fetches scope details on select', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsExpand()); + await userEvents.click(getApplicationsSlothVoteTrackerSelect()); + await waitFor(() => expect(fetchScopeSpy).toHaveBeenCalledTimes(1)); + }); + + it('Selects the proper scopes', async () => { + await act(async () => filtersScene.updateScopes(['slothPictureFactory', 'slothVoteTracker'])); + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsExpand()); + expect(getApplicationsSlothVoteTrackerSelect()).toBeChecked(); + expect(getApplicationsSlothPictureFactorySelect()).toBeChecked(); + }); + + it('Can select scopes from same level', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsExpand()); + await userEvents.click(getApplicationsSlothVoteTrackerSelect()); + await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getApplicationsClustersSelect()); + await userEvents.click(getBasicInput()); + expect(getBasicInput().value).toBe('slothVoteTracker, slothPictureFactory, Cluster Index Helper'); + }); + + it("Can't navigate deeper than the level where scopes are selected", async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsExpand()); + await userEvents.click(getApplicationsSlothVoteTrackerSelect()); + await userEvents.click(getApplicationsClustersExpand()); + expect(queryApplicationsClustersSlothClusterNorthTitle()).not.toBeInTheDocument(); + }); + + it('Can select a node from an upper level', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsExpand()); + await userEvents.click(getApplicationsSlothVoteTrackerSelect()); + await userEvents.click(getApplicationsExpand()); + await userEvents.click(getClustersSelect()); + await userEvents.click(getBasicInput()); + expect(getBasicInput().value).toBe('Cluster Index Helper'); + }); + + it('Respects only one select per container', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getClustersExpand()); + await userEvents.click(getClustersSlothClusterNorthSelect()); + expect(getClustersSlothClusterSouthSelect()).toBeDisabled(); + }); + + it('Search works', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getBasicOpenAdvanced()); + await userEvents.click(getApplicationsExpand()); + await userEvents.type(getApplicationsSearch(), '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'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4)); + expect(getApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); + expect(getApplicationsSlothVoteTrackerSelect()).toBeInTheDocument(); + expect(queryApplicationsClustersTitle()).not.toBeInTheDocument(); + }); + }); + + describe('Basic selector', () => { + it('Opens', async () => { + await userEvents.click(getBasicInput()); + expect(getBasicInnerContainer()).toBeInTheDocument(); + }); + + it('Fetches scope details on save', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getClustersSelect()); + await userEvents.click(getBasicInput()); + await waitFor(() => expect(fetchScopesSpy).toHaveBeenCalled()); + expect(filtersScene.getSelectedScopes()).toEqual( + mocksScopes.filter(({ metadata: { name } }) => name === 'indexHelperCluster') + ); + }); + + it('Shows selected scopes', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getClustersSelect()); + await userEvents.click(getBasicInput()); + expect(getBasicInput().value).toEqual('Cluster Index Helper'); + }); + }); + + describe('Advanced selector', () => { + it('Opens', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getBasicOpenAdvanced()); + expect(queryBasicInnerContainer()).not.toBeInTheDocument(); + expect(getAdvancedApply()).toBeInTheDocument(); + }); + + it('Fetches scope details on save', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getBasicOpenAdvanced()); + await userEvents.click(getClustersSelect()); + await userEvents.click(getAdvancedApply()); + await waitFor(() => expect(fetchScopesSpy).toHaveBeenCalled()); + expect(filtersScene.getSelectedScopes()).toEqual( + mocksScopes.filter(({ metadata: { name } }) => name === 'indexHelperCluster') + ); + }); + + it("Doesn't save the scopes on close", async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getBasicOpenAdvanced()); + await userEvents.click(getClustersSelect()); + await userEvents.click(getAdvancedCancel()); + await waitFor(() => expect(fetchScopesSpy).not.toHaveBeenCalled()); + expect(filtersScene.getSelectedScopes()).toEqual([]); + }); + }); + + describe('Selectors interoperability', () => { + it('Replicates the same structure from basic to advanced selector', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsExpand()); + await userEvents.click(getBasicOpenAdvanced()); + expect(getApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); + }); + + it('Replicates the same structure from advanced to basic selector', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getBasicOpenAdvanced()); + await userEvents.click(getApplicationsExpand()); + await userEvents.click(getAdvancedApply()); + await userEvents.click(getBasicInput()); + expect(getApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); + }); + }); + + describe('Dashboards list', () => { + it('Toggles expanded state', async () => { + await userEvents.click(getRootExpand()); + expect(getDashboardsContainer()).toBeInTheDocument(); + }); + + it('Does not fetch dashboards list when the list is not expanded', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsExpand()); + await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getBasicInput()); + await waitFor(() => expect(fetchDashboardsSpy).not.toHaveBeenCalled()); + }); + + it('Fetches dashboards list when the list is expanded', async () => { + await userEvents.click(getRootExpand()); + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsExpand()); + await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getBasicInput()); + await waitFor(() => expect(fetchDashboardsSpy).toHaveBeenCalled()); + }); + + it('Fetches dashboards list when the list is expanded after scope selection', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsExpand()); + await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getBasicInput()); + await userEvents.click(getRootExpand()); + await waitFor(() => expect(fetchDashboardsSpy).toHaveBeenCalled()); + }); + + it('Shows dashboards for multiple scopes', async () => { + await userEvents.click(getRootExpand()); + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsExpand()); + await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getBasicInput()); + expect(getDashboard('1')).toBeInTheDocument(); + expect(getDashboard('2')).toBeInTheDocument(); + expect(queryDashboard('3')).not.toBeInTheDocument(); + expect(queryDashboard('4')).not.toBeInTheDocument(); + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsSlothVoteTrackerSelect()); + await userEvents.click(getBasicInput()); + expect(getDashboard('1')).toBeInTheDocument(); + expect(getDashboard('2')).toBeInTheDocument(); + expect(getDashboard('3')).toBeInTheDocument(); + expect(getDashboard('4')).toBeInTheDocument(); + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getBasicInput()); + expect(queryDashboard('1')).not.toBeInTheDocument(); + expect(queryDashboard('2')).not.toBeInTheDocument(); + expect(getDashboard('3')).toBeInTheDocument(); + expect(getDashboard('4')).toBeInTheDocument(); + }); + + it('Filters the dashboards list', async () => { + await userEvents.click(getRootExpand()); + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsExpand()); + await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getBasicInput()); + expect(getDashboard('1')).toBeInTheDocument(); + expect(getDashboard('2')).toBeInTheDocument(); + await userEvents.type(getDashboardsSearch(), '1'); + expect(queryDashboard('2')).not.toBeInTheDocument(); + }); + }); + + describe('View mode', () => { + it('Enters view mode', async () => { + await act(async () => dashboardScene.onEnterEditMode()); + expect(scopesScene.state.isViewing).toEqual(true); + expect(scopesScene.state.isExpanded).toEqual(false); + }); + + it('Closes basic selector on enter', async () => { + await userEvents.click(getBasicInput()); + await act(async () => dashboardScene.onEnterEditMode()); + expect(queryBasicInnerContainer()).not.toBeInTheDocument(); + }); + + it('Closes advanced selector on enter', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getBasicOpenAdvanced()); + await act(async () => dashboardScene.onEnterEditMode()); + expect(queryAdvancedApply()).not.toBeInTheDocument(); + }); + + it('Closes dashboards list on enter', async () => { + await userEvents.click(getRootExpand()); + await act(async () => dashboardScene.onEnterEditMode()); + expect(queryDashboardsContainer()).not.toBeInTheDocument(); + }); + + it('Does not open basic selector when view mode is active', async () => { + await act(async () => dashboardScene.onEnterEditMode()); + await userEvents.click(getBasicInput()); + expect(queryBasicInnerContainer()).not.toBeInTheDocument(); + }); + + it('Hides the expand button when view mode is active', async () => { + await act(async () => dashboardScene.onEnterEditMode()); + expect(queryRootExpand()).not.toBeInTheDocument(); + }); + }); + + describe('Data requests', () => { + it('Enriches data requests', async () => { + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsExpand()); + await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getBasicInput()); + await waitFor(() => { + const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; + expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual( + mocksScopes.filter(({ metadata: { name } }) => name === 'slothPictureFactory') + ); + }); + + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsSlothVoteTrackerSelect()); + await userEvents.click(getBasicInput()); + await waitFor(() => { + const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; + expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual( + mocksScopes.filter( + ({ metadata: { name } }) => name === 'slothPictureFactory' || name === 'slothVoteTracker' + ) + ); + }); + + await userEvents.click(getBasicInput()); + await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getBasicInput()); + await waitFor(() => { + const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; + expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual( + mocksScopes.filter(({ metadata: { name } }) => name === 'slothVoteTracker') + ); + }); + }); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/scene/ScopesScene.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx similarity index 67% rename from public/app/features/dashboard-scene/scene/ScopesScene.tsx rename to public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx index dd4bda3c888..e37d99eb374 100644 --- a/public/app/features/dashboard-scene/scene/ScopesScene.tsx +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; import { IconButton, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { ScopesDashboardsScene } from './ScopesDashboardsScene'; import { ScopesFiltersScene } from './ScopesFiltersScene'; @@ -27,44 +28,52 @@ export class ScopesScene extends SceneObjectBase { }); this.addActivationHandler(() => { - this.state.filters.fetchBaseNodes(); + this._subs.add( + this.state.filters.subscribeToState((newState, prevState) => { + if (newState.scopes !== prevState.scopes) { + if (this.state.isExpanded) { + this.state.dashboards.fetchDashboards(newState.scopes); + } - const filtersValueSubscription = this.state.filters.subscribeToState((newState, prevState) => { - if (newState.scopes !== prevState.scopes) { - this.state.dashboards.fetchDashboards(newState.scopes); - sceneGraph.getTimeRange(this.parent!).onRefresh(); - } - }); - - const dashboardEditModeSubscription = this.parent?.subscribeToState((newState) => { - const isEditing = 'isEditing' in newState ? !!newState.isEditing : false; - - if (isEditing !== this.state.isViewing) { - if (isEditing) { - this.enterViewMode(); - } else { - this.exitViewMode(); + sceneGraph.getTimeRange(this.parent!).onRefresh(); } - } - }); + }) + ); - return () => { - filtersValueSubscription.unsubscribe(); - dashboardEditModeSubscription?.unsubscribe(); - }; + this._subs.add( + this.parent?.subscribeToState((newState) => { + const isEditing = 'isEditing' in newState ? !!newState.isEditing : false; + + if (isEditing !== this.state.isViewing) { + if (isEditing) { + this.enterViewMode(); + } else { + this.exitViewMode(); + } + } + }) + ); }); } public getSelectedScopes() { - return this.state.filters.state.scopes; + return this.state.filters.getSelectedScopes(); } public toggleIsExpanded() { - this.setState({ isExpanded: !this.state.isExpanded }); + const isExpanded = !this.state.isExpanded; + + if (isExpanded) { + this.state.dashboards.fetchDashboards(this.getSelectedScopes()); + } + + this.setState({ isExpanded }); } private enterViewMode() { this.setState({ isExpanded: false, isViewing: true }); + + this.state.filters.enterViewMode(); } private exitViewMode() { @@ -82,17 +91,21 @@ export function ScopesSceneRenderer({ model }: SceneComponentProps) {!isViewing && ( model.toggleIsExpanded()} /> )} - {isExpanded && ( -
+ {isExpanded && !isViewing && ( +
)} diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx new file mode 100644 index 00000000000..51b7d882e02 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx @@ -0,0 +1,141 @@ +import { css } from '@emotion/css'; +import { debounce } from 'lodash'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Checkbox, Icon, IconButton, Input, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { NodesMap } from './types'; + +export interface ScopesTreeLevelProps { + showQuery: boolean; + nodes: NodesMap; + nodePath: string[]; + loadingNodeName: string | undefined; + scopeNames: string[]; + onNodeUpdate: (path: string[], isExpanded: boolean, query: string) => void; + onNodeSelectToggle: (path: string[]) => void; +} + +export function ScopesTreeLevel({ + showQuery, + nodes, + nodePath, + loadingNodeName, + scopeNames, + 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 anyChildExpanded = childNodesArr.some(({ isExpanded }) => isExpanded); + const anyChildSelected = childNodesArr.some(({ linkId }) => linkId && scopeNames.includes(linkId!)); + + return ( + <> + {showQuery && !anyChildExpanded && ( + } + className={styles.searchInput} + disabled={!!loadingNodeName} + placeholder={t('scopes.tree.search', 'Filter')} + defaultValue={node.query} + data-testid={`scopes-tree-${nodeId}-search`} + onChange={debounce((evt) => { + onNodeUpdate(nodePath, true, evt.target.value); + }, 500)} + /> + )} + +
+ {childNodesArr.map((childNode) => { + const isSelected = childNode.isSelectable && scopeNames.includes(childNode.linkId!); + + if (anyChildExpanded && !childNode.isExpanded && !isSelected) { + return null; + } + + const childNodePath = [...nodePath, childNode.name]; + + return ( +
+
+ {childNode.isSelectable && !childNode.isExpanded ? ( + { + onNodeSelectToggle(childNodePath); + }} + /> + ) : null} + + {childNode.isExpandable && ( + { + onNodeUpdate(childNodePath, !childNode.isExpanded, childNode.query); + }} + /> + )} + + {childNode.title} +
+ +
+ {childNode.isExpanded && ( + + )} +
+
+ ); + })} +
+ + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + searchInput: css({ + margin: theme.spacing(1, 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), + }), + itemChildren: css({ + paddingLeft: theme.spacing(4), + }), + }; +}; diff --git a/public/app/features/dashboard-scene/scene/Scopes/api.ts b/public/app/features/dashboard-scene/scene/Scopes/api.ts new file mode 100644 index 00000000000..43e1e7931ed --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/api.ts @@ -0,0 +1,103 @@ +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'; + +const group = 'scope.grafana.app'; +const version = 'v0alpha1'; +const namespace = config.namespace ?? 'default'; + +const nodesEndpoint = `/apis/${group}/${version}/namespaces/${namespace}/find/scope_node_children`; +const dashboardsEndpoint = `/apis/${group}/${version}/namespaces/${namespace}/find/scope_dashboard_bindings`; + +const scopesClient = new ScopedResourceClient({ + group, + version, + resource: 'scopes', +}); + +const scopesCache = new Map>(); + +async function fetchScopeNodes(parent: string, query: string): Promise { + try { + return (await getBackendSrv().get<{ items: ScopeNode[] }>(nodesEndpoint, { parent, query }))?.items ?? []; + } catch (err) { + return []; + } +} + +export async function fetchNodes(parent: string, query: string): Promise { + return (await fetchScopeNodes(parent, query)).reduce((acc, { metadata: { name }, spec }) => { + acc[name] = { + name, + ...spec, + isExpandable: spec.nodeType === 'container', + isSelectable: spec.linkType === 'scope', + isExpanded: false, + query: '', + nodes: {}, + }; + return acc; + }, {}); +} + +export async function fetchScope(name: string): Promise { + if (scopesCache.has(name)) { + return scopesCache.get(name)!; + } + + const response = new Promise(async (resolve) => { + const basicScope: Scope = { + metadata: { name }, + spec: { + filters: [], + title: name, + type: '', + category: '', + description: '', + }, + }; + + try { + const serverScope = await scopesClient.get(name); + + const scope = { + ...basicScope, + metadata: { + ...basicScope.metadata, + ...serverScope.metadata, + }, + spec: { + ...basicScope.spec, + ...serverScope.spec, + }, + }; + + resolve(scope); + } catch (err) { + scopesCache.delete(name); + + resolve(basicScope); + } + }); + + scopesCache.set(name, response); + + return response; +} + +export async function fetchScopes(names: string[]): Promise { + return await Promise.all(names.map(fetchScope)); +} + +export async function fetchDashboards(scopes: Scope[]): Promise { + try { + const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(dashboardsEndpoint, { + scope: scopes.map(({ metadata: { name } }) => name), + }); + + return response?.items ?? []; + } catch (err) { + return []; + } +} diff --git a/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx b/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx new file mode 100644 index 00000000000..7288f5f29e4 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx @@ -0,0 +1,330 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; +import { render } from 'test/test-utils'; + +import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data'; +import { behaviors, SceneGridItem, SceneGridLayout, SceneQueryRunner, SceneTimeRange, VizPanel } from '@grafana/scenes'; +import { DashboardControls } from 'app/features/dashboard-scene/scene//DashboardControls'; +import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; + +import * as api from './api'; + +export const mocksScopes: Scope[] = [ + { + metadata: { name: 'indexHelperCluster' }, + spec: { + title: 'Cluster Index Helper', + type: 'indexHelper', + description: 'redundant label filter but makes queries faster', + category: 'indexHelpers', + filters: [{ key: 'indexHelper', value: 'cluster', operator: 'equals' }], + }, + }, + { + metadata: { name: 'slothClusterNorth' }, + spec: { + title: 'slothClusterNorth', + type: 'cluster', + description: 'slothClusterNorth', + category: 'clusters', + filters: [{ key: 'cluster', value: 'slothClusterNorth', operator: 'equals' }], + }, + }, + { + metadata: { name: 'slothClusterSouth' }, + spec: { + title: 'slothClusterSouth', + type: 'cluster', + description: 'slothClusterSouth', + category: 'clusters', + filters: [{ key: 'cluster', value: 'slothClusterSouth', operator: 'equals' }], + }, + }, + { + metadata: { name: 'slothPictureFactory' }, + spec: { + title: 'slothPictureFactory', + type: 'app', + description: 'slothPictureFactory', + category: 'apps', + filters: [{ key: 'app', value: 'slothPictureFactory', operator: 'equals' }], + }, + }, + { + metadata: { name: 'slothVoteTracker' }, + spec: { + title: 'slothVoteTracker', + type: 'app', + description: 'slothVoteTracker', + category: 'apps', + filters: [{ key: 'app', value: 'slothVoteTracker', operator: 'equals' }], + }, + }, +] as const; + +export const mocksScopeDashboardBindings: ScopeDashboardBinding[] = [ + { + metadata: { name: 'binding1' }, + spec: { dashboard: '1', dashboardTitle: 'My Dashboard 1', scope: 'slothPictureFactory' }, + }, + { + metadata: { name: 'binding2' }, + spec: { dashboard: '2', dashboardTitle: 'My Dashboard 2', scope: 'slothPictureFactory' }, + }, + { + metadata: { name: 'binding3' }, + spec: { dashboard: '3', dashboardTitle: 'My Dashboard 3', scope: 'slothVoteTracker' }, + }, + { + metadata: { name: 'binding4' }, + spec: { dashboard: '4', dashboardTitle: 'My Dashboard 4', scope: 'slothVoteTracker' }, + }, +] as const; + +export const mocksNodes: Array = [ + { + parent: '', + metadata: { name: 'applications' }, + spec: { + nodeType: 'container', + title: 'Applications', + description: 'Application Scopes', + }, + }, + { + parent: '', + metadata: { name: 'clusters' }, + spec: { + nodeType: 'container', + title: 'Clusters', + description: 'Cluster Scopes', + disableMultiSelect: true, + linkType: 'scope', + linkId: 'indexHelperCluster', + }, + }, + { + parent: 'applications', + metadata: { name: 'applications-slothPictureFactory' }, + spec: { + nodeType: 'leaf', + title: 'slothPictureFactory', + description: 'slothPictureFactory', + linkType: 'scope', + linkId: 'slothPictureFactory', + }, + }, + { + parent: 'applications', + metadata: { name: 'applications-slothVoteTracker' }, + spec: { + nodeType: 'leaf', + title: 'slothVoteTracker', + description: 'slothVoteTracker', + linkType: 'scope', + linkId: 'slothVoteTracker', + }, + }, + { + parent: 'applications', + metadata: { name: 'applications.clusters' }, + spec: { + nodeType: 'container', + title: 'Clusters', + description: 'Application/Clusters Scopes', + linkType: 'scope', + linkId: 'indexHelperCluster', + }, + }, + { + parent: 'applications.clusters', + metadata: { name: 'applications.clusters-slothClusterNorth' }, + spec: { + nodeType: 'leaf', + title: 'slothClusterNorth', + description: 'slothClusterNorth', + linkType: 'scope', + linkId: 'slothClusterNorth', + }, + }, + { + parent: 'applications.clusters', + metadata: { name: 'applications.clusters-slothClusterSouth' }, + spec: { + nodeType: 'leaf', + title: 'slothClusterSouth', + description: 'slothClusterSouth', + linkType: 'scope', + linkId: 'slothClusterSouth', + }, + }, + { + parent: 'clusters', + metadata: { name: 'clusters-slothClusterNorth' }, + spec: { + nodeType: 'leaf', + title: 'slothClusterNorth', + description: 'slothClusterNorth', + linkType: 'scope', + linkId: 'slothClusterNorth', + }, + }, + { + parent: 'clusters', + metadata: { name: 'clusters-slothClusterSouth' }, + spec: { + nodeType: 'leaf', + title: 'slothClusterSouth', + description: 'slothClusterSouth', + linkType: 'scope', + linkId: 'slothClusterSouth', + }, + }, + { + parent: 'clusters', + metadata: { name: 'clusters.applications' }, + spec: { + nodeType: 'container', + title: 'Applications', + description: 'Clusters/Application Scopes', + }, + }, + { + parent: 'clusters.applications', + metadata: { name: 'clusters.applications-slothPictureFactory' }, + spec: { + nodeType: 'leaf', + title: 'slothPictureFactory', + description: 'slothPictureFactory', + linkType: 'scope', + linkId: 'slothPictureFactory', + }, + }, + { + parent: 'clusters.applications', + metadata: { name: 'clusters.applications-slothVoteTracker' }, + spec: { + nodeType: 'leaf', + title: 'slothVoteTracker', + description: 'slothVoteTracker', + linkType: 'scope', + linkId: 'slothVoteTracker', + }, + }, +] as const; + +export const fetchNodesSpy = jest.spyOn(api, 'fetchNodes'); +export const fetchScopeSpy = jest.spyOn(api, 'fetchScope'); +export const fetchScopesSpy = jest.spyOn(api, 'fetchScopes'); +export const fetchDashboardsSpy = jest.spyOn(api, 'fetchDashboards'); + +const selectors = { + root: { + expand: 'scopes-root-expand', + }, + tree: { + search: (nodeId: string) => `scopes-tree-${nodeId}-search`, + select: (nodeId: string) => `scopes-tree-${nodeId}-checkbox`, + expand: (nodeId: string) => `scopes-tree-${nodeId}-expand`, + title: (nodeId: string) => `scopes-tree-${nodeId}-title`, + }, + basicSelector: { + container: 'scopes-basic-container', + innerContainer: 'scopes-basic-inner-container', + loading: 'scopes-basic-loading', + openAdvanced: 'scopes-basic-open-advanced', + input: 'scopes-basic-input', + }, + advancedSelector: { + container: 'scopes-advanced-container', + loading: 'scopes-advanced-loading', + apply: 'scopes-advanced-apply', + cancel: 'scopes-advanced-cancel', + }, + dashboards: { + container: 'scopes-dashboards-container', + search: 'scopes-dashboards-search', + loading: 'scopes-dashboards-loading', + dashboard: (uid: string) => `scopes-dashboards-${uid}`, + }, +}; + +export const queryRootExpand = () => screen.queryByTestId(selectors.root.expand); +export const getRootExpand = () => screen.getByTestId(selectors.root.expand); + +export const queryBasicInnerContainer = () => screen.queryByTestId(selectors.basicSelector.innerContainer); +export const getBasicInnerContainer = () => screen.getByTestId(selectors.basicSelector.innerContainer); +export const getBasicInput = () => screen.getByTestId(selectors.basicSelector.input); +export const getBasicOpenAdvanced = () => screen.getByTestId(selectors.basicSelector.openAdvanced); + +export const queryAdvancedApply = () => screen.queryByTestId(selectors.advancedSelector.apply); +export const getAdvancedApply = () => screen.getByTestId(selectors.advancedSelector.apply); +export const getAdvancedCancel = () => screen.getByTestId(selectors.advancedSelector.cancel); + +export const queryDashboardsContainer = () => screen.queryByTestId(selectors.dashboards.container); +export const getDashboardsContainer = () => screen.getByTestId(selectors.dashboards.container); +export const getDashboardsSearch = () => screen.getByTestId(selectors.dashboards.search); +export const queryDashboard = (uid: string) => screen.queryByTestId(selectors.dashboards.dashboard(uid)); +export const getDashboard = (uid: string) => screen.getByTestId(selectors.dashboards.dashboard(uid)); + +export const getApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications')); +export const getApplicationsSearch = () => screen.getByTestId(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 getClustersSelect = () => screen.getByTestId(selectors.tree.select('clusters')); +export const getClustersExpand = () => screen.getByTestId(selectors.tree.expand('clusters')); +export const getClustersSlothClusterNorthSelect = () => + screen.getByTestId(selectors.tree.select('clusters-slothClusterNorth')); +export const getClustersSlothClusterSouthSelect = () => + screen.getByTestId(selectors.tree.select('clusters-slothClusterSouth')); + +export function buildTestScene(overrides: Partial = {}) { + return new DashboardScene({ + title: 'hello', + uid: 'dash-1', + description: 'hello description', + tags: ['tag1', 'tag2'], + editable: true, + $timeRange: new SceneTimeRange({ + timeZone: 'browser', + }), + controls: new DashboardControls({}), + $behaviors: [new behaviors.CursorSync({})], + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 300, + height: 300, + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), + }), + }), + ], + }), + ...overrides, + }); +} + +export function renderDashboard(dashboardScene: DashboardScene) { + return render(); +} diff --git a/public/app/features/dashboard-scene/scene/Scopes/types.ts b/public/app/features/dashboard-scene/scene/Scopes/types.ts new file mode 100644 index 00000000000..ed68e0c8ff6 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/types.ts @@ -0,0 +1,12 @@ +import { ScopeNodeSpec } from '@grafana/data'; + +export interface Node extends ScopeNodeSpec { + name: string; + isExpandable: boolean; + isSelectable: boolean; + isExpanded: boolean; + query: string; + nodes: NodesMap; +} + +export type NodesMap = Record; diff --git a/public/app/features/dashboard-scene/scene/ScopesDashboardsScene.tsx b/public/app/features/dashboard-scene/scene/ScopesDashboardsScene.tsx deleted file mode 100644 index 5322c1e5e6f..00000000000 --- a/public/app/features/dashboard-scene/scene/ScopesDashboardsScene.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { css } from '@emotion/css'; -import React from 'react'; -import { Link } from 'react-router-dom'; - -import { AppEvents, GrafanaTheme2, Scope, ScopeDashboardBindingSpec, urlUtil } from '@grafana/data'; -import { getAppEvents, getBackendSrv } from '@grafana/runtime'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; -import { CustomScrollbar, Icon, Input, useStyles2 } from '@grafana/ui'; -import { useQueryParams } from 'app/core/hooks/useQueryParams'; -import { ScopedResourceClient } from 'app/features/apiserver/client'; - -export interface ScopeDashboard { - uid: string; - title: string; - url: string; -} - -export interface ScopesDashboardsSceneState extends SceneObjectState { - dashboards: ScopeDashboard[]; - filteredDashboards: ScopeDashboard[]; - isLoading: boolean; - searchQuery: string; -} - -export class ScopesDashboardsScene extends SceneObjectBase { - static Component = ScopesDashboardsSceneRenderer; - - private server = new ScopedResourceClient({ - group: 'scope.grafana.app', - version: 'v0alpha1', - resource: 'scopedashboardbindings', - }); - - constructor() { - super({ - dashboards: [], - filteredDashboards: [], - isLoading: false, - searchQuery: '', - }); - } - - public async fetchDashboards(scopes: Scope[]) { - if (scopes.length === 0) { - return this.setState({ dashboards: [], filteredDashboards: [], isLoading: false }); - } - - this.setState({ isLoading: true }); - - const dashboardUids = await Promise.all( - scopes.map((scope) => this.fetchDashboardsUids(scope.metadata.name).catch(() => [])) - ); - const dashboards = await this.fetchDashboardsDetails(dashboardUids.flat()); - - this.setState({ - dashboards, - filteredDashboards: this.filterDashboards(dashboards, this.state.searchQuery), - isLoading: false, - }); - } - - public changeSearchQuery(searchQuery: string) { - this.setState({ - filteredDashboards: searchQuery - ? this.filterDashboards(this.state.dashboards, searchQuery) - : this.state.dashboards, - searchQuery: searchQuery ?? '', - }); - } - - private async fetchDashboardsUids(scope: string): Promise { - try { - const response = await this.server.list({ - fieldSelector: [ - { - key: 'spec.scope', - operator: '=', - value: scope, - }, - ], - }); - - return response.items.map((item) => item.spec.dashboard).filter((dashboardUid) => !!dashboardUid) ?? []; - } catch (err) { - return []; - } - } - - private async fetchDashboardsDetails(dashboardUids: string[]): Promise { - try { - const dashboards = await Promise.all( - dashboardUids.map((dashboardUid) => this.fetchDashboardDetails(dashboardUid)) - ); - - return dashboards.filter((dashboard): dashboard is ScopeDashboard => !!dashboard); - } catch (err) { - getAppEvents().publish({ - type: AppEvents.alertError.name, - payload: ['Failed to fetch suggested dashboards'], - }); - - return []; - } - } - - private async fetchDashboardDetails(dashboardUid: string): Promise { - try { - const dashboard = await getBackendSrv().get(`/api/dashboards/uid/${dashboardUid}`); - - return { - uid: dashboard.dashboard.uid, - title: dashboard.dashboard.title, - url: dashboard.meta.url, - }; - } catch (err) { - return undefined; - } - } - - private filterDashboards(dashboards: ScopeDashboard[], searchQuery: string) { - const lowerCasedSearchQuery = searchQuery.toLowerCase(); - return dashboards.filter((dashboard) => dashboard.title.toLowerCase().includes(lowerCasedSearchQuery)); - } -} - -export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps) { - const { filteredDashboards, isLoading } = model.useState(); - const styles = useStyles2(getStyles); - - const [queryParams] = useQueryParams(); - - return ( - <> -
- } - disabled={isLoading} - onChange={(evt) => model.changeSearchQuery(evt.currentTarget.value)} - /> -
- - - {filteredDashboards.map((dashboard, idx) => ( -
- {dashboard.title} -
- ))} -
- - ); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - searchInputContainer: css({ - flex: '0 1 auto', - }), - dashboardItem: css({ - padding: theme.spacing(1, 0), - borderBottom: `1px solid ${theme.colors.border.weak}`, - - ':first-child': { - paddingTop: 0, - }, - }), - }; -}; diff --git a/public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx b/public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx deleted file mode 100644 index d33012e780a..00000000000 --- a/public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx +++ /dev/null @@ -1,309 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React from 'react'; - -import { AppEvents, GrafanaTheme2, Scope, ScopeSpec, ScopeTreeItemSpec } from '@grafana/data'; -import { config, getAppEvents, getBackendSrv } from '@grafana/runtime'; -import { - SceneComponentProps, - SceneObjectBase, - SceneObjectState, - SceneObjectUrlSyncConfig, - SceneObjectUrlValues, -} from '@grafana/scenes'; -import { Checkbox, Icon, Input, Toggletip, useStyles2 } from '@grafana/ui'; -import { ScopedResourceClient } from 'app/features/apiserver/client'; - -export interface Node { - item: ScopeTreeItemSpec; - isScope: boolean; - children: Record; -} - -export interface ScopesFiltersSceneState extends SceneObjectState { - nodes: Record; - expandedNodes: string[]; - scopes: Scope[]; -} - -export class ScopesFiltersScene extends SceneObjectBase { - static Component = ScopesFiltersSceneRenderer; - - protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scopes'] }); - - private serverGroup = 'scope.grafana.app'; - private serverVersion = 'v0alpha1'; - private serverNamespace = config.namespace; - - private server = new ScopedResourceClient({ - group: this.serverGroup, - version: this.serverVersion, - resource: 'scopes', - }); - - constructor() { - super({ - nodes: {}, - expandedNodes: [], - scopes: [], - }); - } - - getUrlState() { - return { scopes: this.state.scopes.map((scope) => scope.metadata.name) }; - } - - updateFromUrl(values: SceneObjectUrlValues) { - let scopesNames = values.scopes ?? []; - scopesNames = Array.isArray(scopesNames) ? scopesNames : [scopesNames]; - - const scopesPromises = scopesNames.map((scopeName) => this.server.get(scopeName)); - - Promise.all(scopesPromises).then((scopes) => { - this.setState({ - scopes: scopesNames.map((scopeName, scopeNameIdx) => - this.mergeScopeNameWithScopes(scopeName, scopes[scopeNameIdx] ?? {}) - ), - }); - }); - } - - public async fetchTreeItems(nodeId: string): Promise> { - try { - return ( - ( - await getBackendSrv().get<{ items: ScopeTreeItemSpec[] }>( - `/apis/${this.serverGroup}/${this.serverVersion}/namespaces/${this.serverNamespace}/find`, - { parent: nodeId } - ) - )?.items ?? [] - ).reduce>((acc, item) => { - acc[item.nodeId] = { - item, - isScope: item.nodeType === 'leaf', - children: {}, - }; - - return acc; - }, {}); - } catch (err) { - getAppEvents().publish({ - type: AppEvents.alertError.name, - payload: ['Failed to fetch tree items'], - }); - - return {}; - } - } - - public async fetchScopes(parent: string) { - try { - return (await this.server.list({ labelSelector: [{ key: 'category', operator: '=', value: parent }] }))?.items; - } catch (err) { - getAppEvents().publish({ - type: AppEvents.alertError.name, - payload: ['Failed to fetch scopes'], - }); - return []; - } - } - - public async expandNode(path: string[]) { - let nodes = { ...this.state.nodes }; - let currentLevel = nodes; - - for (let idx = 0; idx < path.length; idx++) { - const nodeId = path[idx]; - const isLast = idx === path.length - 1; - const currentNode = currentLevel[nodeId]; - - currentLevel[nodeId] = { - ...currentNode, - children: isLast ? await this.fetchTreeItems(nodeId) : currentLevel[nodeId].children, - }; - - currentLevel = currentNode.children; - } - - this.setState({ - nodes, - expandedNodes: path, - }); - } - - public async fetchBaseNodes() { - this.setState({ - nodes: await this.fetchTreeItems(''), - expandedNodes: [], - }); - } - - public async toggleScope(linkId: string) { - let scopes = [...this.state.scopes]; - const selectedIdx = scopes.findIndex((scope) => scope.metadata.name === linkId); - - if (selectedIdx === -1) { - const receivedScope = await this.server.get(linkId); - - scopes.push(this.mergeScopeNameWithScopes(linkId, receivedScope ?? {})); - } else { - scopes.splice(selectedIdx, 1); - } - - this.setState({ scopes }); - } - - private mergeScopeNameWithScopes(scopeName: string, scope: Partial): Scope { - return { - ...scope, - metadata: { - name: scopeName, - ...scope.metadata, - }, - spec: { - filters: [], - title: scopeName, - type: '', - category: '', - description: '', - ...scope.spec, - }, - }; - } -} - -export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps) { - const { nodes, expandedNodes, scopes } = model.useState(); - const parentState = model.parent!.useState(); - const isViewing = 'isViewing' in parentState ? !!parentState.isViewing : false; - - const handleNodeExpand = (path: string[]) => model.expandNode(path); - const handleScopeToggle = (linkId: string) => model.toggleScope(linkId); - - return ( - - } - footer={'Open advanced scope selector'} - closeButton={false} - > - scope.spec.title)} /> - - ); -} - -export interface ScopesTreeLevelProps { - isExpanded: boolean; - path: string[]; - nodes: Record; - expandedNodes: string[]; - scopes: Scope[]; - onNodeExpand: (path: string[]) => void; - onScopeToggle: (linkId: string) => void; -} - -export function ScopesTreeLevel({ - isExpanded, - path, - nodes, - expandedNodes, - scopes, - onNodeExpand, - onScopeToggle, -}: ScopesTreeLevelProps) { - const styles = useStyles2(getStyles); - - if (!isExpanded) { - return null; - } - - return ( -
0 ? styles.innerLevelContainer : undefined}> - {Object.values(nodes).map((node) => { - const { - item: { nodeId, linkId }, - isScope, - children, - } = node; - const nodePath = [...path, nodeId]; - const isExpanded = expandedNodes.includes(nodeId); - const isSelected = isScope && !!scopes.find((scope) => scope.metadata.name === linkId); - - return ( -
{ - evt.stopPropagation(); - onNodeExpand(nodePath); - }} - onKeyDown={(evt) => { - evt.stopPropagation(); - onNodeExpand(nodePath); - }} - > - {!isScope ? ( - - ) : ( - { - evt.stopPropagation(); - - if (linkId) { - onScopeToggle(linkId); - } - }} - /> - )} - - {node.item.title} - - -
- ); - })} -
- ); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - innerLevelContainer: css({ - marginLeft: theme.spacing(2), - }), - item: css({ - cursor: 'pointer', - margin: theme.spacing(1, 0), - }), - itemScope: css({ - cursor: 'default', - }), - icon: css({ - marginRight: theme.spacing(1), - }), - checkbox: css({ - marginRight: theme.spacing(1), - }), - }; -}; diff --git a/public/app/features/dashboard-scene/scene/ScopesScene.test.tsx b/public/app/features/dashboard-scene/scene/ScopesScene.test.tsx deleted file mode 100644 index 6efe679a873..00000000000 --- a/public/app/features/dashboard-scene/scene/ScopesScene.test.tsx +++ /dev/null @@ -1,414 +0,0 @@ -import { waitFor } from '@testing-library/react'; - -import { Scope, ScopeDashboardBindingSpec, ScopeTreeItemSpec } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { - behaviors, - sceneGraph, - SceneGridItem, - SceneGridLayout, - SceneQueryRunner, - SceneTimeRange, - VizPanel, -} from '@grafana/scenes'; - -import { DashboardControls } from './DashboardControls'; -import { DashboardScene } from './DashboardScene'; -import { ScopeDashboard, ScopesDashboardsScene } from './ScopesDashboardsScene'; -import { ScopesFiltersScene } from './ScopesFiltersScene'; -import { ScopesScene } from './ScopesScene'; - -const mocksScopes: Scope[] = [ - { - metadata: { name: 'indexHelperCluster' }, - spec: { - title: 'Cluster Index Helper', - type: 'indexHelper', - description: 'redundant label filter but makes queries faster', - category: 'indexHelpers', - filters: [{ key: 'indexHelper', value: 'cluster', operator: 'equals' }], - }, - }, - { - metadata: { name: 'slothClusterNorth' }, - spec: { - title: 'slothClusterNorth', - type: 'cluster', - description: 'slothClusterNorth', - category: 'clusters', - filters: [{ key: 'cluster', value: 'slothClusterNorth', operator: 'equals' }], - }, - }, - { - metadata: { name: 'slothClusterSouth' }, - spec: { - title: 'slothClusterSouth', - type: 'cluster', - description: 'slothClusterSouth', - category: 'clusters', - filters: [{ key: 'cluster', value: 'slothClusterSouth', operator: 'equals' }], - }, - }, - { - metadata: { name: 'slothPictureFactory' }, - spec: { - title: 'slothPictureFactory', - type: 'app', - description: 'slothPictureFactory', - category: 'apps', - filters: [{ key: 'app', value: 'slothPictureFactory', operator: 'equals' }], - }, - }, - { - metadata: { name: 'slothVoteTracker' }, - spec: { - title: 'slothVoteTracker', - type: 'app', - description: 'slothVoteTracker', - category: 'apps', - filters: [{ key: 'app', value: 'slothVoteTracker', operator: 'equals' }], - }, - }, -] as const; - -const mocksScopeDashboardBindings: ScopeDashboardBindingSpec[] = [ - { dashboard: '1', scope: 'slothPictureFactory' }, - { dashboard: '2', scope: 'slothPictureFactory' }, - { dashboard: '3', scope: 'slothVoteTracker' }, - { dashboard: '4', scope: 'slothVoteTracker' }, -] as const; - -const mocksNodes: ScopeTreeItemSpec[] = [ - { - nodeId: 'applications', - nodeType: 'container', - title: 'Applications', - description: 'Application Scopes', - }, - { - nodeId: 'clusters', - nodeType: 'container', - title: 'Clusters', - description: 'Cluster Scopes', - linkType: 'scope', - linkId: 'indexHelperCluster', - }, - { - nodeId: 'applications-slothPictureFactory', - nodeType: 'leaf', - title: 'slothPictureFactory', - description: 'slothPictureFactory', - linkType: 'scope', - linkId: 'slothPictureFactory', - }, - { - nodeId: 'applications-slothVoteTracker', - nodeType: 'leaf', - title: 'slothVoteTracker', - description: 'slothVoteTracker', - linkType: 'scope', - linkId: 'slothVoteTracker', - }, - { - nodeId: 'applications.clusters', - nodeType: 'container', - title: 'Clusters', - description: 'Application/Clusters Scopes', - linkType: 'scope', - linkId: 'indexHelperCluster', - }, - { - nodeId: 'applications.clusters-slothClusterNorth', - nodeType: 'leaf', - title: 'slothClusterNorth', - description: 'slothClusterNorth', - linkType: 'scope', - linkId: 'slothClusterNorth', - }, - { - nodeId: 'applications.clusters-slothClusterSouth', - nodeType: 'leaf', - title: 'slothClusterSouth', - description: 'slothClusterSouth', - linkType: 'scope', - linkId: 'slothClusterSouth', - }, - { - nodeId: 'clusters-slothClusterNorth', - nodeType: 'leaf', - title: 'slothClusterNorth', - description: 'slothClusterNorth', - linkType: 'scope', - linkId: 'slothClusterNorth', - }, - { - nodeId: 'clusters-slothClusterSouth', - nodeType: 'leaf', - title: 'slothClusterSouth', - description: 'slothClusterSouth', - linkType: 'scope', - linkId: 'slothClusterSouth', - }, - { - nodeId: 'clusters.applications', - nodeType: 'container', - title: 'Applications', - description: 'Clusters/Application Scopes', - }, - { - nodeId: 'clusters.applications-slothPictureFactory', - nodeType: 'leaf', - title: 'slothPictureFactory', - description: 'slothPictureFactory', - linkType: 'scope', - linkId: 'slothPictureFactory', - }, - { - nodeId: 'clusters.applications-slothVoteTracker', - nodeType: 'leaf', - title: 'slothVoteTracker', - description: 'slothVoteTracker', - linkType: 'scope', - linkId: 'slothVoteTracker', - }, -] as const; - -const getDashboardDetailsForUid = (uid: string) => ({ - dashboard: { - title: `Dashboard ${uid}`, - uid, - }, - meta: { - url: `/d/dashboard${uid}`, - }, -}); -const getDashboardScopeForUid = (uid: string) => ({ - title: `Dashboard ${uid}`, - uid, - url: `/d/dashboard${uid}`, -}); - -jest.mock('@grafana/runtime', () => ({ - __esModule: true, - ...jest.requireActual('@grafana/runtime'), - getBackendSrv: () => ({ - get: jest.fn().mockImplementation((url: string) => { - const search = new URLSearchParams(url.split('?').pop() || ''); - - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find')) { - const parent = search.get('parent')?.replace('parent=', ''); - - return { - items: mocksNodes.filter((node) => (parent ? node.nodeId.startsWith(parent) : !node.nodeId.includes('-'))), - }; - } - - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/')) { - const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/', ''); - - return mocksScopes.find((scope) => scope.metadata.name === name) ?? {}; - } - - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopedashboardbindings')) { - const scope = search.get('fieldSelector')?.replace('spec.scope=', '') ?? ''; - - return { - items: mocksScopeDashboardBindings.filter((binding) => binding.scope === scope), - }; - } - - if (url.startsWith('/api/dashboards/uid/')) { - const uid = url.split('/').pop(); - - return uid ? getDashboardDetailsForUid(uid) : {}; - } - - return {}; - }), - }), -})); - -describe('ScopesScene', () => { - describe('Feature flag off', () => { - beforeAll(() => { - config.featureToggles.scopeFilters = false; - }); - - it('Does not initialize', () => { - const dashboardScene = buildTestScene(); - dashboardScene.activate(); - const scopesScene = dashboardScene.state.scopes; - - expect(scopesScene).toBeUndefined(); - }); - }); - - describe('Feature flag on', () => { - let scopesNames: string[]; - let scopes: Scope[]; - let scopeDashboardBindings: ScopeDashboardBindingSpec[][]; - let dashboards: ScopeDashboard[][]; - let dashboardScene: DashboardScene; - let scopesScene: ScopesScene; - let filtersScene: ScopesFiltersScene; - let dashboardsScene: ScopesDashboardsScene; - let fetchBaseNodesSpy: jest.SpyInstance; - let fetchScopesSpy: jest.SpyInstance; - let fetchDashboardsSpy: jest.SpyInstance; - - beforeAll(() => { - config.featureToggles.scopeFilters = true; - }); - - beforeEach(() => { - scopesNames = ['slothClusterNorth', 'slothClusterSouth']; - scopes = scopesNames.map((scopeName) => mocksScopes.find((scope) => scope.metadata.name === scopeName)!); - scopeDashboardBindings = scopesNames.map( - (scopeName) => mocksScopeDashboardBindings.filter((binding) => binding.scope === scopeName)! - ); - dashboards = scopeDashboardBindings.map((bindings) => - bindings.map((binding) => getDashboardScopeForUid(binding.dashboard)) - ); - dashboardScene = buildTestScene(); - scopesScene = dashboardScene.state.scopes!; - filtersScene = scopesScene.state.filters; - dashboardsScene = scopesScene.state.dashboards; - fetchBaseNodesSpy = jest.spyOn(filtersScene!, 'fetchBaseNodes'); - fetchScopesSpy = jest.spyOn(filtersScene!, 'fetchScopes'); - fetchDashboardsSpy = jest.spyOn(dashboardsScene!, 'fetchDashboards'); - dashboardScene.activate(); - scopesScene.activate(); - filtersScene.activate(); - dashboardsScene.activate(); - }); - - it('Initializes', () => { - expect(scopesScene).toBeInstanceOf(ScopesScene); - expect(filtersScene).toBeInstanceOf(ScopesFiltersScene); - expect(dashboardsScene).toBeInstanceOf(ScopesDashboardsScene); - }); - - it('Fetches nodes list', () => { - expect(fetchBaseNodesSpy).toHaveBeenCalled(); - }); - - it('Fetches scope details', () => { - filtersScene.toggleScope(scopesNames[0]); - waitFor(() => { - expect(fetchScopesSpy).toHaveBeenCalled(); - expect(filtersScene.state.scopes).toEqual(scopes.filter((scope) => scope.metadata.name === scopesNames[0])); - }); - - filtersScene.toggleScope(scopesNames[1]); - waitFor(() => { - expect(fetchScopesSpy).toHaveBeenCalled(); - expect(filtersScene.state.scopes).toEqual(scopes); - }); - - filtersScene.toggleScope(scopesNames[0]); - waitFor(() => { - expect(fetchScopesSpy).toHaveBeenCalled(); - expect(filtersScene.state.scopes).toEqual(scopes.filter((scope) => scope.metadata.name === scopesNames[1])); - }); - }); - - it('Fetches dashboards list', () => { - filtersScene.toggleScope(scopesNames[0]); - waitFor(() => { - expect(fetchDashboardsSpy).toHaveBeenCalled(); - expect(dashboardsScene.state.dashboards).toEqual(dashboards[0]); - }); - - filtersScene.toggleScope(scopesNames[1]); - waitFor(() => { - expect(fetchDashboardsSpy).toHaveBeenCalled(); - expect(dashboardsScene.state.dashboards).toEqual(dashboards.flat()); - }); - - filtersScene.toggleScope(scopesNames[0]); - - waitFor(() => { - expect(fetchDashboardsSpy).toHaveBeenCalled(); - expect(dashboardsScene.state.dashboards).toEqual(dashboards[1]); - }); - }); - - it('Enriches data requests', () => { - filtersScene.toggleScope(scopesNames[0]); - waitFor(() => { - const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; - expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual( - scopes.filter((scope) => scope.metadata.name === scopesNames[0]) - ); - }); - - filtersScene.toggleScope(scopesNames[1]); - waitFor(() => { - const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; - expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(scopes); - }); - - filtersScene.toggleScope(scopesNames[0]); - waitFor(() => { - const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; - expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual( - scopes.filter((scope) => scope.metadata.name === scopesNames[1]) - ); - }); - }); - - it('Toggles expanded state', () => { - scopesScene.toggleIsExpanded(); - - expect(scopesScene.state.isExpanded).toEqual(true); - }); - - it('Enters view mode', () => { - dashboardScene.onEnterEditMode(); - - expect(scopesScene.state.isViewing).toEqual(true); - expect(scopesScene.state.isExpanded).toEqual(false); - }); - - it('Exits view mode', () => { - dashboardScene.onEnterEditMode(); - dashboardScene.exitEditMode({ skipConfirm: true }); - - expect(scopesScene.state.isViewing).toEqual(false); - expect(scopesScene.state.isExpanded).toEqual(false); - }); - }); -}); - -function buildTestScene(overrides: Partial = {}) { - return new DashboardScene({ - title: 'hello', - uid: 'dash-1', - description: 'hello description', - tags: ['tag1', 'tag2'], - editable: true, - $timeRange: new SceneTimeRange({ - timeZone: 'browser', - }), - controls: new DashboardControls({}), - $behaviors: [new behaviors.CursorSync({})], - body: new SceneGridLayout({ - children: [ - new SceneGridItem({ - key: 'griditem-1', - x: 0, - y: 0, - width: 300, - height: 300, - body: new VizPanel({ - title: 'Panel A', - key: 'panel-1', - pluginId: 'table', - $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), - }), - }), - ], - }), - ...overrides, - }); -} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 802bb676d3e..c35ef31dc36 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1512,6 +1512,31 @@ }, "dismissable-button": "Close" }, + "scopes": { + "advancedSelector": { + "apply": "Apply", + "cancel": "Cancel", + "title": "Select scopes" + }, + "basicSelector": { + "openAdvanced": "Open advanced scope selector <1>", + "placeholder": "Select scopes...", + "removeAll": "Remove all scopes" + }, + "root": { + "collapse": "Collapse scope filters", + "expand": "Expand scope filters" + }, + "suggestedDashboards": { + "loading": "Loading dashboards", + "search": "Filter" + }, + "tree": { + "collapse": "Collapse", + "expand": "Expand", + "search": "Filter" + } + }, "search": { "actions": { "include-panels": "Include panels", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 855683388bd..fe230f7e22e 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1512,6 +1512,31 @@ }, "dismissable-button": "Cľőşę" }, + "scopes": { + "advancedSelector": { + "apply": "Åppľy", + "cancel": "Cäʼnčęľ", + "title": "Ŝęľęčŧ şčőpęş" + }, + "basicSelector": { + "openAdvanced": "Øpęʼn äđväʼnčęđ şčőpę şęľęčŧőř <1>", + "placeholder": "Ŝęľęčŧ şčőpęş...", + "removeAll": "Ŗęmővę äľľ şčőpęş" + }, + "root": { + "collapse": "Cőľľäpşę şčőpę ƒįľŧęřş", + "expand": "Ēχpäʼnđ şčőpę ƒįľŧęřş" + }, + "suggestedDashboards": { + "loading": "Ŀőäđįʼnģ đäşĥþőäřđş", + "search": "Fįľŧęř" + }, + "tree": { + "collapse": "Cőľľäpşę", + "expand": "Ēχpäʼnđ", + "search": "Fįľŧęř" + } + }, "search": { "actions": { "include-panels": "Ĩʼnčľūđę päʼnęľş",