From a6c16c52e6d092fe9ae1a4aa7f7efcaca309693b Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Fri, 14 Mar 2025 10:48:12 +0100 Subject: [PATCH] Scopes: Refactor services and the api client (#102042) * Externalize api and remove dependency cycles * fix tests * Update comment * Split the state observable creation in ScopesService * Make the feature flag guard more explicit * Change reduce to map --- .../src/services/ScopesContext.ts | 30 +- public/app/features/scopes/ScopesApiClient.ts | 103 +++++++ .../features/scopes/ScopesContextProvider.tsx | 53 +++- public/app/features/scopes/ScopesService.ts | 135 ++++++--- .../app/features/scopes/ScopesServiceBase.ts | 6 - .../scopes/dashboards/ScopesDashboards.tsx | 20 +- .../dashboards/ScopesDashboardsService.ts | 64 +---- .../scopes/selector/ScopesSelector.tsx | 28 +- .../scopes/selector/ScopesSelectorService.ts | 261 +++++------------- .../scopes/tests/dashboardReload.test.ts | 7 +- .../scopes/tests/dashboardsList.test.ts | 53 ++-- .../features/scopes/tests/selector.test.ts | 16 +- public/app/features/scopes/tests/tree.test.ts | 39 +-- .../features/scopes/tests/utils/actions.ts | 3 +- .../features/scopes/tests/utils/assertions.ts | 10 +- .../features/scopes/tests/utils/render.tsx | 17 +- .../features/scopes/tests/utils/selectors.ts | 13 +- .../features/scopes/tests/viewMode.test.ts | 9 +- public/app/features/scopes/utils.ts | 14 + 19 files changed, 490 insertions(+), 391 deletions(-) create mode 100644 public/app/features/scopes/ScopesApiClient.ts create mode 100644 public/app/features/scopes/utils.ts diff --git a/packages/grafana-runtime/src/services/ScopesContext.ts b/packages/grafana-runtime/src/services/ScopesContext.ts index 0c7faade62c..5c6a18226e4 100644 --- a/packages/grafana-runtime/src/services/ScopesContext.ts +++ b/packages/grafana-runtime/src/services/ScopesContext.ts @@ -1,14 +1,19 @@ -import { createContext, useContext } from 'react'; +import { createContext, useContext, useMemo } from 'react'; import { useObservable } from 'react-use'; import { Observable } from 'rxjs'; import { Scope } from '@grafana/data'; export interface ScopesContextValueState { + // Whether the drawer with the related dashboards is open drawerOpened: boolean; enabled: boolean; + + // loading state of the scopes loading: boolean; readOnly: boolean; + + // Currently selected scopes value: Scope[]; } @@ -49,13 +54,18 @@ export function useScopes(): ScopesContextValue | undefined { useObservable(context?.stateObservable ?? new Observable(), context?.state); - return context - ? { - state: context.state, - stateObservable: context.stateObservable, - changeScopes: context.changeScopes, - setReadOnly: context.setReadOnly, - setEnabled: context.setEnabled, - } - : undefined; + return useMemo(() => { + return context + ? { + state: context.state, + stateObservable: context.stateObservable, + changeScopes: context.changeScopes, + setReadOnly: context.setReadOnly, + setEnabled: context.setEnabled, + } + : undefined; + // Not sure why it thinks the context?.state is not required, but we want to recreate this when the state changes. + // context.stateObservable is readOnly so that is not needed, others are methods which should not change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [context, context?.state]); } diff --git a/public/app/features/scopes/ScopesApiClient.ts b/public/app/features/scopes/ScopesApiClient.ts new file mode 100644 index 00000000000..443a7cad8c6 --- /dev/null +++ b/public/app/features/scopes/ScopesApiClient.ts @@ -0,0 +1,103 @@ +import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; + +import { getAPINamespace } from '../../api/utils'; + +import { NodeReason, NodesMap, SelectedScope, TreeScope } from './selector/types'; +import { getEmptyScopeObject } from './utils'; + +const apiGroup = 'scope.grafana.app'; +const apiVersion = 'v0alpha1'; +const apiNamespace = getAPINamespace(); +const apiUrl = `/apis/${apiGroup}/${apiVersion}/namespaces/${apiNamespace}`; + +export class ScopesApiClient { + private scopesCache = new Map>(); + + async fetchScope(name: string): Promise { + if (this.scopesCache.has(name)) { + return this.scopesCache.get(name)!; + } + + const response = new Promise(async (resolve) => { + const basicScope = getEmptyScopeObject(name); + + try { + const serverScope = await getBackendSrv().get(apiUrl + `/scopes/${name}`); + + const scope = { + ...basicScope, + ...serverScope, + metadata: { + ...basicScope.metadata, + ...serverScope.metadata, + }, + spec: { + ...basicScope.spec, + ...serverScope.spec, + }, + }; + + resolve(scope); + } catch (err) { + this.scopesCache.delete(name); + + resolve(basicScope); + } + }); + + this.scopesCache.set(name, response); + + return response; + } + + async fetchMultipleScopes(treeScopes: TreeScope[]): Promise { + const scopes = await Promise.all(treeScopes.map(({ scopeName }) => this.fetchScope(scopeName))); + + return scopes.map((scope, idx) => { + return { + scope, + path: treeScopes[idx].path, + }; + }); + } + + async fetchNode(parent: string, query: string): Promise { + try { + const nodes = + (await getBackendSrv().get<{ items: ScopeNode[] }>(apiUrl + `/find/scope_node_children`, { parent, query })) + ?.items ?? []; + + return nodes.reduce((acc, { metadata: { name }, spec }) => { + acc[name] = { + name, + ...spec, + expandable: spec.nodeType === 'container', + selectable: spec.linkType === 'scope', + expanded: false, + query: '', + reason: NodeReason.Result, + nodes: {}, + }; + return acc; + }, {}); + } catch (err) { + return {}; + } + } + + public fetchDashboards = async (scopeNames: string[]): Promise => { + try { + const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>( + apiUrl + `/find/scope_dashboard_bindings`, + { + scope: scopeNames, + } + ); + + return response?.items ?? []; + } catch (err) { + return []; + } + }; +} diff --git a/public/app/features/scopes/ScopesContextProvider.tsx b/public/app/features/scopes/ScopesContextProvider.tsx index 06fd129e08e..8e47b0e4b3a 100644 --- a/public/app/features/scopes/ScopesContextProvider.tsx +++ b/public/app/features/scopes/ScopesContextProvider.tsx @@ -1,13 +1,58 @@ -import { ReactNode } from 'react'; +import { createContext, ReactNode, useMemo, useContext } from 'react'; -import { ScopesContext } from '@grafana/runtime'; +import { config, ScopesContext } from '@grafana/runtime'; +import { ScopesApiClient } from './ScopesApiClient'; import { ScopesService } from './ScopesService'; +import { ScopesDashboardsService } from './dashboards/ScopesDashboardsService'; +import { ScopesSelectorService } from './selector/ScopesSelectorService'; + +type Services = { + scopesService: ScopesService; + scopesSelectorService: ScopesSelectorService; + scopesDashboardsService: ScopesDashboardsService; +}; + +/** + * We use this separate context to provide a private service to internal code, compared to the restricted public API + * provided by the `ScopesContext`. + */ +export const ScopesServicesContext = createContext(undefined); +export function useScopesServices() { + return useContext(ScopesServicesContext); +} interface ScopesContextProviderProps { children: ReactNode; + services?: { + scopesService: ScopesService; + scopesSelectorService: ScopesSelectorService; + scopesDashboardsService: ScopesDashboardsService; + }; } -export const ScopesContextProvider = ({ children }: ScopesContextProviderProps) => { - return {children}; +export function defaultScopesServices() { + const client = new ScopesApiClient(); + const dashboardService = new ScopesDashboardsService(client); + const selectorService = new ScopesSelectorService(client, dashboardService); + return { + scopesService: new ScopesService(selectorService, dashboardService), + scopesSelectorService: selectorService, + scopesDashboardsService: dashboardService, + client, + }; +} + +export const ScopesContextProvider = ({ children, services }: ScopesContextProviderProps) => { + const memoizedServices = useMemo(() => { + return services ?? defaultScopesServices(); + }, [services]); + + return ( + + + {children} + + + ); }; diff --git a/public/app/features/scopes/ScopesService.ts b/public/app/features/scopes/ScopesService.ts index 83563b18f87..2c836470194 100644 --- a/public/app/features/scopes/ScopesService.ts +++ b/public/app/features/scopes/ScopesService.ts @@ -1,39 +1,96 @@ -import { Scope } from '@grafana/data'; -import { config, ScopesContextValue, ScopesContextValueState } from '@grafana/runtime'; +import { isEqual } from 'lodash'; +import { BehaviorSubject, Observable, combineLatest } from 'rxjs'; +import { map, distinctUntilChanged } from 'rxjs/operators'; -import { ScopesServiceBase } from './ScopesServiceBase'; +import { ScopesContextValue, ScopesContextValueState } from '@grafana/runtime'; + +import { ScopesDashboardsService } from './dashboards/ScopesDashboardsService'; import { ScopesSelectorService } from './selector/ScopesSelectorService'; -export class ScopesService extends ScopesServiceBase implements ScopesContextValue { - static #instance: ScopesService | undefined = undefined; +export interface State { + enabled: boolean; + readOnly: boolean; +} - private constructor() { - super({ - drawerOpened: false, +/** + * The ScopesService is mainly an aggregation of the ScopesSelectorService and ScopesDashboardsService which handle + * the scope selection mechanics and then loading and showing related dashboards. We aggregate the state of these + * here in single service to serve as a public facade we can later publish through the grafana/runtime to plugins. + */ +export class ScopesService implements ScopesContextValue { + // Only internal part of the state. + private readonly _state: BehaviorSubject; + + // This will contain the combined state that will be public. + private readonly _stateObservable: BehaviorSubject; + + constructor( + private selectorService: ScopesSelectorService, + private dashboardsService: ScopesDashboardsService + ) { + this._state = new BehaviorSubject({ enabled: false, - loading: false, readOnly: false, - value: [], }); + + this._stateObservable = new BehaviorSubject({ + ...this._state.getValue(), + value: this.selectorService.state.selectedScopes.map(({ scope }) => scope), + loading: this.selectorService.state.loading, + drawerOpened: this.dashboardsService.state.drawerOpened, + }); + + // We combine the latest emissions from this state + selectorService + dashboardsService. + combineLatest([ + this._state.asObservable(), + this.getSelectorServiceStateObservable(), + this.getDashboardsServiceStateObservable(), + ]) + .pipe( + // Map the 3 states into single ScopesContextValueState object + map( + ([thisState, selectorState, dashboardsState]): ScopesContextValueState => ({ + ...thisState, + value: selectorState.selectedScopes, + loading: selectorState.loading, + drawerOpened: dashboardsState.drawerOpened, + }) + ) + ) + // We pass this into behaviourSubject so we get the 1 event buffer and we can access latest value. + .subscribe(this._stateObservable); } - public static get instance(): ScopesService | undefined { - if (!ScopesService.#instance && config.featureToggles.scopeFilters) { - ScopesService.#instance = new ScopesService(); - } + /** + * This updates only the internal state of this service. + * @param newState + */ + private updateState = (newState: Partial) => { + this._state.next({ ...this._state.getValue(), ...newState }); + }; - return ScopesService.#instance; + /** + * The state of this service is a combination of the downstream services state plus the state of this service. + */ + public get state(): ScopesContextValueState { + // As a side effect this also gives us memoizeOne on this so it should be safe to use in react without unnecessary + // rerenders. + return this._stateObservable.value; } - public changeScopes = (scopeNames: string[]) => ScopesSelectorService.instance?.changeScopes(scopeNames); + public get stateObservable(): Observable { + return this._stateObservable; + } + + public changeScopes = (scopeNames: string[]) => this.selectorService.changeScopes(scopeNames); public setReadOnly = (readOnly: boolean) => { if (this.state.readOnly !== readOnly) { this.updateState({ readOnly }); } - if (readOnly && ScopesSelectorService.instance?.state.opened) { - ScopesSelectorService.instance?.closeAndReset(); + if (readOnly && this.selectorService.state.opened) { + this.selectorService.closeAndReset(); } }; @@ -43,21 +100,31 @@ export class ScopesService extends ScopesServiceBase im } }; - public setScopes = (scopes: Scope[]) => this.updateState({ value: scopes }); + /** + * Returns observable that emits when relevant parts of the selectorService state change. + * @private + */ + private getSelectorServiceStateObservable() { + return this.selectorService.stateObservable.pipe( + map((state) => ({ + // We only need these 2 properties from the selectorService state. + // We do mapping here but mainly to make the distinctUntilChanged simpler + selectedScopes: state.selectedScopes.map(({ scope }) => scope), + loading: state.loading, + })), + distinctUntilChanged( + (prev, curr) => prev.loading === curr.loading && isEqual(prev.selectedScopes, curr.selectedScopes) + ) + ); + } - public setLoading = (loading: boolean) => { - if (this.state.loading !== loading) { - this.updateState({ loading }); - } - }; - - public setDrawerOpened = (drawerOpened: boolean) => { - if (this.state.drawerOpened !== drawerOpened) { - this.updateState({ drawerOpened }); - } - }; - - public reset = () => { - ScopesService.#instance = undefined; - }; + /** + * Returns observable that emits when relevant parts of the dashboardService state change. + * @private + */ + private getDashboardsServiceStateObservable() { + return this.dashboardsService.stateObservable.pipe( + distinctUntilChanged((prev, curr) => prev.drawerOpened === curr.drawerOpened) + ); + } } diff --git a/public/app/features/scopes/ScopesServiceBase.ts b/public/app/features/scopes/ScopesServiceBase.ts index 4c0f637c92e..1f2603753d9 100644 --- a/public/app/features/scopes/ScopesServiceBase.ts +++ b/public/app/features/scopes/ScopesServiceBase.ts @@ -1,13 +1,7 @@ import { BehaviorSubject, Observable, pairwise, Subscription } from 'rxjs'; -import { getAPINamespace } from '../../api/utils'; - export abstract class ScopesServiceBase { private _state: BehaviorSubject; - protected _fetchSub: Subscription | undefined; - protected _apiGroup = 'scope.grafana.app'; - protected _apiVersion = 'v0alpha1'; - protected _apiNamespace = getAPINamespace(); protected constructor(initialState: T) { this._state = new BehaviorSubject(Object.freeze(initialState)); diff --git a/public/app/features/scopes/dashboards/ScopesDashboards.tsx b/public/app/features/scopes/dashboards/ScopesDashboards.tsx index d27eba0e10e..5d931e980c6 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboards.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboards.tsx @@ -7,28 +7,26 @@ import { useScopes } from '@grafana/runtime'; import { Button, LoadingPlaceholder, ScrollContainer, useStyles2 } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; -import { ScopesDashboardsService } from './ScopesDashboardsService'; +import { useScopesServices } from '../ScopesContextProvider'; + import { ScopesDashboardsTree } from './ScopesDashboardsTree'; import { ScopesDashboardsTreeSearch } from './ScopesDashboardsTreeSearch'; export function ScopesDashboards() { const styles = useStyles2(getStyles); const scopes = useScopes(); + const scopeServices = useScopesServices(); - const scopesDashboardsService = ScopesDashboardsService.instance; + useObservable( + scopeServices?.scopesDashboardsService.stateObservable ?? new Observable(), + scopeServices?.scopesDashboardsService.state + ); - useObservable(scopesDashboardsService?.stateObservable ?? new Observable(), scopesDashboardsService?.state); - - if ( - !scopes || - !scopesDashboardsService || - !scopes.state.enabled || - !scopes.state.drawerOpened || - scopes.state.readOnly - ) { + if (!scopeServices || !scopes || !scopes.state.enabled || !scopes.state.drawerOpened || scopes.state.readOnly) { return null; } + const { scopesDashboardsService } = scopeServices; const { loading, forScopeNames, dashboards, searchQuery, filteredFolders } = scopesDashboardsService.state; const { changeSearchQuery, updateFolder, clearSearchQuery } = scopesDashboardsService; diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts index e2817635473..5b6b261bdbe 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts @@ -1,15 +1,15 @@ import { isEqual } from 'lodash'; -import { finalize, from } from 'rxjs'; import { ScopeDashboardBinding } from '@grafana/data'; -import { config, getBackendSrv } from '@grafana/runtime'; -import { ScopesService } from '../ScopesService'; +import { ScopesApiClient } from '../ScopesApiClient'; import { ScopesServiceBase } from '../ScopesServiceBase'; import { SuggestedDashboardsFoldersMap } from './types'; interface ScopesDashboardsServiceState { + // State of the drawer showing related dashboards + drawerOpened: boolean; // by keeping a track of the raw response, it's much easier to check if we got any dashboards for the currently selected scopes dashboards: ScopeDashboardBinding[]; // a filtered version of the `folders` property. this prevents a lot of unnecessary parsings in React renders @@ -22,10 +22,9 @@ interface ScopesDashboardsServiceState { } export class ScopesDashboardsService extends ScopesServiceBase { - static #instance: ScopesDashboardsService | undefined = undefined; - - private constructor() { + constructor(private apiClient: ScopesApiClient) { super({ + drawerOpened: false, dashboards: [], filteredFolders: {}, folders: {}, @@ -35,14 +34,6 @@ export class ScopesDashboardsService extends ScopesServiceBase { let folders = { ...this.state.folders }; let filteredFolders = { ...this.state.filteredFolders }; @@ -81,8 +72,6 @@ export class ScopesDashboardsService extends ScopesServiceBase { - this.updateState({ loading: false }); - }) - ) - .subscribe((dashboards) => { - const folders = this.groupDashboards(dashboards); - const filteredFolders = this.filterFolders(folders, this.state.searchQuery); + const dashboards = await this.apiClient.fetchDashboards(forScopeNames); + if (isEqual(this.state.forScopeNames, forScopeNames)) { + const folders = this.groupDashboards(dashboards); + const filteredFolders = this.filterFolders(folders, this.state.searchQuery); - this.updateState({ dashboards, filteredFolders, folders, loading: false }); - - ScopesService.instance?.setDrawerOpened(dashboards.length > 0); - - this._fetchSub?.unsubscribe(); - }); + this.updateState({ dashboards, filteredFolders, folders, loading: false, drawerOpened: dashboards.length > 0 }); + } }; public groupDashboards = (dashboards: ScopeDashboardBinding[]): SuggestedDashboardsFoldersMap => { @@ -196,22 +175,5 @@ export class ScopesDashboardsService extends ScopesServiceBase => { - try { - const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>( - `/apis/${this._apiGroup}/${this._apiVersion}/namespaces/${this._apiNamespace}/find/scope_dashboard_bindings`, - { - scope: scopeNames, - } - ); - - return response?.items ?? []; - } catch (err) { - return []; - } - }; - - public reset = () => { - ScopesDashboardsService.#instance = undefined; - }; + public toggleDrawer = () => this.updateState({ drawerOpened: !this.state.drawerOpened }); } diff --git a/public/app/features/scopes/selector/ScopesSelector.tsx b/public/app/features/scopes/selector/ScopesSelector.tsx index 002a9ed73fd..3196b611501 100644 --- a/public/app/features/scopes/selector/ScopesSelector.tsx +++ b/public/app/features/scopes/selector/ScopesSelector.tsx @@ -8,8 +8,10 @@ import { Button, Drawer, IconButton, Spinner, useStyles2 } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { t, Trans } from 'app/core/internationalization'; +import { useScopesServices } from '../ScopesContextProvider'; + import { ScopesInput } from './ScopesInput'; -import { ScopesSelectorService } from './ScopesSelectorService'; +import { ScopesSelectorServiceState } from './ScopesSelectorService'; import { ScopesTree } from './ScopesTree'; export const ScopesSelector = () => { @@ -19,18 +21,20 @@ export const ScopesSelector = () => { const styles = useStyles2(getStyles, menuDockedAndOpen); const scopes = useScopes(); - const scopesSelectorService = ScopesSelectorService.instance; + const services = useScopesServices(); - useObservable(scopesSelectorService?.stateObservable ?? new Observable(), scopesSelectorService?.state); + const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable( + services?.scopesSelectorService.stateObservable ?? new Observable(), + services?.scopesSelectorService.state + ); - if (!scopes || !scopesSelectorService || !scopes.state.enabled) { + if (!services || !scopes || !scopes.state.enabled || !selectorServiceState) { return null; } - + const { nodes, loadingNodeName, selectedScopes, opened, treeScopes } = selectorServiceState; + const { scopesService, scopesSelectorService, scopesDashboardsService } = services; const { readOnly, drawerOpened, loading } = scopes.state; - const { nodes, selectedScopes, opened, loadingNodeName, treeScopes } = scopesSelectorService.state; - const { toggleDrawer, open, removeAllScopes, closeAndApply, closeAndReset, updateNode, toggleNodeSelect } = - scopesSelectorService; + const { open, removeAllScopes, closeAndApply, closeAndReset, updateNode, toggleNodeSelect } = scopesSelectorService; const dashboardsIconLabel = readOnly ? t('scopes.dashboards.toggle.disabled', 'Suggested dashboards list is disabled due to read only mode') @@ -47,7 +51,7 @@ export const ScopesSelector = () => { tooltip={dashboardsIconLabel} data-testid="scopes-dashboards-expand" disabled={readOnly} - onClick={toggleDrawer} + onClick={scopesDashboardsService.toggleDrawer} /> { scopes={selectedScopes} disabled={readOnly} loading={loading} - onInputClick={open} + onInputClick={() => { + if (!scopesService.state.readOnly) { + open(); + } + }} onRemoveAllClick={removeAllScopes} /> diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts index 705145829d3..158be33d5f3 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -1,16 +1,16 @@ import { isEqual } from 'lodash'; -import { finalize, from } from 'rxjs'; -import { Scope, ScopeNode } from '@grafana/data'; -import { config, getBackendSrv } from '@grafana/runtime'; - -import { ScopesService } from '../ScopesService'; +import { ScopesApiClient } from '../ScopesApiClient'; import { ScopesServiceBase } from '../ScopesServiceBase'; import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService'; +import { getEmptyScopeObject } from '../utils'; import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types'; -interface ScopesSelectorServiceState { +export interface ScopesSelectorServiceState { + loading: boolean; + + // Whether the scopes selector drawer is opened opened: boolean; loadingNodeName: string | undefined; nodes: NodesMap; @@ -19,12 +19,12 @@ interface ScopesSelectorServiceState { } export class ScopesSelectorService extends ScopesServiceBase { - static #instance: ScopesSelectorService | undefined = undefined; - - private _scopesCache = new Map>(); - - private constructor() { + constructor( + private apiClient: ScopesApiClient, + private dashboardsService: ScopesDashboardsService + ) { super({ + loading: false, opened: false, loadingNodeName: undefined, nodes: { @@ -45,17 +45,7 @@ export class ScopesSelectorService extends ScopesServiceBase { - this._fetchSub?.unsubscribe(); - let nodes = { ...this.state.nodes }; let currentLevel: NodesMap = nodes; @@ -74,38 +64,32 @@ export class ScopesSelectorService extends ScopesServiceBase { - this.updateState({ loadingNodeName: undefined }); - }) - ) - .subscribe((childNodes) => { - const [selectedScopes, treeScopes] = this.getScopesAndTreeScopesWithPaths( - this.state.selectedScopes, - this.state.treeScopes, - path, - childNodes - ); + // fetchNodeApi does not throw just return empty object + const childNodes = await this.apiClient.fetchNode(loadingNodeName, query); + if (loadingNodeName === this.state.loadingNodeName) { + const [selectedScopes, treeScopes] = this.getScopesAndTreeScopesWithPaths( + this.state.selectedScopes, + this.state.treeScopes, + path, + childNodes + ); - const persistedNodes = treeScopes - .map(({ path }) => path[path.length - 1]) - .filter((nodeName) => nodeName in currentNode.nodes && !(nodeName in childNodes)) - .reduce((acc, nodeName) => { - acc[nodeName] = { - ...currentNode.nodes[nodeName], - reason: NodeReason.Persisted, - }; + const persistedNodes = treeScopes + .map(({ path }) => path[path.length - 1]) + .filter((nodeName) => nodeName in currentNode.nodes && !(nodeName in childNodes)) + .reduce((acc, nodeName) => { + acc[nodeName] = { + ...currentNode.nodes[nodeName], + reason: NodeReason.Persisted, + }; - return acc; - }, {}); + return acc; + }, {}); - currentNode.nodes = { ...persistedNodes, ...childNodes }; + currentNode.nodes = { ...persistedNodes, ...childNodes }; - this.updateState({ nodes, selectedScopes, treeScopes }); - - this._fetchSub?.unsubscribe(); - }); + this.updateState({ nodes, selectedScopes, treeScopes, loadingNodeName: undefined }); + } } else { this.updateState({ nodes, loadingNodeName: undefined }); } @@ -126,7 +110,7 @@ export class ScopesSelectorService extends ScopesServiceBase scopeName === linkId); if (selectedIdx === -1) { - this.fetchScopeApi(linkId!); + this.apiClient.fetchScope(linkId!); const selectedFromSameNode = treeScopes.length === 0 || @@ -147,50 +131,44 @@ export class ScopesSelectorService extends ScopesServiceBase - this.setNewScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] }))); + changeScopes = (scopeNames: string[]) => this.setNewScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] }))); - public setNewScopes = async (treeScopes = this.state.treeScopes) => { + private setNewScopes = async (treeScopes = this.state.treeScopes) => { if (isEqual(treeScopes, this.getTreeScopesFromSelectedScopes(this.state.selectedScopes))) { return; } let selectedScopes = treeScopes.map(({ scopeName, path }) => ({ - scope: this.getBasicScope(scopeName), + scope: getEmptyScopeObject(scopeName), path, })); - this.updateState({ selectedScopes, treeScopes }); - ScopesService.instance?.setLoading(true); - ScopesDashboardsService.instance?.fetchDashboards(selectedScopes.map(({ scope }) => scope.metadata.name)); + this.updateState({ selectedScopes, treeScopes, loading: true }); + this.dashboardsService.fetchDashboards(selectedScopes.map(({ scope }) => scope.metadata.name)); - selectedScopes = await this.fetchScopesApi(treeScopes); - this.updateState({ selectedScopes }); - ScopesService.instance?.setScopes(selectedScopes.map(({ scope }) => scope)); - ScopesService.instance?.setLoading(false); + selectedScopes = await this.apiClient.fetchMultipleScopes(treeScopes); + this.updateState({ selectedScopes, loading: false }); }; public removeAllScopes = () => this.setNewScopes([]); public open = async () => { - if (!ScopesService.instance?.state.readOnly) { - if (Object.keys(this.state.nodes[''].nodes).length === 0) { - await this.updateNode([''], true, ''); - } - - let nodes = { ...this.state.nodes }; - - // First close all nodes - nodes = this.closeNodes(nodes); - - // Extract the path of a scope - let path = [...(this.state.selectedScopes[0]?.path ?? ['', ''])]; - path.splice(path.length - 1, 1); - - // Expand the nodes to the selected scope - nodes = this.expandNodes(nodes, path); - - this.updateState({ nodes, opened: true }); + if (Object.keys(this.state.nodes[''].nodes).length === 0) { + await this.updateNode([''], true, ''); } + + let nodes = { ...this.state.nodes }; + + // First close all nodes + nodes = this.closeNodes(nodes); + + // Extract the path of a scope + let path = [...(this.state.selectedScopes[0]?.path ?? ['', ''])]; + path.splice(path.length - 1, 1); + + // Expand the nodes to the selected scope + nodes = expandNodes(nodes, path); + + this.updateState({ nodes, opened: true }); }; public closeAndReset = () => { @@ -202,8 +180,6 @@ export class ScopesSelectorService extends ScopesServiceBase ScopesService.instance?.setDrawerOpened(!ScopesService.instance?.state.drawerOpened); - private closeNodes = (nodes: NodesMap): NodesMap => { return Object.entries(nodes).reduce((acc, [id, node]) => { acc[id] = { @@ -216,36 +192,6 @@ export class ScopesSelectorService extends ScopesServiceBase { - nodes = { ...nodes }; - let currentNodes = nodes; - - for (let i = 0; i < path.length; i++) { - const nodeId = path[i]; - - currentNodes[nodeId] = { - ...currentNodes[nodeId], - expanded: true, - }; - currentNodes = currentNodes[nodeId].nodes; - } - - return nodes; - }; - - private getBasicScope = (name: string): Scope => { - return { - metadata: { name }, - spec: { - filters: [], - title: name, - type: '', - category: '', - description: '', - }, - }; - }; - private getTreeScopesFromSelectedScopes = (scopes: SelectedScope[]): TreeScope[] => { return scopes.map(({ scope, path }) => ({ scopeName: scope.metadata.name, @@ -305,88 +251,21 @@ export class ScopesSelectorService extends ScopesServiceBase => { - try { - const nodes = - ( - await getBackendSrv().get<{ items: ScopeNode[] }>( - `/apis/${this._apiGroup}/${this._apiVersion}/namespaces/${this._apiNamespace}/find/scope_node_children`, - { parent, query } - ) - )?.items ?? []; +function expandNodes(nodes: NodesMap, path: string[]): NodesMap { + nodes = { ...nodes }; + let currentNodes = nodes; - return nodes.reduce((acc, { metadata: { name }, spec }) => { - acc[name] = { - name, - ...spec, - expandable: spec.nodeType === 'container', - selectable: spec.linkType === 'scope', - expanded: false, - query: '', - reason: NodeReason.Result, - nodes: {}, - }; - return acc; - }, {}); - } catch (err) { - return {}; - } - }; + for (let i = 0; i < path.length; i++) { + const nodeId = path[i]; - public fetchScopeApi = async (name: string): Promise => { - if (this._scopesCache.has(name)) { - return this._scopesCache.get(name)!; - } + currentNodes[nodeId] = { + ...currentNodes[nodeId], + expanded: true, + }; + currentNodes = currentNodes[nodeId].nodes; + } - const response = new Promise(async (resolve) => { - const basicScope = this.getBasicScope(name); - - try { - const serverScope = await getBackendSrv().get( - `/apis/${this._apiGroup}/${this._apiVersion}/namespaces/${this._apiNamespace}/scopes/${name}` - ); - - const scope = { - ...basicScope, - ...serverScope, - metadata: { - ...basicScope.metadata, - ...serverScope.metadata, - }, - spec: { - ...basicScope.spec, - ...serverScope.spec, - }, - }; - - resolve(scope); - } catch (err) { - this._scopesCache.delete(name); - - resolve(basicScope); - } - }); - - this._scopesCache.set(name, response); - - return response; - }; - - public fetchScopesApi = async (treeScopes: TreeScope[]): Promise => { - const scopes = await Promise.all(treeScopes.map(({ scopeName }) => this.fetchScopeApi(scopeName))); - - return scopes.reduce((acc, scope, idx) => { - acc.push({ - scope, - path: treeScopes[idx].path, - }); - - return acc; - }, []); - }; - - public reset = () => { - ScopesSelectorService.#instance = undefined; - }; + return nodes; } diff --git a/public/app/features/scopes/tests/dashboardReload.test.ts b/public/app/features/scopes/tests/dashboardReload.test.ts index 7973842c33e..97b46ee3235 100644 --- a/public/app/features/scopes/tests/dashboardReload.test.ts +++ b/public/app/features/scopes/tests/dashboardReload.test.ts @@ -40,7 +40,10 @@ describe('Dashboard reload', () => { config.featureToggles.reloadDashboardsOnParamsChange = reloadDashboardsOnParamsChange; setDashboardAPI(undefined); - const dashboardScene = await renderDashboard({ uid: withUid ? 'dash-1' : undefined }, { reloadOnParamsChange }); + const { scene: dashboardScene, scopesService } = await renderDashboard( + { uid: withUid ? 'dash-1' : undefined }, + { reloadOnParamsChange } + ); dashboardReloadSpy = jest.spyOn(getDashboardScenePageStateManager(), 'reloadDashboard'); @@ -67,7 +70,7 @@ describe('Dashboard reload', () => { expect(dashboardReloadSpy).toHaveBeenCalled(); } - await updateScopes(['grafana']); + await updateScopes(scopesService, ['grafana']); await jest.advanceTimersToNextTimerAsync(); if (!shouldReload) { expect(dashboardReloadSpy).not.toHaveBeenCalled(); diff --git a/public/app/features/scopes/tests/dashboardsList.test.ts b/public/app/features/scopes/tests/dashboardsList.test.ts index 895337f69d2..8c89619b062 100644 --- a/public/app/features/scopes/tests/dashboardsList.test.ts +++ b/public/app/features/scopes/tests/dashboardsList.test.ts @@ -1,5 +1,6 @@ import { config } from '@grafana/runtime'; +import { ScopesService } from '../ScopesService'; import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService'; import { @@ -48,6 +49,8 @@ jest.mock('@grafana/runtime', () => ({ describe('Dashboards list', () => { let fetchDashboardsSpy: jest.SpyInstance; + let scopesService: ScopesService; + let scopesDashboardsService: ScopesDashboardsService; beforeAll(() => { config.featureToggles.scopeFilters = true; @@ -55,8 +58,10 @@ describe('Dashboards list', () => { }); beforeEach(async () => { - await renderDashboard(); - fetchDashboardsSpy = jest.spyOn(ScopesDashboardsService.instance!, 'fetchDashboardsApi'); + const result = await renderDashboard(); + scopesService = result.scopesService; + scopesDashboardsService = result.scopesDashboardsService; + fetchDashboardsSpy = jest.spyOn(result.client, 'fetchDashboards'); }); afterEach(async () => { @@ -65,35 +70,35 @@ describe('Dashboards list', () => { it('Opens container and fetches dashboards list when a scope is selected', async () => { expectDashboardsClosed(); - await updateScopes(['mimir']); + await updateScopes(scopesService, ['mimir']); expectDashboardsOpen(); expect(fetchDashboardsSpy).toHaveBeenCalled(); }); it('Closes container when no scopes are selected', async () => { - await updateScopes(['mimir']); + await updateScopes(scopesService, ['mimir']); expectDashboardsOpen(); - await updateScopes(['mimir', 'loki']); + await updateScopes(scopesService, ['mimir', 'loki']); expectDashboardsOpen(); - await updateScopes([]); + await updateScopes(scopesService, []); expectDashboardsClosed(); }); it('Fetches dashboards list when the list is expanded', async () => { await toggleDashboards(); - await updateScopes(['mimir']); + await updateScopes(scopesService, ['mimir']); expect(fetchDashboardsSpy).toHaveBeenCalled(); }); it('Fetches dashboards list when the list is expanded after scope selection', async () => { - await updateScopes(['mimir']); + await updateScopes(scopesService, ['mimir']); await toggleDashboards(); expect(fetchDashboardsSpy).toHaveBeenCalled(); }); it('Shows dashboards for multiple scopes', async () => { await toggleDashboards(); - await updateScopes(['grafana']); + await updateScopes(scopesService, ['grafana']); await expandDashboardFolder('General'); await expandDashboardFolder('Observability'); await expandDashboardFolder('Usage'); @@ -118,7 +123,7 @@ describe('Dashboards list', () => { expectDashboardNotInDocument('multiple2-compacter'); expectDashboardNotInDocument('another-stats'); - await updateScopes(['grafana', 'mimir']); + await updateScopes(scopesService, ['grafana', 'mimir']); await expandDashboardFolder('General'); await expandDashboardFolder('Observability'); await expandDashboardFolder('Usage'); @@ -143,7 +148,7 @@ describe('Dashboards list', () => { expectDashboardLength('multiple2-compacter', 2); expectDashboardInDocument('another-stats'); - await updateScopes(['grafana']); + await updateScopes(scopesService, ['grafana']); await expandDashboardFolder('General'); await expandDashboardFolder('Observability'); await expandDashboardFolder('Usage'); @@ -171,7 +176,7 @@ describe('Dashboards list', () => { it('Filters the dashboards list for dashboards', async () => { await toggleDashboards(); - await updateScopes(['grafana']); + await updateScopes(scopesService, ['grafana']); await expandDashboardFolder('General'); await expandDashboardFolder('Observability'); await expandDashboardFolder('Usage'); @@ -205,7 +210,7 @@ describe('Dashboards list', () => { it('Filters the dashboards list for folders', async () => { await toggleDashboards(); - await updateScopes(['grafana']); + await updateScopes(scopesService, ['grafana']); await expandDashboardFolder('General'); await expandDashboardFolder('Observability'); await expandDashboardFolder('Usage'); @@ -239,7 +244,7 @@ describe('Dashboards list', () => { it('Deduplicates the dashboards list', async () => { await toggleDashboards(); - await updateScopes(['dev', 'ops']); + await updateScopes(scopesService, ['dev', 'ops']); await expandDashboardFolder('Cardinality Management'); await expandDashboardFolder('Usage Insights'); expectDashboardLength('cardinality-management-labels', 1); @@ -260,14 +265,14 @@ describe('Dashboards list', () => { }); it('Does not show the input when there are no dashboards found for scope', async () => { - await updateScopes(['cloud']); + await updateScopes(scopesService, ['cloud']); await toggleDashboards(); expectNoDashboardsForScope(); expectNoDashboardsSearch(); }); it('Shows the input and a message when there are no dashboards found for filter', async () => { - await updateScopes(['mimir']); + await updateScopes(scopesService, ['mimir']); await searchDashboards('unknown'); expectDashboardsSearch(); expectNoDashboardsForFilter(); @@ -278,7 +283,7 @@ describe('Dashboards list', () => { describe('groupDashboards', () => { it('Assigns dashboards without groups to root folder', () => { - expect(ScopesDashboardsService.instance?.groupDashboards([dashboardWithoutFolder])).toEqual({ + expect(scopesDashboardsService.groupDashboards([dashboardWithoutFolder])).toEqual({ '': { title: '', expanded: true, @@ -295,7 +300,7 @@ describe('Dashboards list', () => { }); it('Assigns dashboards with root group to root folder', () => { - expect(ScopesDashboardsService.instance?.groupDashboards([dashboardWithRootFolder])).toEqual({ + expect(scopesDashboardsService.groupDashboards([dashboardWithRootFolder])).toEqual({ '': { title: '', expanded: true, @@ -312,9 +317,7 @@ describe('Dashboards list', () => { }); it('Merges folders from multiple dashboards', () => { - expect( - ScopesDashboardsService.instance?.groupDashboards([dashboardWithOneFolder, dashboardWithTwoFolders]) - ).toEqual({ + expect(scopesDashboardsService.groupDashboards([dashboardWithOneFolder, dashboardWithTwoFolders])).toEqual({ '': { title: '', expanded: true, @@ -356,7 +359,7 @@ describe('Dashboards list', () => { it('Merges scopes from multiple dashboards', () => { expect( - ScopesDashboardsService.instance?.groupDashboards([dashboardWithTwoFolders, alternativeDashboardWithTwoFolders]) + scopesDashboardsService.groupDashboards([dashboardWithTwoFolders, alternativeDashboardWithTwoFolders]) ).toEqual({ '': { title: '', @@ -394,7 +397,7 @@ describe('Dashboards list', () => { it('Matches snapshot', () => { expect( - ScopesDashboardsService.instance?.groupDashboards([ + scopesDashboardsService.groupDashboards([ dashboardWithoutFolder, dashboardWithOneFolder, dashboardWithTwoFolders, @@ -475,7 +478,7 @@ describe('Dashboards list', () => { describe('filterFolders', () => { it('Shows folders matching criteria', () => { expect( - ScopesDashboardsService.instance?.filterFolders( + scopesDashboardsService.filterFolders( { '': { title: '', @@ -554,7 +557,7 @@ describe('Dashboards list', () => { it('Shows dashboards matching criteria', () => { expect( - ScopesDashboardsService.instance?.filterFolders( + scopesDashboardsService.filterFolders( { '': { title: '', diff --git a/public/app/features/scopes/tests/selector.test.ts b/public/app/features/scopes/tests/selector.test.ts index 951c6f54b2e..251d19c0db7 100644 --- a/public/app/features/scopes/tests/selector.test.ts +++ b/public/app/features/scopes/tests/selector.test.ts @@ -1,7 +1,7 @@ import { config } from '@grafana/runtime'; import { getDashboardScenePageStateManager } from '../../dashboard-scene/pages/DashboardScenePageStateManager'; -import { ScopesSelectorService } from '../selector/ScopesSelectorService'; +import { ScopesService } from '../ScopesService'; import { applyScopes, cancelScopes, openSelector, selectResultCloud, updateScopes } from './utils/actions'; import { expectScopesSelectorValue } from './utils/assertions'; @@ -21,6 +21,7 @@ jest.mock('@grafana/runtime', () => ({ describe('Selector', () => { let fetchSelectedScopesSpy: jest.SpyInstance; let dashboardReloadSpy: jest.SpyInstance; + let scopesService: ScopesService; beforeAll(() => { config.featureToggles.scopeFilters = true; @@ -28,8 +29,9 @@ describe('Selector', () => { }); beforeEach(async () => { - await renderDashboard(); - fetchSelectedScopesSpy = jest.spyOn(ScopesSelectorService.instance!, 'fetchScopesApi'); + const result = await renderDashboard(); + scopesService = result.scopesService; + fetchSelectedScopesSpy = jest.spyOn(result.client, 'fetchMultipleScopes'); dashboardReloadSpy = jest.spyOn(getDashboardScenePageStateManager(), 'reloadDashboard'); }); @@ -42,7 +44,7 @@ describe('Selector', () => { await selectResultCloud(); await applyScopes(); expect(fetchSelectedScopesSpy).toHaveBeenCalled(); - expect(getListOfScopes()).toEqual(mocksScopes.filter(({ metadata: { name } }) => name === 'cloud')); + expect(getListOfScopes(scopesService)).toEqual(mocksScopes.filter(({ metadata: { name } }) => name === 'cloud')); }); it('Does not save the scopes on close', async () => { @@ -50,16 +52,16 @@ describe('Selector', () => { await selectResultCloud(); await cancelScopes(); expect(fetchSelectedScopesSpy).not.toHaveBeenCalled(); - expect(getListOfScopes()).toEqual([]); + expect(getListOfScopes(scopesService)).toEqual([]); }); it('Shows selected scopes', async () => { - await updateScopes(['grafana']); + await updateScopes(scopesService, ['grafana']); expectScopesSelectorValue('Grafana'); }); it('Does not reload the dashboard on scope change', async () => { - await updateScopes(['grafana']); + await updateScopes(scopesService, ['grafana']); expect(dashboardReloadSpy).not.toHaveBeenCalled(); }); }); diff --git a/public/app/features/scopes/tests/tree.test.ts b/public/app/features/scopes/tests/tree.test.ts index 186e6fe06f0..84f047a0768 100644 --- a/public/app/features/scopes/tests/tree.test.ts +++ b/public/app/features/scopes/tests/tree.test.ts @@ -1,5 +1,6 @@ import { config } from '@grafana/runtime'; +import { ScopesService } from '../ScopesService'; import { ScopesSelectorService } from '../selector/ScopesSelectorService'; import { @@ -56,6 +57,8 @@ jest.mock('@grafana/runtime', () => ({ describe('Tree', () => { let fetchNodesSpy: jest.SpyInstance; let fetchScopeSpy: jest.SpyInstance; + let scopesService: ScopesService; + let scopesSelectorService: ScopesSelectorService; beforeAll(() => { config.featureToggles.scopeFilters = true; @@ -63,9 +66,11 @@ describe('Tree', () => { }); beforeEach(async () => { - await renderDashboard(); - fetchNodesSpy = jest.spyOn(ScopesSelectorService.instance!, 'fetchNodeApi'); - fetchScopeSpy = jest.spyOn(ScopesSelectorService.instance!, 'fetchScopeApi'); + const result = await renderDashboard(); + scopesService = result.scopesService; + scopesSelectorService = result.scopesSelectorService; + fetchNodesSpy = jest.spyOn(result.client, 'fetchNode'); + fetchScopeSpy = jest.spyOn(result.client, 'fetchScope'); }); afterEach(async () => { @@ -80,7 +85,7 @@ describe('Tree', () => { }); it('Selects the proper scopes', async () => { - await updateScopes(['grafana', 'mimir']); + await updateScopes(scopesService, ['grafana', 'mimir']); await openSelector(); await expandResultApplications(); expectResultApplicationsGrafanaSelected(); @@ -259,22 +264,22 @@ describe('Tree', () => { const unselectedScopeName = 'mimir'; const selectedScopeNameFromOtherGroup = 'dev'; - await updateScopes([selectedScopeName, selectedScopeNameFromOtherGroup]); - expectSelectedScopePath(selectedScopeName, []); - expectTreeScopePath(selectedScopeName, []); - expectSelectedScopePath(unselectedScopeName, undefined); - expectTreeScopePath(unselectedScopeName, undefined); - expectSelectedScopePath(selectedScopeNameFromOtherGroup, []); - expectTreeScopePath(selectedScopeNameFromOtherGroup, []); + await updateScopes(scopesService, [selectedScopeName, selectedScopeNameFromOtherGroup]); + expectSelectedScopePath(scopesSelectorService, selectedScopeName, []); + expectTreeScopePath(scopesSelectorService, selectedScopeName, []); + expectSelectedScopePath(scopesSelectorService, unselectedScopeName, undefined); + expectTreeScopePath(scopesSelectorService, unselectedScopeName, undefined); + expectSelectedScopePath(scopesSelectorService, selectedScopeNameFromOtherGroup, []); + expectTreeScopePath(scopesSelectorService, selectedScopeNameFromOtherGroup, []); await openSelector(); await expandResultApplications(); const expectedPath = ['', 'applications', 'applications-grafana']; - expectSelectedScopePath(selectedScopeName, expectedPath); - expectTreeScopePath(selectedScopeName, expectedPath); - expectSelectedScopePath(unselectedScopeName, undefined); - expectTreeScopePath(unselectedScopeName, undefined); - expectSelectedScopePath(selectedScopeNameFromOtherGroup, []); - expectTreeScopePath(selectedScopeNameFromOtherGroup, []); + expectSelectedScopePath(scopesSelectorService, selectedScopeName, expectedPath); + expectTreeScopePath(scopesSelectorService, selectedScopeName, expectedPath); + expectSelectedScopePath(scopesSelectorService, unselectedScopeName, undefined); + expectTreeScopePath(scopesSelectorService, unselectedScopeName, undefined); + expectSelectedScopePath(scopesSelectorService, selectedScopeNameFromOtherGroup, []); + expectTreeScopePath(scopesSelectorService, selectedScopeNameFromOtherGroup, []); }); }); diff --git a/public/app/features/scopes/tests/utils/actions.ts b/public/app/features/scopes/tests/utils/actions.ts index 5972a430fc5..53e71f5a492 100644 --- a/public/app/features/scopes/tests/utils/actions.ts +++ b/public/app/features/scopes/tests/utils/actions.ts @@ -35,7 +35,8 @@ const type = async (selector: () => HTMLInputElement, value: string) => { await jest.runOnlyPendingTimersAsync(); }; -export const updateScopes = async (scopes: string[]) => act(async () => ScopesService.instance?.changeScopes(scopes)); +export const updateScopes = async (service: ScopesService, scopes: string[]) => + act(async () => service.changeScopes(scopes)); export const openSelector = async () => click(getSelectorInput); export const applyScopes = async () => { await click(getSelectorApply); diff --git a/public/app/features/scopes/tests/utils/assertions.ts b/public/app/features/scopes/tests/utils/assertions.ts index c842e81b3fa..4f03ce058f8 100644 --- a/public/app/features/scopes/tests/utils/assertions.ts +++ b/public/app/features/scopes/tests/utils/assertions.ts @@ -1,3 +1,5 @@ +import { ScopesSelectorService } from '../../selector/ScopesSelectorService'; + import { getDashboard, getDashboardsContainer, @@ -77,7 +79,7 @@ export const expectDashboardNotInDocument = (uid: string) => expectNotInDocument export const expectDashboardLength = (uid: string, length: number) => expect(queryAllDashboard(uid)).toHaveLength(length); -export const expectSelectedScopePath = (name: string, path: string[] | undefined) => - expect(getSelectedScope(name)?.path).toEqual(path); -export const expectTreeScopePath = (name: string, path: string[] | undefined) => - expect(getTreeScope(name)?.path).toEqual(path); +export const expectSelectedScopePath = (service: ScopesSelectorService, name: string, path: string[] | undefined) => + expect(getSelectedScope(service, name)?.path).toEqual(path); +export const expectTreeScopePath = (service: ScopesSelectorService, name: string, path: string[] | undefined) => + expect(getTreeScope(service, name)?.path).toEqual(path); diff --git a/public/app/features/scopes/tests/utils/render.tsx b/public/app/features/scopes/tests/utils/render.tsx index 66072bd43b2..3fa447d952b 100644 --- a/public/app/features/scopes/tests/utils/render.tsx +++ b/public/app/features/scopes/tests/utils/render.tsx @@ -10,10 +10,7 @@ import { AppChrome } from 'app/core/components/AppChrome/AppChrome'; import { transformSaveModelToScene } from 'app/features/dashboard-scene/serialization/transformSaveModelToScene'; import { DashboardDataDTO, DashboardDTO, DashboardMeta } from 'app/types'; -import { ScopesContextProvider } from '../../ScopesContextProvider'; -import { ScopesService } from '../../ScopesService'; -import { ScopesDashboardsService } from '../../dashboards/ScopesDashboardsService'; -import { ScopesSelectorService } from '../../selector/ScopesSelectorService'; +import { defaultScopesServices, ScopesContextProvider } from '../../ScopesContextProvider'; import { getMock } from './mocks'; @@ -188,9 +185,11 @@ export async function renderDashboard( const dto: DashboardDTO = getDashboardDTO(overrideDashboard, overrideMeta); const scene = transformSaveModelToScene(dto); + const services = defaultScopesServices(); + render( - + @@ -200,7 +199,10 @@ export async function renderDashboard( await waitFor(() => expect(sceneGraph.getScopesBridge(scene)).toBeDefined()); - return scene; + return { + scene, + ...services, + }; } export async function resetScenes(spies: jest.SpyInstance[] = []) { @@ -208,8 +210,5 @@ export async function resetScenes(spies: jest.SpyInstance[] = []) { jest.useRealTimers(); getMock.mockClear(); spies.forEach((spy) => spy.mockClear()); - ScopesService.instance?.reset(); - ScopesSelectorService.instance?.reset(); - ScopesDashboardsService.instance?.reset(); cleanup(); } diff --git a/public/app/features/scopes/tests/utils/selectors.ts b/public/app/features/scopes/tests/utils/selectors.ts index e5ef25e26bb..a08a6928b1b 100644 --- a/public/app/features/scopes/tests/utils/selectors.ts +++ b/public/app/features/scopes/tests/utils/selectors.ts @@ -87,9 +87,10 @@ export const getResultCloudDevRadio = () => export const getResultCloudOpsRadio = () => screen.getByTestId(selectors.tree.radio('cloud-ops', 'result')); -export const getListOfScopes = () => ScopesService.instance?.state.value; -export const getListOfSelectedScopes = () => ScopesSelectorService.instance?.state.selectedScopes; -export const getListOfTreeScopes = () => ScopesSelectorService.instance?.state.treeScopes; -export const getSelectedScope = (name: string) => - getListOfSelectedScopes()?.find((selectedScope) => selectedScope.scope.metadata.name === name); -export const getTreeScope = (name: string) => getListOfTreeScopes()?.find((treeScope) => treeScope.scopeName === name); +export const getListOfScopes = (service: ScopesService) => service.state.value; +export const getListOfSelectedScopes = (service: ScopesSelectorService) => service.state.selectedScopes; +export const getListOfTreeScopes = (service: ScopesSelectorService) => service.state.treeScopes; +export const getSelectedScope = (service: ScopesSelectorService, name: string) => + getListOfSelectedScopes(service)?.find((selectedScope) => selectedScope.scope.metadata.name === name); +export const getTreeScope = (service: ScopesSelectorService, name: string) => + getListOfTreeScopes(service)?.find((treeScope) => treeScope.scopeName === name); diff --git a/public/app/features/scopes/tests/viewMode.test.ts b/public/app/features/scopes/tests/viewMode.test.ts index ba115534f98..90ba082b734 100644 --- a/public/app/features/scopes/tests/viewMode.test.ts +++ b/public/app/features/scopes/tests/viewMode.test.ts @@ -24,6 +24,7 @@ jest.mock('@grafana/runtime', () => ({ describe('View mode', () => { let dashboardScene: DashboardScene; + let scopesService: ScopesService; beforeAll(() => { config.featureToggles.scopeFilters = true; @@ -31,7 +32,9 @@ describe('View mode', () => { }); beforeEach(async () => { - dashboardScene = await renderDashboard(); + const renderResult = await renderDashboard(); + dashboardScene = renderResult.scene; + scopesService = renderResult.scopesService; }); afterEach(async () => { @@ -40,8 +43,8 @@ describe('View mode', () => { it('Enters view mode', async () => { await enterEditMode(dashboardScene); - expect(ScopesService.instance?.state.readOnly).toEqual(true); - expect(ScopesService.instance?.state.drawerOpened).toEqual(false); + expect(scopesService.state.readOnly).toEqual(true); + expect(scopesService.state.drawerOpened).toEqual(false); }); it('Closes selector on enter', async () => { diff --git a/public/app/features/scopes/utils.ts b/public/app/features/scopes/utils.ts new file mode 100644 index 00000000000..d80a19d239c --- /dev/null +++ b/public/app/features/scopes/utils.ts @@ -0,0 +1,14 @@ +import { Scope } from '@grafana/data'; + +export function getEmptyScopeObject(name: string): Scope { + return { + metadata: { name }, + spec: { + filters: [], + title: name, + type: '', + category: '', + description: '', + }, + }; +}