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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user