mirror of https://github.com/grafana/grafana
Scopes: Refactor scopes to plain React and move it to runtime (#97176)
* Scopes: Refactor scopes * Scopes: Refactor scopes * Scopes: Refactor scopes * Revert data changes * Refactorings * Refactorings * Refactorings * Remove unused * Refactorings * Refactors * Fixes * Update scenes * Rebase * Update .betterer.results * Fix tests * Move scopes selector to appchrome * More fixes * Fix tests * Remove ScopesFacade * use latest canary scenes version to pass tests * Make fields private * Update scenes version --------- Co-authored-by: Tobias Skarhed <tobias.skarhed@gmail.com> Co-authored-by: Victor Marin <victor.marin@grafana.com> Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com>pull/101947/head
parent
210c886bb7
commit
312d80e0e1
@ -1,48 +0,0 @@ |
||||
import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes'; |
||||
import { ScopesFacade } from 'app/features/scopes'; |
||||
|
||||
import { getDashboardSceneFor } from '../utils/utils'; |
||||
|
||||
import { convertScopesToAdHocFilters } from './convertScopesToAdHocFilters'; |
||||
|
||||
export interface DashboardScopesFacadeState { |
||||
reloadOnParamsChange?: boolean; |
||||
uid?: string; |
||||
} |
||||
|
||||
export class DashboardScopesFacade extends ScopesFacade { |
||||
constructor({ reloadOnParamsChange, uid }: DashboardScopesFacadeState) { |
||||
super({ |
||||
handler: (facade) => { |
||||
if (!reloadOnParamsChange || !uid) { |
||||
sceneGraph.getTimeRange(facade).onRefresh(); |
||||
} |
||||
|
||||
// push filters as soon as they come
|
||||
this.pushScopeFiltersToAdHocVariable(); |
||||
}, |
||||
}); |
||||
|
||||
this.addActivationHandler(() => { |
||||
// also try to push filters on activation, for
|
||||
// when the dashboard is changed
|
||||
this.pushScopeFiltersToAdHocVariable(); |
||||
}); |
||||
} |
||||
|
||||
private pushScopeFiltersToAdHocVariable() { |
||||
const dashboard = getDashboardSceneFor(this); |
||||
|
||||
const adhoc = dashboard.state.$variables?.state.variables.find((v) => v instanceof AdHocFiltersVariable); |
||||
|
||||
if (!adhoc) { |
||||
return; |
||||
} |
||||
|
||||
const filters = convertScopesToAdHocFilters(this.value); |
||||
|
||||
adhoc.setState({ |
||||
baseFilters: filters, |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,13 @@ |
||||
import { ReactNode } from 'react'; |
||||
|
||||
import { ScopesContext } from '@grafana/runtime'; |
||||
|
||||
import { ScopesService } from './ScopesService'; |
||||
|
||||
interface ScopesContextProviderProps { |
||||
children: ReactNode; |
||||
} |
||||
|
||||
export const ScopesContextProvider = ({ children }: ScopesContextProviderProps) => { |
||||
return <ScopesContext.Provider value={ScopesService.instance}>{children}</ScopesContext.Provider>; |
||||
}; |
@ -1,9 +0,0 @@ |
||||
import { scopesDashboardsScene } from './instance'; |
||||
|
||||
export function ScopesDashboards() { |
||||
if (!scopesDashboardsScene) { |
||||
return null; |
||||
} |
||||
|
||||
return <scopesDashboardsScene.Component model={scopesDashboardsScene} />; |
||||
} |
@ -1,87 +0,0 @@ |
||||
import { isEqual } from 'lodash'; |
||||
|
||||
import { |
||||
SceneObjectBase, |
||||
SceneObjectState, |
||||
SceneObjectUrlSyncConfig, |
||||
SceneObjectUrlValues, |
||||
SceneObjectWithUrlSync, |
||||
} from '@grafana/scenes'; |
||||
|
||||
import { scopesSelectorScene } from './instance'; |
||||
import { disableScopes, enableScopes, enterScopesReadOnly, exitScopesReadOnly, getSelectedScopes } from './utils'; |
||||
|
||||
interface ScopesFacadeState extends SceneObjectState { |
||||
// A callback that will be executed when new scopes are set
|
||||
handler?: (facade: ScopesFacade) => void; |
||||
// The render count is a workaround to force the URL sync manager to update the URL with the latest scopes
|
||||
// Basically it starts at 0, and it is increased with every scopes value update
|
||||
renderCount?: number; |
||||
} |
||||
|
||||
export class ScopesFacade extends SceneObjectBase<ScopesFacadeState> implements SceneObjectWithUrlSync { |
||||
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scopes'] }); |
||||
|
||||
public constructor(state: ScopesFacadeState) { |
||||
super({ |
||||
...state, |
||||
renderCount: 0, |
||||
}); |
||||
|
||||
this.addActivationHandler(this._activationHandler); |
||||
} |
||||
|
||||
public getUrlState() { |
||||
return { |
||||
scopes: this.value.map(({ metadata }) => metadata.name), |
||||
}; |
||||
} |
||||
|
||||
public updateFromUrl(values: SceneObjectUrlValues) { |
||||
if (!values.scopes && !scopesSelectorScene?.state.isEnabled) { |
||||
return; |
||||
} |
||||
|
||||
let scopeNames = values.scopes ?? []; |
||||
scopeNames = Array.isArray(scopeNames) ? scopeNames : [scopeNames]; |
||||
|
||||
scopesSelectorScene?.updateScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] }))); |
||||
} |
||||
|
||||
private _activationHandler = () => { |
||||
this.enable(); |
||||
|
||||
this._subs.add( |
||||
scopesSelectorScene?.subscribeToState((newState, prevState) => { |
||||
if (!newState.isLoadingScopes && (prevState.isLoadingScopes || !isEqual(newState.scopes, prevState.scopes))) { |
||||
this.setState({ renderCount: (this.state.renderCount ?? 0) + 1 }); |
||||
this.state.handler?.(this); |
||||
} |
||||
}) |
||||
); |
||||
|
||||
return () => { |
||||
this.disable(); |
||||
}; |
||||
}; |
||||
|
||||
public get value() { |
||||
return getSelectedScopes(); |
||||
} |
||||
|
||||
public enable() { |
||||
enableScopes(); |
||||
} |
||||
|
||||
public disable() { |
||||
disableScopes(); |
||||
} |
||||
|
||||
public enterReadOnly() { |
||||
enterScopesReadOnly(); |
||||
} |
||||
|
||||
public exitReadOnly() { |
||||
exitScopesReadOnly(); |
||||
} |
||||
} |
@ -1,9 +0,0 @@ |
||||
import { scopesSelectorScene } from './instance'; |
||||
|
||||
export function ScopesSelector() { |
||||
if (!scopesSelectorScene) { |
||||
return null; |
||||
} |
||||
|
||||
return <scopesSelectorScene.Component model={scopesSelectorScene} />; |
||||
} |
@ -0,0 +1,63 @@ |
||||
import { Scope } from '@grafana/data'; |
||||
import { config, ScopesContextValue, ScopesContextValueState } from '@grafana/runtime'; |
||||
|
||||
import { ScopesServiceBase } from './ScopesServiceBase'; |
||||
import { ScopesSelectorService } from './selector/ScopesSelectorService'; |
||||
|
||||
export class ScopesService extends ScopesServiceBase<ScopesContextValueState> implements ScopesContextValue { |
||||
static #instance: ScopesService | undefined = undefined; |
||||
|
||||
private constructor() { |
||||
super({ |
||||
drawerOpened: false, |
||||
enabled: false, |
||||
loading: false, |
||||
readOnly: false, |
||||
value: [], |
||||
}); |
||||
} |
||||
|
||||
public static get instance(): ScopesService | undefined { |
||||
if (!ScopesService.#instance && config.featureToggles.scopeFilters) { |
||||
ScopesService.#instance = new ScopesService(); |
||||
} |
||||
|
||||
return ScopesService.#instance; |
||||
} |
||||
|
||||
public changeScopes = (scopeNames: string[]) => ScopesSelectorService.instance?.changeScopes(scopeNames); |
||||
|
||||
public setReadOnly = (readOnly: boolean) => { |
||||
if (this.state.readOnly !== readOnly) { |
||||
this.updateState({ readOnly }); |
||||
} |
||||
|
||||
if (readOnly && ScopesSelectorService.instance?.state.opened) { |
||||
ScopesSelectorService.instance?.closeAndReset(); |
||||
} |
||||
}; |
||||
|
||||
public setEnabled = (enabled: boolean) => { |
||||
if (this.state.enabled !== enabled) { |
||||
this.updateState({ enabled }); |
||||
} |
||||
}; |
||||
|
||||
public setScopes = (scopes: Scope[]) => this.updateState({ value: scopes }); |
||||
|
||||
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; |
||||
}; |
||||
} |
@ -0,0 +1,31 @@ |
||||
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)); |
||||
} |
||||
|
||||
public get state(): T { |
||||
return this._state.getValue(); |
||||
} |
||||
|
||||
public get stateObservable(): Observable<T> { |
||||
return this._state.asObservable(); |
||||
} |
||||
|
||||
public subscribeToState = (cb: (newState: T, prevState: T) => void): Subscription => { |
||||
return this._state.pipe(pairwise()).subscribe(([prevState, newState]) => cb(newState, prevState)); |
||||
}; |
||||
|
||||
protected updateState = (newState: Partial<T>) => { |
||||
this._state.next(Object.freeze({ ...this.state, ...newState })); |
||||
}; |
||||
} |
@ -0,0 +1,114 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import { useObservable } from 'react-use'; |
||||
import { Observable } from 'rxjs'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
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 { ScopesDashboardsTree } from './ScopesDashboardsTree'; |
||||
import { ScopesDashboardsTreeSearch } from './ScopesDashboardsTreeSearch'; |
||||
|
||||
export function ScopesDashboards() { |
||||
const styles = useStyles2(getStyles); |
||||
const scopes = useScopes(); |
||||
|
||||
const scopesDashboardsService = ScopesDashboardsService.instance; |
||||
|
||||
useObservable(scopesDashboardsService?.stateObservable ?? new Observable(), scopesDashboardsService?.state); |
||||
|
||||
if ( |
||||
!scopes || |
||||
!scopesDashboardsService || |
||||
!scopes.state.enabled || |
||||
!scopes.state.drawerOpened || |
||||
scopes.state.readOnly |
||||
) { |
||||
return null; |
||||
} |
||||
|
||||
const { loading, forScopeNames, dashboards, searchQuery, filteredFolders } = scopesDashboardsService.state; |
||||
const { changeSearchQuery, updateFolder, clearSearchQuery } = scopesDashboardsService; |
||||
|
||||
if (!loading) { |
||||
if (forScopeNames.length === 0) { |
||||
return ( |
||||
<div |
||||
className={cx(styles.container, styles.noResultsContainer)} |
||||
data-testid="scopes-dashboards-notFoundNoScopes" |
||||
> |
||||
<Trans i18nKey="scopes.dashboards.noResultsNoScopes">No scopes selected</Trans> |
||||
</div> |
||||
); |
||||
} else if (dashboards.length === 0) { |
||||
return ( |
||||
<div |
||||
className={cx(styles.container, styles.noResultsContainer)} |
||||
data-testid="scopes-dashboards-notFoundForScope" |
||||
> |
||||
<Trans i18nKey="scopes.dashboards.noResultsForScopes">No dashboards found for the selected scopes</Trans> |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div className={styles.container} data-testid="scopes-dashboards-container"> |
||||
<ScopesDashboardsTreeSearch disabled={loading} query={searchQuery} onChange={changeSearchQuery} /> |
||||
|
||||
{loading ? ( |
||||
<LoadingPlaceholder |
||||
className={styles.loadingIndicator} |
||||
text={t('scopes.dashboards.loading', 'Loading dashboards')} |
||||
data-testid="scopes-dashboards-loading" |
||||
/> |
||||
) : filteredFolders[''] ? ( |
||||
<ScrollContainer> |
||||
<ScopesDashboardsTree folders={filteredFolders} folderPath={['']} onFolderUpdate={updateFolder} /> |
||||
</ScrollContainer> |
||||
) : ( |
||||
<p className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForFilter"> |
||||
<Trans i18nKey="scopes.dashboards.noResultsForFilter">No results found for your query</Trans> |
||||
|
||||
<Button |
||||
variant="secondary" |
||||
onClick={clearSearchQuery} |
||||
data-testid="scopes-dashboards-notFoundForFilter-clear" |
||||
> |
||||
<Trans i18nKey="scopes.dashboards.noResultsForFilterClear">Clear search</Trans> |
||||
</Button> |
||||
</p> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
container: css({ |
||||
backgroundColor: theme.colors.background.primary, |
||||
borderRight: `1px solid ${theme.colors.border.weak}`, |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
height: '100%', |
||||
gap: theme.spacing(1), |
||||
padding: theme.spacing(2), |
||||
width: theme.spacing(37.5), |
||||
}), |
||||
noResultsContainer: css({ |
||||
alignItems: 'center', |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
gap: theme.spacing(1), |
||||
height: '100%', |
||||
justifyContent: 'center', |
||||
margin: 0, |
||||
textAlign: 'center', |
||||
}), |
||||
loadingIndicator: css({ |
||||
alignSelf: 'center', |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,217 @@ |
||||
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 { ScopesServiceBase } from '../ScopesServiceBase'; |
||||
|
||||
import { SuggestedDashboardsFoldersMap } from './types'; |
||||
|
||||
interface ScopesDashboardsServiceState { |
||||
// 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
|
||||
filteredFolders: SuggestedDashboardsFoldersMap; |
||||
// this is a grouping in folders of the `dashboards` property. it is used for filtering the dashboards and folders when the search query changes
|
||||
folders: SuggestedDashboardsFoldersMap; |
||||
forScopeNames: string[]; |
||||
loading: boolean; |
||||
searchQuery: string; |
||||
} |
||||
|
||||
export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsServiceState> { |
||||
static #instance: ScopesDashboardsService | undefined = undefined; |
||||
|
||||
private constructor() { |
||||
super({ |
||||
dashboards: [], |
||||
filteredFolders: {}, |
||||
folders: {}, |
||||
forScopeNames: [], |
||||
loading: false, |
||||
searchQuery: '', |
||||
}); |
||||
} |
||||
|
||||
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 }; |
||||
let currentLevelFolders: SuggestedDashboardsFoldersMap = folders; |
||||
let currentLevelFilteredFolders: SuggestedDashboardsFoldersMap = filteredFolders; |
||||
|
||||
for (let idx = 0; idx < path.length - 1; idx++) { |
||||
currentLevelFolders = currentLevelFolders[path[idx]].folders; |
||||
currentLevelFilteredFolders = currentLevelFilteredFolders[path[idx]].folders; |
||||
} |
||||
|
||||
const name = path[path.length - 1]; |
||||
const currentFolder = currentLevelFolders[name]; |
||||
const currentFilteredFolder = currentLevelFilteredFolders[name]; |
||||
|
||||
currentFolder.expanded = expanded; |
||||
currentFilteredFolder.expanded = expanded; |
||||
|
||||
this.updateState({ folders, filteredFolders }); |
||||
}; |
||||
|
||||
public changeSearchQuery = (searchQuery: string) => { |
||||
searchQuery = searchQuery ?? ''; |
||||
|
||||
const filteredFolders = this.filterFolders(this.state.folders, searchQuery); |
||||
|
||||
this.updateState({ filteredFolders, searchQuery }); |
||||
}; |
||||
|
||||
public clearSearchQuery = () => { |
||||
this.changeSearchQuery(''); |
||||
}; |
||||
|
||||
public fetchDashboards = async (forScopeNames: string[]) => { |
||||
if (isEqual(this.state.forScopeNames, forScopeNames)) { |
||||
return; |
||||
} |
||||
|
||||
this._fetchSub?.unsubscribe(); |
||||
|
||||
if (forScopeNames.length === 0) { |
||||
this.updateState({ |
||||
dashboards: [], |
||||
filteredFolders: {}, |
||||
folders: {}, |
||||
forScopeNames: [], |
||||
loading: 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); |
||||
|
||||
this.updateState({ dashboards, filteredFolders, folders, loading: false }); |
||||
|
||||
ScopesService.instance?.setDrawerOpened(dashboards.length > 0); |
||||
|
||||
this._fetchSub?.unsubscribe(); |
||||
}); |
||||
}; |
||||
|
||||
public groupDashboards = (dashboards: ScopeDashboardBinding[]): SuggestedDashboardsFoldersMap => { |
||||
return dashboards.reduce<SuggestedDashboardsFoldersMap>( |
||||
(acc, dashboard) => { |
||||
const rootNode = acc['']; |
||||
const groups = dashboard.status.groups ?? []; |
||||
|
||||
groups.forEach((group) => { |
||||
if (group && !rootNode.folders[group]) { |
||||
rootNode.folders[group] = { |
||||
title: group, |
||||
expanded: false, |
||||
folders: {}, |
||||
dashboards: {}, |
||||
}; |
||||
} |
||||
}); |
||||
|
||||
const targets = |
||||
groups.length > 0 |
||||
? groups.map((group) => (group === '' ? rootNode.dashboards : rootNode.folders[group].dashboards)) |
||||
: [rootNode.dashboards]; |
||||
|
||||
targets.forEach((target) => { |
||||
if (!target[dashboard.spec.dashboard]) { |
||||
target[dashboard.spec.dashboard] = { |
||||
dashboard: dashboard.spec.dashboard, |
||||
dashboardTitle: dashboard.status.dashboardTitle, |
||||
items: [], |
||||
}; |
||||
} |
||||
|
||||
target[dashboard.spec.dashboard].items.push(dashboard); |
||||
}); |
||||
|
||||
return acc; |
||||
}, |
||||
{ |
||||
'': { |
||||
title: '', |
||||
expanded: true, |
||||
folders: {}, |
||||
dashboards: {}, |
||||
}, |
||||
} |
||||
); |
||||
}; |
||||
|
||||
public filterFolders = (folders: SuggestedDashboardsFoldersMap, query: string): SuggestedDashboardsFoldersMap => { |
||||
query = (query ?? '').toLowerCase(); |
||||
|
||||
return Object.entries(folders).reduce<SuggestedDashboardsFoldersMap>((acc, [folderId, folder]) => { |
||||
// If folder matches the query, we show everything inside
|
||||
if (folder.title.toLowerCase().includes(query)) { |
||||
acc[folderId] = { |
||||
...folder, |
||||
expanded: true, |
||||
}; |
||||
|
||||
return acc; |
||||
} |
||||
|
||||
const filteredFolders = this.filterFolders(folder.folders, query); |
||||
const filteredDashboards = Object.entries(folder.dashboards).filter(([_, dashboard]) => |
||||
dashboard.dashboardTitle.toLowerCase().includes(query) |
||||
); |
||||
|
||||
if (Object.keys(filteredFolders).length > 0 || filteredDashboards.length > 0) { |
||||
acc[folderId] = { |
||||
...folder, |
||||
expanded: true, |
||||
folders: filteredFolders, |
||||
dashboards: Object.fromEntries(filteredDashboards), |
||||
}; |
||||
} |
||||
|
||||
return acc; |
||||
}, {}); |
||||
}; |
||||
|
||||
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; |
||||
}; |
||||
} |
@ -0,0 +1,19 @@ |
||||
import { ScopeDashboardBinding } from '@grafana/data'; |
||||
|
||||
export interface SuggestedDashboard { |
||||
dashboard: string; |
||||
dashboardTitle: string; |
||||
items: ScopeDashboardBinding[]; |
||||
} |
||||
|
||||
export interface SuggestedDashboardsFolder { |
||||
title: string; |
||||
expanded: boolean; |
||||
folders: SuggestedDashboardsFoldersMap; |
||||
dashboards: SuggestedDashboardsMap; |
||||
} |
||||
|
||||
export type SuggestedDashboardsMap = Record<string, SuggestedDashboard>; |
||||
export type SuggestedDashboardsFoldersMap = Record<string, SuggestedDashboardsFolder>; |
||||
|
||||
export type OnFolderUpdate = (path: string[], expanded: boolean) => void; |
@ -1,15 +0,0 @@ |
||||
export { initializeScopes } from './instance'; |
||||
export { ScopesDashboards } from './ScopesDashboards'; |
||||
/* eslint-disable */ |
||||
export { ScopesFacade } from './ScopesFacadeScene'; |
||||
export { ScopesSelector } from './ScopesSelector'; |
||||
export { useScopesDashboardsState } from './useScopesDashboardsState'; |
||||
export { |
||||
disableScopes, |
||||
enableScopes, |
||||
enterScopesReadOnly, |
||||
exitScopesReadOnly, |
||||
getClosestScopesFacade, |
||||
getSelectedScopes, |
||||
getSelectedScopesNames, |
||||
} from './utils'; |
@ -1,17 +0,0 @@ |
||||
import { config } from '@grafana/runtime'; |
||||
|
||||
import { ScopesDashboardsScene } from './internal/ScopesDashboardsScene'; |
||||
import { ScopesSelectorScene } from './internal/ScopesSelectorScene'; |
||||
|
||||
export let scopesDashboardsScene: ScopesDashboardsScene | null = null; |
||||
export let scopesSelectorScene: ScopesSelectorScene | null = null; |
||||
|
||||
export function initializeScopes() { |
||||
if (config.featureToggles.scopeFilters) { |
||||
scopesSelectorScene = new ScopesSelectorScene(); |
||||
scopesDashboardsScene = new ScopesDashboardsScene(); |
||||
|
||||
scopesSelectorScene.setState({ dashboards: scopesDashboardsScene.getRef() }); |
||||
scopesDashboardsScene.setState({ selector: scopesSelectorScene.getRef() }); |
||||
} |
||||
} |
@ -1,275 +0,0 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import { isEqual } from 'lodash'; |
||||
import { finalize, from, Subscription } from 'rxjs'; |
||||
|
||||
import { GrafanaTheme2, ScopeDashboardBinding } from '@grafana/data'; |
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState } from '@grafana/scenes'; |
||||
import { Button, LoadingPlaceholder, ScrollContainer, useStyles2 } from '@grafana/ui'; |
||||
import { t, Trans } from 'app/core/internationalization'; |
||||
|
||||
import { ScopesDashboardsTree } from './ScopesDashboardsTree'; |
||||
import { ScopesDashboardsTreeSearch } from './ScopesDashboardsTreeSearch'; |
||||
import { ScopesSelectorScene } from './ScopesSelectorScene'; |
||||
import { fetchDashboards } from './api'; |
||||
import { SuggestedDashboardsFoldersMap } from './types'; |
||||
import { filterFolders, groupDashboards } from './utils'; |
||||
|
||||
export interface ScopesDashboardsSceneState extends SceneObjectState { |
||||
selector: SceneObjectRef<ScopesSelectorScene> | null; |
||||
// 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[]; |
||||
// this is a grouping in folders of the `dashboards` property. it is used for filtering the dashboards and folders when the search query changes
|
||||
folders: SuggestedDashboardsFoldersMap; |
||||
// a filtered version of the `folders` property. this prevents a lot of unnecessary parsings in React renders
|
||||
filteredFolders: SuggestedDashboardsFoldersMap; |
||||
forScopeNames: string[]; |
||||
isLoading: boolean; |
||||
isPanelOpened: boolean; |
||||
isEnabled: boolean; |
||||
isReadOnly: boolean; |
||||
searchQuery: string; |
||||
} |
||||
|
||||
export const getInitialDashboardsState: () => Omit<ScopesDashboardsSceneState, 'selector'> = () => ({ |
||||
dashboards: [], |
||||
folders: {}, |
||||
filteredFolders: {}, |
||||
forScopeNames: [], |
||||
isLoading: false, |
||||
isPanelOpened: false, |
||||
isEnabled: false, |
||||
isReadOnly: false, |
||||
searchQuery: '', |
||||
}); |
||||
|
||||
export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsSceneState> { |
||||
static Component = ScopesDashboardsSceneRenderer; |
||||
|
||||
private dashboardsFetchingSub: Subscription | undefined; |
||||
|
||||
constructor() { |
||||
super({ |
||||
selector: null, |
||||
...getInitialDashboardsState(), |
||||
}); |
||||
|
||||
this.addActivationHandler(() => { |
||||
return () => { |
||||
this.dashboardsFetchingSub?.unsubscribe(); |
||||
}; |
||||
}); |
||||
} |
||||
|
||||
public async fetchDashboards(scopeNames: string[]) { |
||||
if (isEqual(this.state.forScopeNames, scopeNames)) { |
||||
return; |
||||
} |
||||
|
||||
this.dashboardsFetchingSub?.unsubscribe(); |
||||
|
||||
this.setState({ forScopeNames: scopeNames }); |
||||
|
||||
if (scopeNames.length === 0) { |
||||
return this.setState({ |
||||
dashboards: [], |
||||
folders: {}, |
||||
filteredFolders: {}, |
||||
forScopeNames: [], |
||||
isLoading: false, |
||||
isPanelOpened: false, |
||||
}); |
||||
} |
||||
|
||||
this.setState({ isLoading: true }); |
||||
|
||||
this.dashboardsFetchingSub = from(fetchDashboards(scopeNames)) |
||||
.pipe( |
||||
finalize(() => { |
||||
this.setState({ isLoading: false }); |
||||
}) |
||||
) |
||||
.subscribe((dashboards) => { |
||||
const folders = groupDashboards(dashboards); |
||||
const filteredFolders = filterFolders(folders, this.state.searchQuery); |
||||
|
||||
this.setState({ |
||||
dashboards, |
||||
folders, |
||||
filteredFolders, |
||||
isLoading: false, |
||||
isPanelOpened: scopeNames.length > 0, |
||||
}); |
||||
|
||||
this.dashboardsFetchingSub?.unsubscribe(); |
||||
}); |
||||
} |
||||
|
||||
public changeSearchQuery(searchQuery: string) { |
||||
searchQuery = searchQuery ?? ''; |
||||
|
||||
this.setState({ |
||||
filteredFolders: filterFolders(this.state.folders, searchQuery), |
||||
searchQuery, |
||||
}); |
||||
} |
||||
|
||||
public updateFolder(path: string[], isExpanded: boolean) { |
||||
let folders = { ...this.state.folders }; |
||||
let filteredFolders = { ...this.state.filteredFolders }; |
||||
let currentLevelFolders: SuggestedDashboardsFoldersMap = folders; |
||||
let currentLevelFilteredFolders: SuggestedDashboardsFoldersMap = filteredFolders; |
||||
|
||||
for (let idx = 0; idx < path.length - 1; idx++) { |
||||
currentLevelFolders = currentLevelFolders[path[idx]].folders; |
||||
currentLevelFilteredFolders = currentLevelFilteredFolders[path[idx]].folders; |
||||
} |
||||
|
||||
const name = path[path.length - 1]; |
||||
const currentFolder = currentLevelFolders[name]; |
||||
const currentFilteredFolder = currentLevelFilteredFolders[name]; |
||||
|
||||
currentFolder.isExpanded = isExpanded; |
||||
currentFilteredFolder.isExpanded = isExpanded; |
||||
|
||||
this.setState({ folders, filteredFolders }); |
||||
} |
||||
|
||||
public togglePanel() { |
||||
if (this.state.isPanelOpened) { |
||||
this.closePanel(); |
||||
} else { |
||||
this.openPanel(); |
||||
} |
||||
} |
||||
|
||||
public openPanel() { |
||||
if (this.state.isPanelOpened) { |
||||
return; |
||||
} |
||||
|
||||
this.setState({ isPanelOpened: true }); |
||||
} |
||||
|
||||
public closePanel() { |
||||
if (!this.state.isPanelOpened) { |
||||
return; |
||||
} |
||||
|
||||
this.setState({ isPanelOpened: false }); |
||||
} |
||||
|
||||
public enable() { |
||||
this.setState({ isEnabled: true }); |
||||
} |
||||
|
||||
public disable() { |
||||
this.setState({ isEnabled: false }); |
||||
} |
||||
|
||||
public enterReadOnly() { |
||||
this.setState({ isReadOnly: true }); |
||||
} |
||||
|
||||
public exitReadOnly() { |
||||
this.setState({ isReadOnly: false }); |
||||
} |
||||
} |
||||
|
||||
export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<ScopesDashboardsScene>) { |
||||
const { dashboards, filteredFolders, forScopeNames, isLoading, isPanelOpened, isEnabled, isReadOnly, searchQuery } = |
||||
model.useState(); |
||||
|
||||
const styles = useStyles2(getStyles); |
||||
|
||||
if (!isEnabled || !isPanelOpened || isReadOnly) { |
||||
return null; |
||||
} |
||||
|
||||
if (!isLoading) { |
||||
if (forScopeNames.length === 0) { |
||||
return ( |
||||
<div |
||||
className={cx(styles.container, styles.noResultsContainer)} |
||||
data-testid="scopes-dashboards-notFoundNoScopes" |
||||
> |
||||
<Trans i18nKey="scopes.dashboards.noResultsNoScopes">No scopes selected</Trans> |
||||
</div> |
||||
); |
||||
} else if (dashboards.length === 0) { |
||||
return ( |
||||
<div |
||||
className={cx(styles.container, styles.noResultsContainer)} |
||||
data-testid="scopes-dashboards-notFoundForScope" |
||||
> |
||||
<Trans i18nKey="scopes.dashboards.noResultsForScopes">No dashboards found for the selected scopes</Trans> |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div className={styles.container} data-testid="scopes-dashboards-container"> |
||||
<ScopesDashboardsTreeSearch |
||||
disabled={isLoading} |
||||
query={searchQuery} |
||||
onChange={(value) => model.changeSearchQuery(value)} |
||||
/> |
||||
|
||||
{isLoading ? ( |
||||
<LoadingPlaceholder |
||||
className={styles.loadingIndicator} |
||||
text={t('scopes.dashboards.loading', 'Loading dashboards')} |
||||
data-testid="scopes-dashboards-loading" |
||||
/> |
||||
) : filteredFolders[''] ? ( |
||||
<ScrollContainer> |
||||
<ScopesDashboardsTree |
||||
folders={filteredFolders} |
||||
folderPath={['']} |
||||
onFolderUpdate={(path, isExpanded) => model.updateFolder(path, isExpanded)} |
||||
/> |
||||
</ScrollContainer> |
||||
) : ( |
||||
<p className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForFilter"> |
||||
<Trans i18nKey="scopes.dashboards.noResultsForFilter">No results found for your query</Trans> |
||||
|
||||
<Button |
||||
variant="secondary" |
||||
onClick={() => model.changeSearchQuery('')} |
||||
data-testid="scopes-dashboards-notFoundForFilter-clear" |
||||
> |
||||
<Trans i18nKey="scopes.dashboards.noResultsForFilterClear">Clear search</Trans> |
||||
</Button> |
||||
</p> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
container: css({ |
||||
backgroundColor: theme.colors.background.primary, |
||||
borderRight: `1px solid ${theme.colors.border.weak}`, |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
height: '100%', |
||||
gap: theme.spacing(1), |
||||
padding: theme.spacing(2), |
||||
width: theme.spacing(37.5), |
||||
}), |
||||
noResultsContainer: css({ |
||||
alignItems: 'center', |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
gap: theme.spacing(1), |
||||
height: '100%', |
||||
justifyContent: 'center', |
||||
margin: 0, |
||||
textAlign: 'center', |
||||
}), |
||||
loadingIndicator: css({ |
||||
alignSelf: 'center', |
||||
}), |
||||
}; |
||||
}; |
@ -1,410 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { isEqual } from 'lodash'; |
||||
import { finalize, from, Subscription } from 'rxjs'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState } from '@grafana/scenes'; |
||||
import { Button, Drawer, IconButton, Spinner, useStyles2 } from '@grafana/ui'; |
||||
import { useGrafana } from 'app/core/context/GrafanaContext'; |
||||
import { t, Trans } from 'app/core/internationalization'; |
||||
|
||||
import { ScopesDashboardsScene } from './ScopesDashboardsScene'; |
||||
import { ScopesInput } from './ScopesInput'; |
||||
import { ScopesTree } from './ScopesTree'; |
||||
import { fetchNodes, fetchScope, fetchSelectedScopes } from './api'; |
||||
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types'; |
||||
import { getBasicScope, getScopesAndTreeScopesWithPaths, getTreeScopesFromSelectedScopes } from './utils'; |
||||
|
||||
export interface ScopesSelectorSceneState extends SceneObjectState { |
||||
dashboards: SceneObjectRef<ScopesDashboardsScene> | null; |
||||
nodes: NodesMap; |
||||
loadingNodeName: string | undefined; |
||||
scopes: SelectedScope[]; |
||||
treeScopes: TreeScope[]; |
||||
isReadOnly: boolean; |
||||
isLoadingScopes: boolean; |
||||
isPickerOpened: boolean; |
||||
isEnabled: boolean; |
||||
} |
||||
|
||||
export const initialSelectorState: Omit<ScopesSelectorSceneState, 'dashboards'> = { |
||||
nodes: { |
||||
'': { |
||||
name: '', |
||||
reason: NodeReason.Result, |
||||
nodeType: 'container', |
||||
title: '', |
||||
isExpandable: true, |
||||
isSelectable: false, |
||||
isExpanded: true, |
||||
query: '', |
||||
nodes: {}, |
||||
}, |
||||
}, |
||||
loadingNodeName: undefined, |
||||
scopes: [], |
||||
treeScopes: [], |
||||
isReadOnly: false, |
||||
isLoadingScopes: false, |
||||
isPickerOpened: false, |
||||
isEnabled: false, |
||||
}; |
||||
|
||||
export class ScopesSelectorScene extends SceneObjectBase<ScopesSelectorSceneState> { |
||||
static Component = ScopesSelectorSceneRenderer; |
||||
|
||||
private nodesFetchingSub: Subscription | undefined; |
||||
|
||||
constructor() { |
||||
super({ |
||||
dashboards: null, |
||||
...initialSelectorState, |
||||
}); |
||||
|
||||
this.addActivationHandler(() => { |
||||
// Only fetch base nodes on activation when there are no nodes fetched
|
||||
// This prevents an issue where base nodes are overwritten upon re-activations
|
||||
if (Object.keys(this.state.nodes[''].nodes).length === 0) { |
||||
this.fetchBaseNodes(); |
||||
} |
||||
|
||||
return () => { |
||||
this.nodesFetchingSub?.unsubscribe(); |
||||
}; |
||||
}); |
||||
} |
||||
|
||||
public fetchBaseNodes() { |
||||
return this.updateNode([''], true, ''); |
||||
} |
||||
|
||||
public async updateNode(path: string[], isExpanded: boolean, query: string) { |
||||
this.nodesFetchingSub?.unsubscribe(); |
||||
|
||||
let nodes = { ...this.state.nodes }; |
||||
let currentLevel: NodesMap = nodes; |
||||
|
||||
for (let idx = 0; idx < path.length - 1; idx++) { |
||||
currentLevel = currentLevel[path[idx]].nodes; |
||||
} |
||||
|
||||
const name = path[path.length - 1]; |
||||
const currentNode = currentLevel[name]; |
||||
|
||||
const isDifferentQuery = currentNode.query !== query; |
||||
|
||||
currentNode.isExpanded = isExpanded; |
||||
currentNode.query = query; |
||||
|
||||
if (isExpanded || isDifferentQuery) { |
||||
this.setState({ nodes, loadingNodeName: name }); |
||||
|
||||
this.nodesFetchingSub = from(fetchNodes(name, query)) |
||||
.pipe( |
||||
finalize(() => { |
||||
this.setState({ loadingNodeName: undefined }); |
||||
}) |
||||
) |
||||
.subscribe((childNodes) => { |
||||
const [scopes, treeScopes] = getScopesAndTreeScopesWithPaths( |
||||
this.state.scopes, |
||||
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, |
||||
}; |
||||
|
||||
return acc; |
||||
}, {}); |
||||
|
||||
currentNode.nodes = { ...persistedNodes, ...childNodes }; |
||||
|
||||
this.setState({ nodes, scopes, treeScopes }); |
||||
|
||||
this.nodesFetchingSub?.unsubscribe(); |
||||
}); |
||||
} else { |
||||
this.setState({ nodes, loadingNodeName: undefined }); |
||||
} |
||||
} |
||||
|
||||
public toggleNodeSelect(path: string[]) { |
||||
let treeScopes = [...this.state.treeScopes]; |
||||
|
||||
let parentNode = this.state.nodes['']; |
||||
|
||||
for (let idx = 1; idx < path.length - 1; idx++) { |
||||
parentNode = parentNode.nodes[path[idx]]; |
||||
} |
||||
|
||||
const nodeName = path[path.length - 1]; |
||||
const { linkId } = parentNode.nodes[nodeName]; |
||||
|
||||
const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId); |
||||
|
||||
if (selectedIdx === -1) { |
||||
fetchScope(linkId!); |
||||
|
||||
const selectedFromSameNode = |
||||
treeScopes.length === 0 || |
||||
Object.values(parentNode.nodes).some(({ linkId }) => linkId === treeScopes[0].scopeName); |
||||
|
||||
const treeScope = { |
||||
scopeName: linkId!, |
||||
path, |
||||
}; |
||||
|
||||
this.setState({ |
||||
treeScopes: parentNode?.disableMultiSelect || !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope], |
||||
}); |
||||
} else { |
||||
treeScopes.splice(selectedIdx, 1); |
||||
|
||||
this.setState({ treeScopes }); |
||||
} |
||||
} |
||||
|
||||
public openPicker() { |
||||
if (!this.state.isReadOnly) { |
||||
let nodes = { ...this.state.nodes }; |
||||
|
||||
// First close all nodes
|
||||
nodes = this.closeNodes(nodes); |
||||
|
||||
// Extract the path of a scope
|
||||
let path = [...(this.state.scopes[0]?.path ?? ['', ''])]; |
||||
path.splice(path.length - 1, 1); |
||||
|
||||
// Expand the nodes to the selected scope
|
||||
nodes = this.expandNodes(nodes, path); |
||||
|
||||
this.setState({ isPickerOpened: true, nodes }); |
||||
} |
||||
} |
||||
|
||||
public closePicker() { |
||||
this.setState({ isPickerOpened: false }); |
||||
} |
||||
|
||||
public async updateScopes(treeScopes = this.state.treeScopes) { |
||||
if (isEqual(treeScopes, getTreeScopesFromSelectedScopes(this.state.scopes))) { |
||||
return; |
||||
} |
||||
|
||||
this.setState({ |
||||
// Update the scopes with the basic scopes otherwise they'd be lost between URL syncs
|
||||
scopes: treeScopes.map(({ scopeName, path }) => ({ scope: getBasicScope(scopeName), path })), |
||||
treeScopes, |
||||
isLoadingScopes: true, |
||||
}); |
||||
|
||||
this.state.dashboards?.resolve().fetchDashboards(treeScopes.map(({ scopeName }) => scopeName)); |
||||
|
||||
const scopes = await fetchSelectedScopes(treeScopes); |
||||
|
||||
this.setState({ scopes, isLoadingScopes: false }); |
||||
} |
||||
|
||||
public resetDirtyScopeNames() { |
||||
this.setState({ treeScopes: getTreeScopesFromSelectedScopes(this.state.scopes) }); |
||||
} |
||||
|
||||
public async removeAllScopes() { |
||||
return this.updateScopes([]); |
||||
} |
||||
|
||||
public enterReadOnly() { |
||||
this.setState({ isReadOnly: true, isPickerOpened: false }); |
||||
} |
||||
|
||||
public exitReadOnly() { |
||||
this.setState({ isReadOnly: false }); |
||||
} |
||||
|
||||
public enable() { |
||||
this.setState({ isEnabled: true }); |
||||
} |
||||
|
||||
public disable() { |
||||
this.setState({ isEnabled: false }); |
||||
} |
||||
|
||||
private closeNodes(nodes: NodesMap): NodesMap { |
||||
return Object.entries(nodes).reduce<NodesMap>((acc, [id, node]) => { |
||||
acc[id] = { |
||||
...node, |
||||
isExpanded: false, |
||||
nodes: this.closeNodes(node.nodes), |
||||
}; |
||||
|
||||
return acc; |
||||
}, {}); |
||||
} |
||||
|
||||
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], |
||||
isExpanded: true, |
||||
}; |
||||
currentNodes = currentNodes[nodeId].nodes; |
||||
} |
||||
|
||||
return nodes; |
||||
} |
||||
} |
||||
|
||||
export function ScopesSelectorSceneRenderer({ model }: SceneComponentProps<ScopesSelectorScene>) { |
||||
const { chrome } = useGrafana(); |
||||
const state = chrome.useState(); |
||||
const menuDockedAndOpen = !state.chromeless && state.megaMenuDocked && state.megaMenuOpen; |
||||
const styles = useStyles2(getStyles, menuDockedAndOpen); |
||||
const { |
||||
dashboards: dashboardsRef, |
||||
nodes, |
||||
loadingNodeName, |
||||
scopes, |
||||
treeScopes, |
||||
isReadOnly, |
||||
isLoadingScopes, |
||||
isPickerOpened, |
||||
isEnabled, |
||||
} = model.useState(); |
||||
|
||||
const dashboards = dashboardsRef?.resolve(); |
||||
|
||||
const { isPanelOpened: isDashboardsPanelOpened } = dashboards?.useState() ?? {}; |
||||
|
||||
if (!isEnabled) { |
||||
return null; |
||||
} |
||||
|
||||
const dashboardsIconLabel = isReadOnly |
||||
? t('scopes.dashboards.toggle.disabled', 'Suggested dashboards list is disabled due to read only mode') |
||||
: isDashboardsPanelOpened |
||||
? t('scopes.dashboards.toggle.collapse', 'Collapse suggested dashboards list') |
||||
: t('scopes.dashboards.toggle..expand', 'Expand suggested dashboards list'); |
||||
|
||||
return ( |
||||
<div className={styles.container}> |
||||
<IconButton |
||||
name="web-section-alt" |
||||
className={styles.dashboards} |
||||
aria-label={dashboardsIconLabel} |
||||
tooltip={dashboardsIconLabel} |
||||
data-testid="scopes-dashboards-expand" |
||||
disabled={isReadOnly} |
||||
onClick={() => dashboards?.togglePanel()} |
||||
/> |
||||
|
||||
<ScopesInput |
||||
nodes={nodes} |
||||
scopes={scopes} |
||||
isDisabled={isReadOnly} |
||||
isLoading={isLoadingScopes} |
||||
onInputClick={() => model.openPicker()} |
||||
onRemoveAllClick={() => model.removeAllScopes()} |
||||
/> |
||||
|
||||
{isPickerOpened && ( |
||||
<Drawer |
||||
title={t('scopes.selector.title', 'Select scopes')} |
||||
size="sm" |
||||
onClose={() => { |
||||
model.closePicker(); |
||||
model.resetDirtyScopeNames(); |
||||
}} |
||||
> |
||||
<div className={styles.drawerContainer}> |
||||
<div className={styles.treeContainer}> |
||||
{isLoadingScopes ? ( |
||||
<Spinner data-testid="scopes-selector-loading" /> |
||||
) : ( |
||||
<ScopesTree |
||||
nodes={nodes} |
||||
nodePath={['']} |
||||
loadingNodeName={loadingNodeName} |
||||
scopes={treeScopes} |
||||
onNodeUpdate={(path, isExpanded, query) => model.updateNode(path, isExpanded, query)} |
||||
onNodeSelectToggle={(path) => model.toggleNodeSelect(path)} |
||||
/> |
||||
)} |
||||
</div> |
||||
|
||||
<div className={styles.buttonsContainer}> |
||||
<Button |
||||
variant="primary" |
||||
data-testid="scopes-selector-apply" |
||||
onClick={() => { |
||||
model.closePicker(); |
||||
model.updateScopes(); |
||||
}} |
||||
> |
||||
<Trans i18nKey="scopes.selector.apply">Apply</Trans> |
||||
</Button> |
||||
<Button |
||||
variant="secondary" |
||||
data-testid="scopes-selector-cancel" |
||||
onClick={() => { |
||||
model.closePicker(); |
||||
model.resetDirtyScopeNames(); |
||||
}} |
||||
> |
||||
<Trans i18nKey="scopes.selector.cancel">Cancel</Trans> |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
</Drawer> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2, menuDockedAndOpen: boolean) => { |
||||
return { |
||||
container: css({ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
paddingLeft: menuDockedAndOpen ? theme.spacing(2) : 'unset', |
||||
}), |
||||
dashboards: css({ |
||||
color: theme.colors.text.secondary, |
||||
marginRight: theme.spacing(2), |
||||
|
||||
'&:hover': css({ |
||||
color: theme.colors.text.primary, |
||||
}), |
||||
}), |
||||
drawerContainer: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
height: '100%', |
||||
}), |
||||
treeContainer: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
maxHeight: '100%', |
||||
overflowY: 'hidden', |
||||
// Fix for top level search outline overflow due to scrollbars
|
||||
paddingLeft: theme.spacing(0.5), |
||||
}), |
||||
buttonsContainer: css({ |
||||
display: 'flex', |
||||
gap: theme.spacing(1), |
||||
marginTop: theme.spacing(8), |
||||
}), |
||||
}; |
||||
}; |
@ -1,102 +0,0 @@ |
||||
import { Scope, ScopeDashboardBinding, ScopeNode, ScopeSpec } from '@grafana/data'; |
||||
import { getBackendSrv } from '@grafana/runtime'; |
||||
import { ScopedResourceClient } from 'app/features/apiserver/client'; |
||||
|
||||
import { getAPINamespace } from '../../../api/utils'; |
||||
|
||||
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types'; |
||||
import { getBasicScope, mergeScopes } from './utils'; |
||||
|
||||
const group = 'scope.grafana.app'; |
||||
const version = 'v0alpha1'; |
||||
const namespace = getAPINamespace(); |
||||
|
||||
const nodesEndpoint = `/apis/${group}/${version}/namespaces/${namespace}/find/scope_node_children`; |
||||
const dashboardsEndpoint = `/apis/${group}/${version}/namespaces/${namespace}/find/scope_dashboard_bindings`; |
||||
|
||||
const scopesClient = new ScopedResourceClient<ScopeSpec, unknown, 'Scope'>({ |
||||
group, |
||||
version, |
||||
resource: 'scopes', |
||||
}); |
||||
|
||||
const scopesCache = new Map<string, Promise<Scope>>(); |
||||
|
||||
async function fetchScopeNodes(parent: string, query: string): Promise<ScopeNode[]> { |
||||
try { |
||||
return (await getBackendSrv().get<{ items: ScopeNode[] }>(nodesEndpoint, { parent, query }))?.items ?? []; |
||||
} catch (err) { |
||||
return []; |
||||
} |
||||
} |
||||
|
||||
export async function fetchNodes(parent: string, query: string): Promise<NodesMap> { |
||||
return (await fetchScopeNodes(parent, query)).reduce<NodesMap>((acc, { metadata: { name }, spec }) => { |
||||
acc[name] = { |
||||
name, |
||||
...spec, |
||||
isExpandable: spec.nodeType === 'container', |
||||
isSelectable: spec.linkType === 'scope', |
||||
isExpanded: false, |
||||
query: '', |
||||
reason: NodeReason.Result, |
||||
nodes: {}, |
||||
}; |
||||
return acc; |
||||
}, {}); |
||||
} |
||||
|
||||
export async function fetchScope(name: string): Promise<Scope> { |
||||
if (scopesCache.has(name)) { |
||||
return scopesCache.get(name)!; |
||||
} |
||||
|
||||
const response = new Promise<Scope>(async (resolve) => { |
||||
const basicScope = getBasicScope(name); |
||||
|
||||
try { |
||||
const serverScope = await scopesClient.get(name); |
||||
|
||||
const scope = mergeScopes(basicScope, serverScope); |
||||
|
||||
resolve(scope); |
||||
} catch (err) { |
||||
scopesCache.delete(name); |
||||
|
||||
resolve(basicScope); |
||||
} |
||||
}); |
||||
|
||||
scopesCache.set(name, response); |
||||
|
||||
return response; |
||||
} |
||||
|
||||
export async function fetchScopes(names: string[]): Promise<Scope[]> { |
||||
return await Promise.all(names.map(fetchScope)); |
||||
} |
||||
|
||||
export async function fetchSelectedScopes(treeScopes: TreeScope[]): Promise<SelectedScope[]> { |
||||
const scopes = await fetchScopes(treeScopes.map(({ scopeName }) => scopeName)); |
||||
|
||||
return scopes.reduce<SelectedScope[]>((acc, scope, idx) => { |
||||
acc.push({ |
||||
scope, |
||||
path: treeScopes[idx].path, |
||||
}); |
||||
|
||||
return acc; |
||||
}, []); |
||||
} |
||||
|
||||
export async function fetchDashboards(scopeNames: string[]): Promise<ScopeDashboardBinding[]> { |
||||
try { |
||||
const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(dashboardsEndpoint, { |
||||
scope: scopeNames, |
||||
}); |
||||
|
||||
return response?.items ?? []; |
||||
} catch (err) { |
||||
return []; |
||||
} |
||||
} |
@ -1,48 +0,0 @@ |
||||
import { Scope, ScopeDashboardBinding, ScopeNodeSpec } from '@grafana/data'; |
||||
|
||||
export enum NodeReason { |
||||
Persisted, |
||||
Result, |
||||
} |
||||
|
||||
export interface Node extends ScopeNodeSpec { |
||||
name: string; |
||||
reason: NodeReason; |
||||
isExpandable: boolean; |
||||
isSelectable: boolean; |
||||
isExpanded: boolean; |
||||
query: string; |
||||
nodes: NodesMap; |
||||
} |
||||
|
||||
export type NodesMap = Record<string, Node>; |
||||
|
||||
export interface SelectedScope { |
||||
scope: Scope; |
||||
path: string[]; |
||||
} |
||||
|
||||
export interface TreeScope { |
||||
scopeName: string; |
||||
path: string[]; |
||||
} |
||||
|
||||
export interface SuggestedDashboard { |
||||
dashboard: string; |
||||
dashboardTitle: string; |
||||
items: ScopeDashboardBinding[]; |
||||
} |
||||
|
||||
export interface SuggestedDashboardsFolder { |
||||
title: string; |
||||
isExpanded: boolean; |
||||
folders: SuggestedDashboardsFoldersMap; |
||||
dashboards: SuggestedDashboardsMap; |
||||
} |
||||
|
||||
export type SuggestedDashboardsMap = Record<string, SuggestedDashboard>; |
||||
export type SuggestedDashboardsFoldersMap = Record<string, SuggestedDashboardsFolder>; |
||||
|
||||
export type OnNodeUpdate = (path: string[], isExpanded: boolean, query: string) => void; |
||||
export type OnNodeSelectToggle = (path: string[]) => void; |
||||
export type OnFolderUpdate = (path: string[], isExpanded: boolean) => void; |
@ -1,177 +0,0 @@ |
||||
import { Scope, ScopeDashboardBinding } from '@grafana/data'; |
||||
|
||||
import { NodesMap, SelectedScope, SuggestedDashboardsFoldersMap, TreeScope } from './types'; |
||||
|
||||
export function getBasicScope(name: string): Scope { |
||||
return { |
||||
metadata: { name }, |
||||
spec: { |
||||
filters: [], |
||||
title: name, |
||||
type: '', |
||||
category: '', |
||||
description: '', |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
export function mergeScopes(scope1: Scope, scope2: Scope): Scope { |
||||
return { |
||||
...scope1, |
||||
metadata: { |
||||
...scope1.metadata, |
||||
...scope2.metadata, |
||||
}, |
||||
spec: { |
||||
...scope1.spec, |
||||
...scope2.spec, |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
export function getTreeScopesFromSelectedScopes(scopes: SelectedScope[]): TreeScope[] { |
||||
return scopes.map(({ scope, path }) => ({ |
||||
scopeName: scope.metadata.name, |
||||
path, |
||||
})); |
||||
} |
||||
|
||||
export function getScopesFromSelectedScopes(scopes: SelectedScope[]): Scope[] { |
||||
return scopes.map(({ scope }) => scope); |
||||
} |
||||
|
||||
export function getScopeNamesFromSelectedScopes(scopes: SelectedScope[]): string[] { |
||||
return scopes.map(({ scope }) => scope.metadata.name); |
||||
} |
||||
|
||||
// helper func to get the selected/tree scopes together with their paths
|
||||
// needed to maintain selected scopes in tree for example when navigating
|
||||
// between categories or when loading scopes from URL to find the scope's path
|
||||
export function getScopesAndTreeScopesWithPaths( |
||||
selectedScopes: SelectedScope[], |
||||
treeScopes: TreeScope[], |
||||
path: string[], |
||||
childNodes: NodesMap |
||||
): [SelectedScope[], TreeScope[]] { |
||||
const childNodesArr = Object.values(childNodes); |
||||
|
||||
// Get all scopes without paths
|
||||
// We use tree scopes as the list is always up to date as opposed to selected scopes which can be outdated
|
||||
const scopeNamesWithoutPaths = treeScopes.filter(({ path }) => path.length === 0).map(({ scopeName }) => scopeName); |
||||
|
||||
// We search for the path of each scope name without a path
|
||||
const scopeNamesWithPaths = scopeNamesWithoutPaths.reduce<Record<string, string[]>>((acc, scopeName) => { |
||||
const possibleParent = childNodesArr.find((childNode) => childNode.isSelectable && childNode.linkId === scopeName); |
||||
|
||||
if (possibleParent) { |
||||
acc[scopeName] = [...path, possibleParent.name]; |
||||
} |
||||
|
||||
return acc; |
||||
}, {}); |
||||
|
||||
// Update the paths of the selected scopes based on what we found
|
||||
const newSelectedScopes = selectedScopes.map((selectedScope) => { |
||||
if (selectedScope.path.length > 0) { |
||||
return selectedScope; |
||||
} |
||||
|
||||
return { |
||||
...selectedScope, |
||||
path: scopeNamesWithPaths[selectedScope.scope.metadata.name] ?? [], |
||||
}; |
||||
}); |
||||
|
||||
// Update the paths of the tree scopes based on what we found
|
||||
const newTreeScopes = treeScopes.map((treeScope) => { |
||||
if (treeScope.path.length > 0) { |
||||
return treeScope; |
||||
} |
||||
|
||||
return { |
||||
...treeScope, |
||||
path: scopeNamesWithPaths[treeScope.scopeName] ?? [], |
||||
}; |
||||
}); |
||||
|
||||
return [newSelectedScopes, newTreeScopes]; |
||||
} |
||||
|
||||
export function groupDashboards(dashboards: ScopeDashboardBinding[]): SuggestedDashboardsFoldersMap { |
||||
return dashboards.reduce<SuggestedDashboardsFoldersMap>( |
||||
(acc, dashboard) => { |
||||
const rootNode = acc['']; |
||||
const groups = dashboard.status.groups ?? []; |
||||
|
||||
groups.forEach((group) => { |
||||
if (group && !rootNode.folders[group]) { |
||||
rootNode.folders[group] = { |
||||
title: group, |
||||
isExpanded: false, |
||||
folders: {}, |
||||
dashboards: {}, |
||||
}; |
||||
} |
||||
}); |
||||
|
||||
const targets = |
||||
groups.length > 0 |
||||
? groups.map((group) => (group === '' ? rootNode.dashboards : rootNode.folders[group].dashboards)) |
||||
: [rootNode.dashboards]; |
||||
|
||||
targets.forEach((target) => { |
||||
if (!target[dashboard.spec.dashboard]) { |
||||
target[dashboard.spec.dashboard] = { |
||||
dashboard: dashboard.spec.dashboard, |
||||
dashboardTitle: dashboard.status.dashboardTitle, |
||||
items: [], |
||||
}; |
||||
} |
||||
|
||||
target[dashboard.spec.dashboard].items.push(dashboard); |
||||
}); |
||||
|
||||
return acc; |
||||
}, |
||||
{ |
||||
'': { |
||||
title: '', |
||||
isExpanded: true, |
||||
folders: {}, |
||||
dashboards: {}, |
||||
}, |
||||
} |
||||
); |
||||
} |
||||
|
||||
export function filterFolders(folders: SuggestedDashboardsFoldersMap, query: string): SuggestedDashboardsFoldersMap { |
||||
query = (query ?? '').toLowerCase(); |
||||
|
||||
return Object.entries(folders).reduce<SuggestedDashboardsFoldersMap>((acc, [folderId, folder]) => { |
||||
// If folder matches the query, we show everything inside
|
||||
if (folder.title.toLowerCase().includes(query)) { |
||||
acc[folderId] = { |
||||
...folder, |
||||
isExpanded: true, |
||||
}; |
||||
|
||||
return acc; |
||||
} |
||||
|
||||
const filteredFolders = filterFolders(folder.folders, query); |
||||
const filteredDashboards = Object.entries(folder.dashboards).filter(([_, dashboard]) => |
||||
dashboard.dashboardTitle.toLowerCase().includes(query) |
||||
); |
||||
|
||||
if (Object.keys(filteredFolders).length > 0 || filteredDashboards.length > 0) { |
||||
acc[folderId] = { |
||||
...folder, |
||||
isExpanded: true, |
||||
folders: filteredFolders, |
||||
dashboards: Object.fromEntries(filteredDashboards), |
||||
}; |
||||
} |
||||
|
||||
return acc; |
||||
}, {}); |
||||
} |
@ -0,0 +1,129 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { useObservable } from 'react-use'; |
||||
import { Observable } from 'rxjs'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useScopes } from '@grafana/runtime'; |
||||
import { Button, Drawer, IconButton, Spinner, useStyles2 } from '@grafana/ui'; |
||||
import { useGrafana } from 'app/core/context/GrafanaContext'; |
||||
import { t, Trans } from 'app/core/internationalization'; |
||||
|
||||
import { ScopesInput } from './ScopesInput'; |
||||
import { ScopesSelectorService } from './ScopesSelectorService'; |
||||
import { ScopesTree } from './ScopesTree'; |
||||
|
||||
export const ScopesSelector = () => { |
||||
const { chrome } = useGrafana(); |
||||
const chromeState = chrome.useState(); |
||||
const menuDockedAndOpen = !chromeState.chromeless && chromeState.megaMenuDocked && chromeState.megaMenuOpen; |
||||
const styles = useStyles2(getStyles, menuDockedAndOpen); |
||||
const scopes = useScopes(); |
||||
|
||||
const scopesSelectorService = ScopesSelectorService.instance; |
||||
|
||||
useObservable(scopesSelectorService?.stateObservable ?? new Observable(), scopesSelectorService?.state); |
||||
|
||||
if (!scopes || !scopesSelectorService || !scopes.state.enabled) { |
||||
return null; |
||||
} |
||||
|
||||
const { readOnly, drawerOpened, loading } = scopes.state; |
||||
const { nodes, selectedScopes, opened, loadingNodeName, treeScopes } = scopesSelectorService.state; |
||||
const { toggleDrawer, 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') |
||||
: drawerOpened |
||||
? t('scopes.dashboards.toggle.collapse', 'Collapse suggested dashboards list') |
||||
: t('scopes.dashboards.toggle..expand', 'Expand suggested dashboards list'); |
||||
|
||||
return ( |
||||
<div className={styles.container}> |
||||
<IconButton |
||||
name="web-section-alt" |
||||
className={styles.dashboards} |
||||
aria-label={dashboardsIconLabel} |
||||
tooltip={dashboardsIconLabel} |
||||
data-testid="scopes-dashboards-expand" |
||||
disabled={readOnly} |
||||
onClick={toggleDrawer} |
||||
/> |
||||
|
||||
<ScopesInput |
||||
nodes={nodes} |
||||
scopes={selectedScopes} |
||||
disabled={readOnly} |
||||
loading={loading} |
||||
onInputClick={open} |
||||
onRemoveAllClick={removeAllScopes} |
||||
/> |
||||
|
||||
{opened && ( |
||||
<Drawer title={t('scopes.selector.title', 'Select scopes')} size="sm" onClose={closeAndReset}> |
||||
<div className={styles.drawerContainer}> |
||||
<div className={styles.treeContainer}> |
||||
{loading ? ( |
||||
<Spinner data-testid="scopes-selector-loading" /> |
||||
) : ( |
||||
<ScopesTree |
||||
nodes={nodes} |
||||
nodePath={['']} |
||||
loadingNodeName={loadingNodeName} |
||||
scopes={treeScopes} |
||||
onNodeUpdate={updateNode} |
||||
onNodeSelectToggle={toggleNodeSelect} |
||||
/> |
||||
)} |
||||
</div> |
||||
|
||||
<div className={styles.buttonsContainer}> |
||||
<Button variant="primary" data-testid="scopes-selector-apply" onClick={closeAndApply}> |
||||
<Trans i18nKey="scopes.selector.apply">Apply</Trans> |
||||
</Button> |
||||
<Button variant="secondary" data-testid="scopes-selector-cancel" onClick={closeAndReset}> |
||||
<Trans i18nKey="scopes.selector.cancel">Cancel</Trans> |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
</Drawer> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2, menuDockedAndOpen: boolean) => { |
||||
return { |
||||
container: css({ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
paddingLeft: menuDockedAndOpen ? theme.spacing(2) : 'unset', |
||||
}), |
||||
dashboards: css({ |
||||
color: theme.colors.text.secondary, |
||||
marginRight: theme.spacing(2), |
||||
|
||||
'&:hover': css({ |
||||
color: theme.colors.text.primary, |
||||
}), |
||||
}), |
||||
drawerContainer: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
height: '100%', |
||||
}), |
||||
treeContainer: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
maxHeight: '100%', |
||||
overflowY: 'hidden', |
||||
// Fix for top level search outline overflow due to scrollbars
|
||||
paddingLeft: theme.spacing(0.5), |
||||
}), |
||||
buttonsContainer: css({ |
||||
display: 'flex', |
||||
gap: theme.spacing(1), |
||||
marginTop: theme.spacing(8), |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,392 @@ |
||||
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 { ScopesServiceBase } from '../ScopesServiceBase'; |
||||
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService'; |
||||
|
||||
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types'; |
||||
|
||||
interface ScopesSelectorServiceState { |
||||
opened: boolean; |
||||
loadingNodeName: string | undefined; |
||||
nodes: NodesMap; |
||||
selectedScopes: SelectedScope[]; |
||||
treeScopes: TreeScope[]; |
||||
} |
||||
|
||||
export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServiceState> { |
||||
static #instance: ScopesSelectorService | undefined = undefined; |
||||
|
||||
private _scopesCache = new Map<string, Promise<Scope>>(); |
||||
|
||||
private constructor() { |
||||
super({ |
||||
opened: false, |
||||
loadingNodeName: undefined, |
||||
nodes: { |
||||
'': { |
||||
name: '', |
||||
reason: NodeReason.Result, |
||||
nodeType: 'container', |
||||
title: '', |
||||
expandable: true, |
||||
selectable: false, |
||||
expanded: true, |
||||
query: '', |
||||
nodes: {}, |
||||
}, |
||||
}, |
||||
selectedScopes: [], |
||||
treeScopes: [], |
||||
}); |
||||
} |
||||
|
||||
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; |
||||
|
||||
for (let idx = 0; idx < path.length - 1; idx++) { |
||||
currentLevel = currentLevel[path[idx]].nodes; |
||||
} |
||||
|
||||
const loadingNodeName = path[path.length - 1]; |
||||
const currentNode = currentLevel[loadingNodeName]; |
||||
|
||||
const differentQuery = currentNode.query !== query; |
||||
|
||||
currentNode.expanded = expanded; |
||||
currentNode.query = query; |
||||
|
||||
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 |
||||
); |
||||
|
||||
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; |
||||
}, {}); |
||||
|
||||
currentNode.nodes = { ...persistedNodes, ...childNodes }; |
||||
|
||||
this.updateState({ nodes, selectedScopes, treeScopes }); |
||||
|
||||
this._fetchSub?.unsubscribe(); |
||||
}); |
||||
} else { |
||||
this.updateState({ nodes, loadingNodeName: undefined }); |
||||
} |
||||
}; |
||||
|
||||
public toggleNodeSelect = (path: string[]) => { |
||||
let treeScopes = [...this.state.treeScopes]; |
||||
|
||||
let parentNode = this.state.nodes['']; |
||||
|
||||
for (let idx = 1; idx < path.length - 1; idx++) { |
||||
parentNode = parentNode.nodes[path[idx]]; |
||||
} |
||||
|
||||
const nodeName = path[path.length - 1]; |
||||
const { linkId } = parentNode.nodes[nodeName]; |
||||
|
||||
const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId); |
||||
|
||||
if (selectedIdx === -1) { |
||||
this.fetchScopeApi(linkId!); |
||||
|
||||
const selectedFromSameNode = |
||||
treeScopes.length === 0 || |
||||
Object.values(parentNode.nodes).some(({ linkId }) => linkId === treeScopes[0].scopeName); |
||||
|
||||
const treeScope = { |
||||
scopeName: linkId!, |
||||
path, |
||||
}; |
||||
|
||||
this.updateState({ |
||||
treeScopes: parentNode?.disableMultiSelect || !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope], |
||||
}); |
||||
} else { |
||||
treeScopes.splice(selectedIdx, 1); |
||||
|
||||
this.updateState({ treeScopes }); |
||||
} |
||||
}; |
||||
|
||||
public changeScopes = (scopeNames: string[]) => |
||||
this.setNewScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] }))); |
||||
|
||||
public 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), |
||||
path, |
||||
})); |
||||
this.updateState({ selectedScopes, treeScopes }); |
||||
ScopesService.instance?.setLoading(true); |
||||
ScopesDashboardsService.instance?.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); |
||||
}; |
||||
|
||||
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 }); |
||||
} |
||||
}; |
||||
|
||||
public closeAndReset = () => { |
||||
this.updateState({ opened: false, treeScopes: this.getTreeScopesFromSelectedScopes(this.state.selectedScopes) }); |
||||
}; |
||||
|
||||
public closeAndApply = () => { |
||||
this.updateState({ opened: false }); |
||||
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] = { |
||||
...node, |
||||
expanded: false, |
||||
nodes: this.closeNodes(node.nodes), |
||||
}; |
||||
|
||||
return acc; |
||||
}, {}); |
||||
}; |
||||
|
||||
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, |
||||
path, |
||||
})); |
||||
}; |
||||
|
||||
// helper func to get the selected/tree scopes together with their paths
|
||||
// needed to maintain selected scopes in tree for example when navigating
|
||||
// between categories or when loading scopes from URL to find the scope's path
|
||||
private getScopesAndTreeScopesWithPaths = ( |
||||
selectedScopes: SelectedScope[], |
||||
treeScopes: TreeScope[], |
||||
path: string[], |
||||
childNodes: NodesMap |
||||
): [SelectedScope[], TreeScope[]] => { |
||||
const childNodesArr = Object.values(childNodes); |
||||
|
||||
// Get all scopes without paths
|
||||
// We use tree scopes as the list is always up to date as opposed to selected scopes which can be outdated
|
||||
const scopeNamesWithoutPaths = treeScopes.filter(({ path }) => path.length === 0).map(({ scopeName }) => scopeName); |
||||
|
||||
// We search for the path of each scope name without a path
|
||||
const scopeNamesWithPaths = scopeNamesWithoutPaths.reduce<Record<string, string[]>>((acc, scopeName) => { |
||||
const possibleParent = childNodesArr.find((childNode) => childNode.selectable && childNode.linkId === scopeName); |
||||
|
||||
if (possibleParent) { |
||||
acc[scopeName] = [...path, possibleParent.name]; |
||||
} |
||||
|
||||
return acc; |
||||
}, {}); |
||||
|
||||
// Update the paths of the selected scopes based on what we found
|
||||
const newSelectedScopes = selectedScopes.map((selectedScope) => { |
||||
if (selectedScope.path.length > 0) { |
||||
return selectedScope; |
||||
} |
||||
|
||||
return { |
||||
...selectedScope, |
||||
path: scopeNamesWithPaths[selectedScope.scope.metadata.name] ?? [], |
||||
}; |
||||
}); |
||||
|
||||
// Update the paths of the tree scopes based on what we found
|
||||
const newTreeScopes = treeScopes.map((treeScope) => { |
||||
if (treeScope.path.length > 0) { |
||||
return treeScope; |
||||
} |
||||
|
||||
return { |
||||
...treeScope, |
||||
path: scopeNamesWithPaths[treeScope.scopeName] ?? [], |
||||
}; |
||||
}); |
||||
|
||||
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 ?? []; |
||||
|
||||
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 fetchScopeApi = async (name: string): Promise<Scope> => { |
||||
if (this._scopesCache.has(name)) { |
||||
return this._scopesCache.get(name)!; |
||||
} |
||||
|
||||
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; |
||||
}; |
||||
} |
@ -0,0 +1,31 @@ |
||||
import { Scope, ScopeNodeSpec } from '@grafana/data'; |
||||
|
||||
export enum NodeReason { |
||||
Persisted, |
||||
Result, |
||||
} |
||||
|
||||
export interface Node extends ScopeNodeSpec { |
||||
name: string; |
||||
reason: NodeReason; |
||||
expandable: boolean; |
||||
selectable: boolean; |
||||
expanded: boolean; |
||||
query: string; |
||||
nodes: NodesMap; |
||||
} |
||||
|
||||
export type NodesMap = Record<string, Node>; |
||||
|
||||
export interface SelectedScope { |
||||
scope: Scope; |
||||
path: string[]; |
||||
} |
||||
|
||||
export interface TreeScope { |
||||
scopeName: string; |
||||
path: string[]; |
||||
} |
||||
|
||||
export type OnNodeUpdate = (path: string[], expanded: boolean, query: string) => void; |
||||
export type OnNodeSelectToggle = (path: string[]) => void; |
@ -1,27 +0,0 @@ |
||||
import { config } from '@grafana/runtime'; |
||||
|
||||
import { scopesSelectorScene } from '../instance'; |
||||
|
||||
import { getDatasource, getInstanceSettings, getMock } from './utils/mocks'; |
||||
import { renderDashboard } from './utils/render'; |
||||
|
||||
jest.mock('@grafana/runtime', () => ({ |
||||
__esModule: true, |
||||
...jest.requireActual('@grafana/runtime'), |
||||
useChromeHeaderHeight: jest.fn(), |
||||
getBackendSrv: () => ({ get: getMock }), |
||||
getDataSourceSrv: () => ({ get: getDatasource, getInstanceSettings }), |
||||
usePluginLinks: jest.fn().mockReturnValue({ links: [] }), |
||||
})); |
||||
|
||||
describe('Feature flag off', () => { |
||||
beforeAll(() => { |
||||
config.featureToggles.scopeFilters = false; |
||||
config.featureToggles.groupByVariable = true; |
||||
}); |
||||
|
||||
it('Does not initialize', () => { |
||||
renderDashboard(); |
||||
expect(scopesSelectorScene).toBeNull(); |
||||
}); |
||||
}); |
@ -1,364 +0,0 @@ |
||||
import { filterFolders, groupDashboards } from '../internal/utils'; |
||||
|
||||
import { |
||||
alternativeDashboardWithRootFolder, |
||||
alternativeDashboardWithTwoFolders, |
||||
dashboardWithOneFolder, |
||||
dashboardWithoutFolder, |
||||
dashboardWithRootFolder, |
||||
dashboardWithRootFolderAndOtherFolder, |
||||
dashboardWithTwoFolders, |
||||
} from './utils/mocks'; |
||||
|
||||
describe('Utils', () => { |
||||
describe('groupDashboards', () => { |
||||
it('Assigns dashboards without groups to root folder', () => { |
||||
expect(groupDashboards([dashboardWithoutFolder])).toEqual({ |
||||
'': { |
||||
title: '', |
||||
isExpanded: true, |
||||
folders: {}, |
||||
dashboards: { |
||||
[dashboardWithoutFolder.spec.dashboard]: { |
||||
dashboard: dashboardWithoutFolder.spec.dashboard, |
||||
dashboardTitle: dashboardWithoutFolder.status.dashboardTitle, |
||||
items: [dashboardWithoutFolder], |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('Assigns dashboards with root group to root folder', () => { |
||||
expect(groupDashboards([dashboardWithRootFolder])).toEqual({ |
||||
'': { |
||||
title: '', |
||||
isExpanded: true, |
||||
folders: {}, |
||||
dashboards: { |
||||
[dashboardWithRootFolder.spec.dashboard]: { |
||||
dashboard: dashboardWithRootFolder.spec.dashboard, |
||||
dashboardTitle: dashboardWithRootFolder.status.dashboardTitle, |
||||
items: [dashboardWithRootFolder], |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('Merges folders from multiple dashboards', () => { |
||||
expect(groupDashboards([dashboardWithOneFolder, dashboardWithTwoFolders])).toEqual({ |
||||
'': { |
||||
title: '', |
||||
isExpanded: true, |
||||
folders: { |
||||
'Folder 1': { |
||||
title: 'Folder 1', |
||||
isExpanded: false, |
||||
folders: {}, |
||||
dashboards: { |
||||
[dashboardWithOneFolder.spec.dashboard]: { |
||||
dashboard: dashboardWithOneFolder.spec.dashboard, |
||||
dashboardTitle: dashboardWithOneFolder.status.dashboardTitle, |
||||
items: [dashboardWithOneFolder], |
||||
}, |
||||
[dashboardWithTwoFolders.spec.dashboard]: { |
||||
dashboard: dashboardWithTwoFolders.spec.dashboard, |
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle, |
||||
items: [dashboardWithTwoFolders], |
||||
}, |
||||
}, |
||||
}, |
||||
'Folder 2': { |
||||
title: 'Folder 2', |
||||
isExpanded: false, |
||||
folders: {}, |
||||
dashboards: { |
||||
[dashboardWithTwoFolders.spec.dashboard]: { |
||||
dashboard: dashboardWithTwoFolders.spec.dashboard, |
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle, |
||||
items: [dashboardWithTwoFolders], |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
dashboards: {}, |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('Merges scopes from multiple dashboards', () => { |
||||
expect(groupDashboards([dashboardWithTwoFolders, alternativeDashboardWithTwoFolders])).toEqual({ |
||||
'': { |
||||
title: '', |
||||
isExpanded: true, |
||||
folders: { |
||||
'Folder 1': { |
||||
title: 'Folder 1', |
||||
isExpanded: false, |
||||
folders: {}, |
||||
dashboards: { |
||||
[dashboardWithTwoFolders.spec.dashboard]: { |
||||
dashboard: dashboardWithTwoFolders.spec.dashboard, |
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle, |
||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders], |
||||
}, |
||||
}, |
||||
}, |
||||
'Folder 2': { |
||||
title: 'Folder 2', |
||||
isExpanded: false, |
||||
folders: {}, |
||||
dashboards: { |
||||
[dashboardWithTwoFolders.spec.dashboard]: { |
||||
dashboard: dashboardWithTwoFolders.spec.dashboard, |
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle, |
||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders], |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
dashboards: {}, |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('Matches snapshot', () => { |
||||
expect( |
||||
groupDashboards([ |
||||
dashboardWithoutFolder, |
||||
dashboardWithOneFolder, |
||||
dashboardWithTwoFolders, |
||||
alternativeDashboardWithTwoFolders, |
||||
dashboardWithRootFolder, |
||||
alternativeDashboardWithRootFolder, |
||||
dashboardWithRootFolderAndOtherFolder, |
||||
]) |
||||
).toEqual({ |
||||
'': { |
||||
dashboards: { |
||||
[dashboardWithRootFolderAndOtherFolder.spec.dashboard]: { |
||||
dashboard: dashboardWithRootFolderAndOtherFolder.spec.dashboard, |
||||
dashboardTitle: dashboardWithRootFolderAndOtherFolder.status.dashboardTitle, |
||||
items: [dashboardWithRootFolderAndOtherFolder], |
||||
}, |
||||
[dashboardWithRootFolder.spec.dashboard]: { |
||||
dashboard: dashboardWithRootFolder.spec.dashboard, |
||||
dashboardTitle: dashboardWithRootFolder.status.dashboardTitle, |
||||
items: [dashboardWithRootFolder, alternativeDashboardWithRootFolder], |
||||
}, |
||||
[dashboardWithoutFolder.spec.dashboard]: { |
||||
dashboard: dashboardWithoutFolder.spec.dashboard, |
||||
dashboardTitle: dashboardWithoutFolder.status.dashboardTitle, |
||||
items: [dashboardWithoutFolder], |
||||
}, |
||||
}, |
||||
folders: { |
||||
'Folder 1': { |
||||
dashboards: { |
||||
[dashboardWithOneFolder.spec.dashboard]: { |
||||
dashboard: dashboardWithOneFolder.spec.dashboard, |
||||
dashboardTitle: dashboardWithOneFolder.status.dashboardTitle, |
||||
items: [dashboardWithOneFolder], |
||||
}, |
||||
[dashboardWithTwoFolders.spec.dashboard]: { |
||||
dashboard: dashboardWithTwoFolders.spec.dashboard, |
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle, |
||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders], |
||||
}, |
||||
}, |
||||
folders: {}, |
||||
isExpanded: false, |
||||
title: 'Folder 1', |
||||
}, |
||||
'Folder 2': { |
||||
dashboards: { |
||||
[dashboardWithTwoFolders.spec.dashboard]: { |
||||
dashboard: dashboardWithTwoFolders.spec.dashboard, |
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle, |
||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders], |
||||
}, |
||||
}, |
||||
folders: {}, |
||||
isExpanded: false, |
||||
title: 'Folder 2', |
||||
}, |
||||
'Folder 3': { |
||||
dashboards: { |
||||
[dashboardWithRootFolderAndOtherFolder.spec.dashboard]: { |
||||
dashboard: dashboardWithRootFolderAndOtherFolder.spec.dashboard, |
||||
dashboardTitle: dashboardWithRootFolderAndOtherFolder.status.dashboardTitle, |
||||
items: [dashboardWithRootFolderAndOtherFolder], |
||||
}, |
||||
}, |
||||
folders: {}, |
||||
isExpanded: false, |
||||
title: 'Folder 3', |
||||
}, |
||||
}, |
||||
isExpanded: true, |
||||
title: '', |
||||
}, |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('filterFolders', () => { |
||||
it('Shows folders matching criteria', () => { |
||||
expect( |
||||
filterFolders( |
||||
{ |
||||
'': { |
||||
title: '', |
||||
isExpanded: true, |
||||
folders: { |
||||
'Folder 1': { |
||||
title: 'Folder 1', |
||||
isExpanded: false, |
||||
folders: {}, |
||||
dashboards: { |
||||
'Dashboard ID': { |
||||
dashboard: 'Dashboard ID', |
||||
dashboardTitle: 'Dashboard Title', |
||||
items: [], |
||||
}, |
||||
}, |
||||
}, |
||||
'Folder 2': { |
||||
title: 'Folder 2', |
||||
isExpanded: true, |
||||
folders: {}, |
||||
dashboards: { |
||||
'Dashboard ID': { |
||||
dashboard: 'Dashboard ID', |
||||
dashboardTitle: 'Dashboard Title', |
||||
items: [], |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
dashboards: { |
||||
'Dashboard ID': { |
||||
dashboard: 'Dashboard ID', |
||||
dashboardTitle: 'Dashboard Title', |
||||
items: [], |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
'Folder' |
||||
) |
||||
).toEqual({ |
||||
'': { |
||||
title: '', |
||||
isExpanded: true, |
||||
folders: { |
||||
'Folder 1': { |
||||
title: 'Folder 1', |
||||
isExpanded: true, |
||||
folders: {}, |
||||
dashboards: { |
||||
'Dashboard ID': { |
||||
dashboard: 'Dashboard ID', |
||||
dashboardTitle: 'Dashboard Title', |
||||
items: [], |
||||
}, |
||||
}, |
||||
}, |
||||
'Folder 2': { |
||||
title: 'Folder 2', |
||||
isExpanded: true, |
||||
folders: {}, |
||||
dashboards: { |
||||
'Dashboard ID': { |
||||
dashboard: 'Dashboard ID', |
||||
dashboardTitle: 'Dashboard Title', |
||||
items: [], |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
dashboards: {}, |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('Shows dashboards matching criteria', () => { |
||||
expect( |
||||
filterFolders( |
||||
{ |
||||
'': { |
||||
title: '', |
||||
isExpanded: true, |
||||
folders: { |
||||
'Folder 1': { |
||||
title: 'Folder 1', |
||||
isExpanded: false, |
||||
folders: {}, |
||||
dashboards: { |
||||
'Dashboard ID': { |
||||
dashboard: 'Dashboard ID', |
||||
dashboardTitle: 'Dashboard Title', |
||||
items: [], |
||||
}, |
||||
}, |
||||
}, |
||||
'Folder 2': { |
||||
title: 'Folder 2', |
||||
isExpanded: true, |
||||
folders: {}, |
||||
dashboards: { |
||||
'Random ID': { |
||||
dashboard: 'Random ID', |
||||
dashboardTitle: 'Random Title', |
||||
items: [], |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
dashboards: { |
||||
'Dashboard ID': { |
||||
dashboard: 'Dashboard ID', |
||||
dashboardTitle: 'Dashboard Title', |
||||
items: [], |
||||
}, |
||||
'Random ID': { |
||||
dashboard: 'Random ID', |
||||
dashboardTitle: 'Random Title', |
||||
items: [], |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
'dash' |
||||
) |
||||
).toEqual({ |
||||
'': { |
||||
title: '', |
||||
isExpanded: true, |
||||
folders: { |
||||
'Folder 1': { |
||||
title: 'Folder 1', |
||||
isExpanded: true, |
||||
folders: {}, |
||||
dashboards: { |
||||
'Dashboard ID': { |
||||
dashboard: 'Dashboard ID', |
||||
dashboardTitle: 'Dashboard Title', |
||||
items: [], |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
dashboards: { |
||||
'Dashboard ID': { |
||||
dashboard: 'Dashboard ID', |
||||
dashboardTitle: 'Dashboard Title', |
||||
items: [], |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
@ -1,5 +0,0 @@ |
||||
import { scopesDashboardsScene } from './instance'; |
||||
|
||||
export const useScopesDashboardsState = () => { |
||||
return scopesDashboardsScene?.useState(); |
||||
}; |
@ -1,39 +0,0 @@ |
||||
import { Scope } from '@grafana/data'; |
||||
import { sceneGraph, SceneObject } from '@grafana/scenes'; |
||||
|
||||
import { ScopesFacade } from './ScopesFacadeScene'; |
||||
import { scopesDashboardsScene, scopesSelectorScene } from './instance'; |
||||
import { getScopesFromSelectedScopes } from './internal/utils'; |
||||
|
||||
export function getSelectedScopes(): Scope[] { |
||||
return getScopesFromSelectedScopes(scopesSelectorScene?.state.scopes ?? []); |
||||
} |
||||
|
||||
export function getSelectedScopesNames(): string[] { |
||||
return getSelectedScopes().map((scope) => scope.metadata.name); |
||||
} |
||||
|
||||
export function enableScopes() { |
||||
scopesSelectorScene?.enable(); |
||||
scopesDashboardsScene?.enable(); |
||||
} |
||||
|
||||
export function disableScopes() { |
||||
scopesSelectorScene?.disable(); |
||||
scopesDashboardsScene?.disable(); |
||||
} |
||||
|
||||
export function exitScopesReadOnly() { |
||||
scopesSelectorScene?.exitReadOnly(); |
||||
scopesDashboardsScene?.exitReadOnly(); |
||||
} |
||||
|
||||
export function enterScopesReadOnly() { |
||||
scopesSelectorScene?.enterReadOnly(); |
||||
scopesDashboardsScene?.enterReadOnly(); |
||||
} |
||||
|
||||
export function getClosestScopesFacade(scene: SceneObject): ScopesFacade | null { |
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return sceneGraph.findObject(scene, (obj) => obj instanceof ScopesFacade) as ScopesFacade | null; |
||||
} |
Loading…
Reference in new issue