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
This commit is contained in:
Andrej Ocenas
2025-03-14 10:48:12 +01:00
committed by GitHub
parent 9330ca5b54
commit a6c16c52e6
19 changed files with 490 additions and 391 deletions
@@ -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]);
}
@@ -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<string, Promise<Scope>>();
async fetchScope(name: string): Promise<Scope> {
if (this.scopesCache.has(name)) {
return this.scopesCache.get(name)!;
}
const response = new Promise<Scope>(async (resolve) => {
const basicScope = getEmptyScopeObject(name);
try {
const serverScope = await getBackendSrv().get<Scope>(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<SelectedScope[]> {
const scopes = await Promise.all(treeScopes.map(({ scopeName }) => this.fetchScope(scopeName)));
return scopes.map<SelectedScope>((scope, idx) => {
return {
scope,
path: treeScopes[idx].path,
};
});
}
async fetchNode(parent: string, query: string): Promise<NodesMap> {
try {
const nodes =
(await getBackendSrv().get<{ items: ScopeNode[] }>(apiUrl + `/find/scope_node_children`, { parent, query }))
?.items ?? [];
return nodes.reduce<NodesMap>((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<ScopeDashboardBinding[]> => {
try {
const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(
apiUrl + `/find/scope_dashboard_bindings`,
{
scope: scopeNames,
}
);
return response?.items ?? [];
} catch (err) {
return [];
}
};
}
@@ -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<Services | undefined>(undefined);
export function useScopesServices() {
return useContext(ScopesServicesContext);
}
interface ScopesContextProviderProps {
children: ReactNode;
services?: {
scopesService: ScopesService;
scopesSelectorService: ScopesSelectorService;
scopesDashboardsService: ScopesDashboardsService;
};
}
export const ScopesContextProvider = ({ children }: ScopesContextProviderProps) => {
return <ScopesContext.Provider value={ScopesService.instance}>{children}</ScopesContext.Provider>;
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 (
<ScopesContext.Provider value={config.featureToggles.scopeFilters ? memoizedServices.scopesService : undefined}>
<ScopesServicesContext.Provider value={config.featureToggles.scopeFilters ? memoizedServices : undefined}>
{children}
</ScopesServicesContext.Provider>
</ScopesContext.Provider>
);
};
+101 -34
View File
@@ -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<ScopesContextValueState> 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<State>;
// This will contain the combined state that will be public.
private readonly _stateObservable: BehaviorSubject<ScopesContextValueState>;
constructor(
private selectorService: ScopesSelectorService,
private dashboardsService: ScopesDashboardsService
) {
this._state = new BehaviorSubject<State>({
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<State>) => {
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<ScopesContextValueState> {
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<ScopesContextValueState> 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)
);
}
}
@@ -1,13 +1,7 @@
import { BehaviorSubject, Observable, pairwise, Subscription } from 'rxjs';
import { getAPINamespace } from '../../api/utils';
export abstract class ScopesServiceBase<T> {
private _state: BehaviorSubject<T>;
protected _fetchSub: Subscription | undefined;
protected _apiGroup = 'scope.grafana.app';
protected _apiVersion = 'v0alpha1';
protected _apiNamespace = getAPINamespace();
protected constructor(initialState: T) {
this._state = new BehaviorSubject<T>(Object.freeze(initialState));
@@ -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;
@@ -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<ScopesDashboardsServiceState> {
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<ScopesDashboardsS
});
}
public static get instance(): ScopesDashboardsService | undefined {
if (!ScopesDashboardsService.#instance && config.featureToggles.scopeFilters) {
ScopesDashboardsService.#instance = new ScopesDashboardsService();
}
return ScopesDashboardsService.#instance;
}
public updateFolder = (path: string[], expanded: boolean) => {
let folders = { ...this.state.folders };
let filteredFolders = { ...this.state.filteredFolders };
@@ -81,8 +72,6 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
return;
}
this._fetchSub?.unsubscribe();
if (forScopeNames.length === 0) {
this.updateState({
dashboards: [],
@@ -90,31 +79,21 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
folders: {},
forScopeNames: [],
loading: false,
drawerOpened: false,
});
ScopesService.instance?.setDrawerOpened(false);
return;
}
this.updateState({ forScopeNames, loading: true });
this._fetchSub = from(this.fetchDashboardsApi(forScopeNames))
.pipe(
finalize(() => {
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<ScopesDashboardsS
}, {});
};
public fetchDashboardsApi = async (scopeNames: string[]): Promise<ScopeDashboardBinding[]> => {
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 });
}
@@ -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}
/>
<ScopesInput
@@ -55,7 +59,11 @@ export const ScopesSelector = () => {
scopes={selectedScopes}
disabled={readOnly}
loading={loading}
onInputClick={open}
onInputClick={() => {
if (!scopesService.state.readOnly) {
open();
}
}}
onRemoveAllClick={removeAllScopes}
/>
@@ -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<ScopesSelectorServiceState> {
static #instance: ScopesSelectorService | undefined = undefined;
private _scopesCache = new Map<string, Promise<Scope>>();
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<ScopesSelectorServi
});
}
public static get instance(): ScopesSelectorService | undefined {
if (!ScopesSelectorService.#instance && config.featureToggles.scopeFilters) {
ScopesSelectorService.#instance = new ScopesSelectorService();
}
return ScopesSelectorService.#instance;
}
public updateNode = async (path: string[], expanded: boolean, query: string) => {
this._fetchSub?.unsubscribe();
let nodes = { ...this.state.nodes };
let currentLevel: NodesMap = nodes;
@@ -74,38 +64,32 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
if (expanded || differentQuery) {
this.updateState({ nodes, loadingNodeName });
this._fetchSub = from(this.fetchNodeApi(loadingNodeName, query))
.pipe(
finalize(() => {
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<NodesMap>((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<NodesMap>((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<ScopesSelectorServi
const selectedIdx = treeScopes.findIndex(({ scopeName }) => 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<ScopesSelectorServi
}
};
public changeScopes = (scopeNames: string[]) =>
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<ScopesSelectorServi
this.setNewScopes();
};
public toggleDrawer = () => ScopesService.instance?.setDrawerOpened(!ScopesService.instance?.state.drawerOpened);
private closeNodes = (nodes: NodesMap): NodesMap => {
return Object.entries(nodes).reduce<NodesMap>((acc, [id, node]) => {
acc[id] = {
@@ -216,36 +192,6 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
}, {});
};
private expandNodes = (nodes: NodesMap, path: string[]): NodesMap => {
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<ScopesSelectorServi
return [newSelectedScopes, newTreeScopes];
};
}
public fetchNodeApi = async (parent: string, query: string): Promise<NodesMap> => {
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<NodesMap>((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<Scope> => {
if (this._scopesCache.has(name)) {
return this._scopesCache.get(name)!;
}
currentNodes[nodeId] = {
...currentNodes[nodeId],
expanded: true,
};
currentNodes = currentNodes[nodeId].nodes;
}
const response = new Promise<Scope>(async (resolve) => {
const basicScope = this.getBasicScope(name);
try {
const serverScope = await getBackendSrv().get<Scope>(
`/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<SelectedScope[]> => {
const scopes = await Promise.all(treeScopes.map(({ scopeName }) => this.fetchScopeApi(scopeName)));
return scopes.reduce<SelectedScope[]>((acc, scope, idx) => {
acc.push({
scope,
path: treeScopes[idx].path,
});
return acc;
}, []);
};
public reset = () => {
ScopesSelectorService.#instance = undefined;
};
return nodes;
}
@@ -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();
@@ -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: '',
@@ -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();
});
});
+22 -17
View File
@@ -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, []);
});
});
@@ -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);
@@ -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);
@@ -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(
<KBarProvider>
<ScopesContextProvider>
<ScopesContextProvider services={services}>
<AppChrome>
<scene.Component model={scene} />
</AppChrome>
@@ -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();
}
@@ -87,9 +87,10 @@ export const getResultCloudDevRadio = () =>
export const getResultCloudOpsRadio = () =>
screen.getByTestId<HTMLInputElement>(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);
@@ -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 () => {
+14
View File
@@ -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: '',
},
};
}