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
Bogdan Matei 4 months ago committed by GitHub
parent 210c886bb7
commit 312d80e0e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      .betterer.results
  2. 4
      package.json
  3. 3
      public/app/AppWrapper.tsx
  4. 3
      public/app/app.ts
  5. 12
      public/app/core/components/AppChrome/AppChrome.tsx
  6. 23
      public/app/core/components/AppChrome/TopBar/SingleTopBarActions.tsx
  7. 54
      public/app/features/dashboard-scene/scene/DashboardReloadBehavior.ts
  8. 41
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  9. 4
      public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx
  10. 2
      public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts
  11. 48
      public/app/features/dashboard-scene/scene/DashboardScopesFacade.ts
  12. 5
      public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
  13. 5
      public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts
  14. 5
      public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
  15. 13
      public/app/features/scopes/ScopesContextProvider.tsx
  16. 9
      public/app/features/scopes/ScopesDashboards.tsx
  17. 87
      public/app/features/scopes/ScopesFacadeScene.ts
  18. 9
      public/app/features/scopes/ScopesSelector.tsx
  19. 63
      public/app/features/scopes/ScopesService.ts
  20. 31
      public/app/features/scopes/ScopesServiceBase.ts
  21. 114
      public/app/features/scopes/dashboards/ScopesDashboards.tsx
  22. 217
      public/app/features/scopes/dashboards/ScopesDashboardsService.ts
  23. 0
      public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx
  24. 0
      public/app/features/scopes/dashboards/ScopesDashboardsTreeDashboardItem.tsx
  25. 10
      public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx
  26. 12
      public/app/features/scopes/dashboards/ScopesDashboardsTreeSearch.tsx
  27. 19
      public/app/features/scopes/dashboards/types.ts
  28. 15
      public/app/features/scopes/index.ts
  29. 17
      public/app/features/scopes/instance.tsx
  30. 275
      public/app/features/scopes/internal/ScopesDashboardsScene.tsx
  31. 410
      public/app/features/scopes/internal/ScopesSelectorScene.tsx
  32. 102
      public/app/features/scopes/internal/api.ts
  33. 48
      public/app/features/scopes/internal/types.ts
  34. 177
      public/app/features/scopes/internal/utils.ts
  35. 33
      public/app/features/scopes/selector/ScopesInput.tsx
  36. 129
      public/app/features/scopes/selector/ScopesSelector.tsx
  37. 392
      public/app/features/scopes/selector/ScopesSelectorService.ts
  38. 12
      public/app/features/scopes/selector/ScopesTree.tsx
  39. 0
      public/app/features/scopes/selector/ScopesTreeHeadline.tsx
  40. 30
      public/app/features/scopes/selector/ScopesTreeItem.tsx
  41. 6
      public/app/features/scopes/selector/ScopesTreeLoading.tsx
  42. 12
      public/app/features/scopes/selector/ScopesTreeSearch.tsx
  43. 31
      public/app/features/scopes/selector/types.ts
  44. 25
      public/app/features/scopes/tests/dashboardReload.test.ts
  45. 382
      public/app/features/scopes/tests/dashboardsList.test.ts
  46. 27
      public/app/features/scopes/tests/featureFlag.test.ts
  47. 48
      public/app/features/scopes/tests/selector.test.ts
  48. 43
      public/app/features/scopes/tests/tree.test.ts
  49. 364
      public/app/features/scopes/tests/utils.test.ts
  50. 29
      public/app/features/scopes/tests/utils/actions.ts
  51. 9
      public/app/features/scopes/tests/utils/assertions.ts
  52. 6
      public/app/features/scopes/tests/utils/mocks.ts
  53. 29
      public/app/features/scopes/tests/utils/render.tsx
  54. 10
      public/app/features/scopes/tests/utils/selectors.ts
  55. 18
      public/app/features/scopes/tests/viewMode.test.ts
  56. 5
      public/app/features/scopes/useScopesDashboardsState.ts
  57. 39
      public/app/features/scopes/utils.ts
  58. 7
      public/app/features/trails/DataTrail.tsx
  59. 103
      public/app/features/trails/DataTrailsApp.tsx
  60. 3
      public/app/features/trails/MetricSelect/MetricSelectScene.tsx
  61. 1
      public/app/features/trails/utils.test.ts
  62. 10
      public/app/features/trails/utils.ts
  63. 58
      yarn.lock

@ -5676,10 +5676,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/scopes/index.ts:5381": [
[0, 0, 0, "Do not re-export imported variable (\`./ScopesDashboards\`)", "0"],
[0, 0, 0, "Do not re-export imported variable (\`./instance\`)", "1"]
],
"public/app/features/search/page/components/ActionRow.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],

@ -277,8 +277,8 @@
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/saga-icons": "workspace:*",
"@grafana/scenes": "6.2.1",
"@grafana/scenes-react": "6.2.1",
"@grafana/scenes": "6.3.1",
"@grafana/scenes-react": "6.3.1",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",

@ -24,6 +24,7 @@ import { ThemeProvider } from './core/utils/ConfigProvider';
import { LiveConnectionWarning } from './features/live/LiveConnectionWarning';
import { ExtensionRegistriesProvider } from './features/plugins/extensions/ExtensionRegistriesContext';
import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
import { ScopesContextProvider } from './features/scopes/ScopesContextProvider';
import { ExperimentalSplitPaneRouterWrapper, RouterWrapper } from './routes/RoutesWrapper';
interface AppWrapperProps {
@ -123,6 +124,7 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
<GlobalStyles />
<MaybeTimeRangeProvider>
<SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}>
<ScopesContextProvider>
<ExtensionRegistriesProvider registries={pluginExtensionRegistries}>
<div className="grafana-app">
{config.featureToggles.appSidecar ? (
@ -134,6 +136,7 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
<PortalContainer />
</div>
</ExtensionRegistriesProvider>
</ScopesContextProvider>
</SidecarContext_EXPERIMENTAL.Provider>
</MaybeTimeRangeProvider>
</KBarProvider>

@ -91,7 +91,6 @@ import { preloadPlugins } from './features/plugins/pluginPreloader';
import { QueryRunner } from './features/query/state/QueryRunner';
import { runRequest } from './features/query/state/runRequest';
import { initWindowRuntime } from './features/runtime/init';
import { initializeScopes } from './features/scopes';
import { cleanupOldExpandedFolders } from './features/search/utils';
import { variableAdapters } from './features/variables/adapters';
import { createAdHocVariableAdapter } from './features/variables/adhoc/adapter';
@ -255,8 +254,6 @@ export class GrafanaApp {
setReturnToPreviousHook(useReturnToPreviousInternal);
setChromeHeaderHeightHook(useChromeHeaderHeight);
initializeScopes();
if (config.featureToggles.crashDetection) {
initializeCrashDetection();
}

@ -3,14 +3,14 @@ import classNames from 'classnames';
import { PropsWithChildren, useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { locationSearchToObject, locationService } from '@grafana/runtime';
import { locationSearchToObject, locationService, useScopes } from '@grafana/runtime';
import { useStyles2, LinkButton, useTheme2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
import { Trans } from 'app/core/internationalization';
import store from 'app/core/store';
import { CommandPalette } from 'app/features/commandPalette/CommandPalette';
import { ScopesDashboards, useScopesDashboardsState } from 'app/features/scopes';
import { ScopesDashboards } from 'app/features/scopes/dashboards/ScopesDashboards';
import { AppChromeMenu } from './AppChromeMenu';
import { DOCKED_LOCAL_STORAGE_KEY, DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY } from './AppChromeService';
@ -27,14 +27,14 @@ export function AppChrome({ children }: Props) {
const { chrome } = useGrafana();
const state = chrome.useState();
const theme = useTheme2();
const styles = useStyles2(getStyles, Boolean(state.actions));
const scopes = useScopes();
const styles = useStyles2(getStyles, Boolean(state.actions) || !!scopes?.state.enabled);
const dockedMenuBreakpoint = theme.breakpoints.values.xl;
const dockedMenuLocalStorageState = store.getBool(DOCKED_LOCAL_STORAGE_KEY, true);
const menuDockedAndOpen = !state.chromeless && state.megaMenuDocked && state.megaMenuOpen;
const scopesDashboardsState = useScopesDashboardsState();
const isScopesDashboardsOpen = Boolean(
scopesDashboardsState?.isEnabled && scopesDashboardsState?.isPanelOpened && !scopesDashboardsState?.isReadOnly
scopes?.state.enabled && scopes?.state.drawerOpened && !scopes?.state.readOnly
);
useMediaQueryChange({
breakpoint: dockedMenuBreakpoint,
@ -101,7 +101,7 @@ export function AppChrome({ children }: Props) {
onToggleMegaMenu={handleMegaMenu}
onToggleKioskMode={chrome.onToggleKioskMode}
/>
{state.actions && <SingleTopBarActions>{state.actions}</SingleTopBarActions>}
{(state.actions || scopes?.state.enabled) && <SingleTopBarActions>{state.actions}</SingleTopBarActions>}
</header>
</>
)}

@ -3,18 +3,39 @@ import { PropsWithChildren } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Components } from '@grafana/e2e-selectors';
import { useScopes } from '@grafana/runtime';
import { Stack, useStyles2 } from '@grafana/ui';
import { ScopesSelector } from 'app/features/scopes/selector/ScopesSelector';
import { TOP_BAR_LEVEL_HEIGHT } from '../types';
export function SingleTopBarActions({ children }: PropsWithChildren) {
const styles = useStyles2(getStyles);
const scopes = useScopes();
const scopesRender = scopes?.state.enabled ? <ScopesSelector /> : undefined;
const childrenRender = children ? (
<Stack
alignItems="center"
justifyContent={scopes?.state.enabled ? 'space-between' : 'flex-end'}
flex={1}
wrap="nowrap"
minWidth={0}
>
{children}
</Stack>
) : undefined;
return (
<div data-testid={Components.NavToolbar.container} className={styles.actionsBar}>
<Stack alignItems="center" justifyContent="flex-end" flex={1} wrap="nowrap" minWidth={0}>
{scopesRender ? (
<Stack alignItems="center" justifyContent="flex-start" flex={1} wrap="nowrap" minWidth={0}>
{scopesRender}
{children}
</Stack>
) : (
childrenRender
)}
</div>
);
}

@ -1,11 +1,19 @@
import { debounce, isEqual } from 'lodash';
import { UrlQueryMap } from '@grafana/data';
import { sceneGraph, SceneObjectBase, SceneObjectState, VariableDependencyConfig } from '@grafana/scenes';
import { getClosestScopesFacade } from 'app/features/scopes';
import {
sceneGraph,
SceneObjectBase,
SceneObjectState,
SceneScopesBridge,
SceneTimeRangeLike,
VariableDependencyConfig,
} from '@grafana/scenes';
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
import { DashboardScene } from './DashboardScene';
export interface DashboardReloadBehaviorState extends SceneObjectState {
reloadOnParamsChange?: boolean;
uid?: string;
@ -13,27 +21,40 @@ export interface DashboardReloadBehaviorState extends SceneObjectState {
}
export class DashboardReloadBehavior extends SceneObjectBase<DashboardReloadBehaviorState> {
constructor(state: DashboardReloadBehaviorState) {
const shouldReload = state.reloadOnParamsChange && state.uid;
private _timeRange: SceneTimeRangeLike | undefined;
private _scopesBridge: SceneScopesBridge | undefined;
private _dashboardScene: DashboardScene | undefined;
constructor(state: DashboardReloadBehaviorState) {
super(state);
// Sometimes the reload is triggered multiple subsequent times
// Debouncing it prevents double/triple reloads
this.reloadDashboard = debounce(this.reloadDashboard).bind(this);
if (shouldReload) {
const shouldReload = !!this.state.uid && !!this.state.reloadOnParamsChange;
this.addActivationHandler(() => {
getClosestScopesFacade(this)?.setState({
handler: this.reloadDashboard,
});
if (!shouldReload) {
return;
}
this._timeRange = sceneGraph.getTimeRange(this);
this._scopesBridge = sceneGraph.getScopesBridge(this);
this._dashboardScene = sceneGraph.getAncestor(this, DashboardScene);
this._variableDependency = new VariableDependencyConfig(this, {
onAnyVariableChanged: this.reloadDashboard,
});
this._scopesBridge?.subscribeToValue(() => {
if (shouldReload) {
this.reloadDashboard();
}
});
this._subs.add(
sceneGraph.getTimeRange(this).subscribeToState((newState, prevState) => {
this._timeRange.subscribeToState((newState, prevState) => {
if (!isEqual(newState.value, prevState.value)) {
this.reloadDashboard();
}
@ -43,10 +64,9 @@ export class DashboardReloadBehavior extends SceneObjectBase<DashboardReloadBeha
this.reloadDashboard();
});
}
}
private isEditing() {
return this.parent && 'isEditing' in this.parent.state && this.parent.state.isEditing;
return !!this._dashboardScene?.state.isEditing;
}
private isWaitingForVariables() {
@ -56,18 +76,19 @@ export class DashboardReloadBehavior extends SceneObjectBase<DashboardReloadBeha
}
private reloadDashboard() {
if (!this.isEditing() && !this.isWaitingForVariables()) {
const timeRange = sceneGraph.getTimeRange(this);
if (this.isEditing() || this.isWaitingForVariables()) {
return;
}
// This is wrapped in setTimeout in order to allow variables and scopes to be set in the URL before actually reloading the dashboard
setTimeout(() => {
getDashboardScenePageStateManager().reloadDashboard({
version: this.state.version!,
scopes: getClosestScopesFacade(this)?.value.map((scope) => scope.metadata.name) ?? [],
scopes: this._scopesBridge?.getValue().map((scope) => scope.metadata.name) ?? [],
// We're not using the getUrlState from timeRange since it makes more sense to pass the absolute timestamps as opposed to relative time
timeRange: {
from: timeRange.state.value.from.toISOString(),
to: timeRange.state.value.to.toISOString(),
from: this._timeRange!.state.value.from.toISOString(),
to: this._timeRange!.state.value.to.toISOString(),
},
variables: sceneGraph.getVariables(this).state.variables.reduce<UrlQueryMap>(
(acc, variable) => ({
@ -80,4 +101,3 @@ export class DashboardReloadBehavior extends SceneObjectBase<DashboardReloadBeha
});
}
}
}

@ -1,15 +1,6 @@
import * as H from 'history';
import {
AppEvents,
CoreApp,
DataQueryRequest,
NavIndex,
NavModelItem,
locationUtil,
DataSourceGetTagKeysOptions,
DataSourceGetTagValuesOptions,
} from '@grafana/data';
import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil } from '@grafana/data';
import { config, locationService, RefreshEvent } from '@grafana/runtime';
import {
sceneGraph,
@ -17,6 +8,7 @@ import {
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
SceneScopesBridge,
SceneTimeRange,
sceneUtils,
SceneVariable,
@ -37,7 +29,6 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardModel, ScopeMeta } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { getClosestScopesFacade, ScopesFacade } from 'app/features/scopes';
import { VariablesChanged } from 'app/features/variables/types';
import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types';
import { ShowConfirmModalEvent } from 'app/types/events';
@ -142,6 +133,7 @@ export interface DashboardSceneState extends SceneObjectState {
panelsPerRow?: number;
/** options pane */
editPane: DashboardEditPane;
scopesBridge: SceneScopesBridge | undefined;
}
export class DashboardScene extends SceneObjectBase<DashboardSceneState> implements LayoutParent {
@ -169,16 +161,14 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
*/
private _changeTracker: DashboardSceneChangeTracker;
/**
* A reference to the scopes facade
*/
private _scopesFacade: ScopesFacade | null;
/**
* Remember scroll position when going into panel edit
*/
private _scrollRef?: ScrollRefElement;
private _prevScrollPos?: number;
protected _renderBeforeActivation = true;
private _serializer: DashboardSceneSerializerLike<
Dashboard | DashboardV2Spec,
DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata']
@ -194,16 +184,17 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
links: state.links ?? [],
...state,
editPane: new DashboardEditPane(),
scopesBridge: config.featureToggles.scopeFilters ? new SceneScopesBridge({}) : undefined,
});
this._scopesFacade = getClosestScopesFacade(this);
this._changeTracker = new DashboardSceneChangeTracker(this);
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
this.state.scopesBridge?.setEnabled(true);
let prevSceneContext = window.__grafanaSceneContext;
const isNew = locationService.getLocation().pathname === '/dashboard/new';
@ -212,6 +203,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
this._initializePanelSearch();
if (this.state.isEditing) {
this.state.scopesBridge?.setReadOnly(true);
this._initialUrlState = locationService.getLocation();
this._changeTracker.startTrackingChanges();
}
@ -236,6 +228,8 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
// Deactivation logic
return () => {
this.state.scopesBridge?.setReadOnly(false);
this.state.scopesBridge?.setEnabled(false);
window.__grafanaSceneContext = prevSceneContext;
clearKeyBindings();
this._changeTracker.terminate();
@ -269,7 +263,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
this.state.body.editModeChanged?.(true);
// Propagate edit mode to scopes
this._scopesFacade?.enterReadOnly();
this.state.scopesBridge?.setReadOnly(true);
this._changeTracker.startTrackingChanges();
};
@ -306,7 +300,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
if (!this.state.isDirty || skipConfirm) {
this.exitEditModeConfirmed(restoreInitialState || this.state.isDirty);
this._scopesFacade?.exitReadOnly();
this.state.scopesBridge?.setReadOnly(false);
return;
}
@ -318,7 +312,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
yesText: 'Discard',
onConfirm: () => {
this.exitEditModeConfirmed();
this._scopesFacade?.exitReadOnly();
this.state.scopesBridge?.setReadOnly(false);
},
})
);
@ -650,13 +644,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
panelId,
panelName: panel?.state?.title,
panelPluginId: panel?.state.pluginId,
scopes: this._scopesFacade?.value,
};
}
public enrichFiltersRequest(): Partial<DataSourceGetTagKeysOptions | DataSourceGetTagValuesOptions> {
return {
scopes: this._scopesFacade?.value,
};
}

@ -14,7 +14,7 @@ import { PanelSearchLayout } from './PanelSearchLayout';
import { DashboardAngularDeprecationBanner } from './angular/DashboardAngularDeprecationBanner';
export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
const { controls, overlay, editview, editPanel, viewPanelScene, panelSearch, panelsPerRow, isEditing } =
const { controls, overlay, editview, editPanel, viewPanelScene, panelSearch, panelsPerRow, isEditing, scopesBridge } =
model.useState();
const { type } = useParams();
const location = useLocation();
@ -41,6 +41,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
if (editview) {
return (
<>
{scopesBridge && <scopesBridge.Component model={scopesBridge} />}
<editview.Component model={editview} />
{overlay && <overlay.Component model={overlay} />}
</>
@ -62,6 +63,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Custom}>
{scopesBridge && <scopesBridge.Component model={scopesBridge} />}
{editPanel && <editPanel.Component model={editPanel} />}
{!editPanel && (
<DashboardEditPaneSplitter

@ -141,6 +141,8 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
this._scene.onEnterEditMode();
}
this._scene.state.scopesBridge?.setReadOnly(true);
const libPanelBehavior = getLibraryPanelBehavior(panel);
if (libPanelBehavior && !libPanelBehavior?.state.isLoaded) {
this._waitForLibPanelToLoadBeforeEnteringPanelEdit(panel, libPanelBehavior);

@ -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,
});
}
}

@ -25,7 +25,6 @@ import { contextSrv } from 'app/core/core';
import { Trans, t } from 'app/core/internationalization';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { ScopesSelector } from 'app/features/scopes';
import { shareDashboardType } from '../../dashboard/components/ShareModal/utils';
import { PanelEditor, buildPanelEditScene } from '../panel-edit/PanelEditor';
@ -76,7 +75,6 @@ export function ToolbarActions({ dashboard }: Props) {
// Means we are not in settings view, fullscreen panel or edit panel
const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel;
const isEditingAndShowingDashboard = isEditing && isShowingDashboard;
const showScopesSelector = config.featureToggles.scopeFilters && !isEditing;
const dashboardNewLayouts = config.featureToggles.dashboardNewLayouts;
const isManaged = Boolean(dashboard.isManaged());
@ -643,11 +641,10 @@ export function ToolbarActions({ dashboard }: Props) {
const rightActionsElements: ReactNode[] = renderActionElements(toolbarActions);
const leftActionsElements: ReactNode[] = renderActionElements(leftActions);
const hasActionsToLeftAndRight = showScopesSelector || leftActionsElements.length > 0;
const hasActionsToLeftAndRight = leftActionsElements.length > 0;
return (
<Stack flex={1} minWidth={0} justifyContent={hasActionsToLeftAndRight ? 'space-between' : 'flex-end'}>
{showScopesSelector && <ScopesSelector />}
{leftActionsElements.length > 0 && <ToolbarButtonRow alignment="left">{leftActionsElements}</ToolbarButtonRow>}
<ToolbarButtonRow alignment="right">{rightActionsElements}</ToolbarButtonRow>
</Stack>

@ -66,7 +66,6 @@ import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehavi
import { registerDashboardMacro } from '../scene/DashboardMacro';
import { DashboardReloadBehavior } from '../scene/DashboardReloadBehavior';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardScopesFacade } from '../scene/DashboardScopesFacade';
import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager';
import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState';
import { getIntervalsFromQueryString } from '../utils/utils';
@ -188,10 +187,6 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
new behaviors.LiveNowTimer({ enabled: dashboard.liveNow }),
preserveDashboardSceneStateInLocalStorage,
addPanelsOnLoadBehavior,
new DashboardScopesFacade({
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange,
uid: dashboardId?.toString(),
}),
new DashboardReloadBehavior({
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange,
uid: dashboardId?.toString(),

@ -36,7 +36,6 @@ import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { registerDashboardMacro } from '../scene/DashboardMacro';
import { DashboardReloadBehavior } from '../scene/DashboardReloadBehavior';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardScopesFacade } from '../scene/DashboardScopesFacade';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
@ -241,10 +240,6 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }),
preserveDashboardSceneStateInLocalStorage,
addPanelsOnLoadBehavior,
new DashboardScopesFacade({
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange && oldModel.meta.reloadOnParamsChange,
uid,
}),
new DashboardReloadBehavior({
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange && oldModel.meta.reloadOnParamsChange,
uid,

@ -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;
};
}

@ -23,23 +23,23 @@ export function ScopesDashboardsTreeFolderItem({
const styles = useStyles2(getStyles);
return (
<div className={styles.container} role="treeitem" aria-selected={folder.isExpanded}>
<div className={styles.container} role="treeitem" aria-selected={folder.expanded}>
<button
className={styles.expand}
data-testid={`scopes-dashboards-${folder.title}-expand`}
aria-label={
folder.isExpanded ? t('scopes.dashboards.collapse', 'Collapse') : t('scopes.dashboards.expand', 'Expand')
folder.expanded ? t('scopes.dashboards.collapse', 'Collapse') : t('scopes.dashboards.expand', 'Expand')
}
onClick={() => {
onFolderUpdate(folderPath, !folder.isExpanded);
onFolderUpdate(folderPath, !folder.expanded);
}}
>
<Icon name={!folder.isExpanded ? 'angle-right' : 'angle-down'} className={styles.icon} />
<Icon name={!folder.expanded ? 'angle-right' : 'angle-down'} className={styles.icon} />
{folder.title}
</button>
{folder.isExpanded && (
{folder.expanded && (
<div className={styles.children}>
<ScopesDashboardsTree folders={folders} folderPath={folderPath} onFolderUpdate={onFolderUpdate} />
</div>

@ -15,21 +15,21 @@ export interface ScopesDashboardsTreeSearchProps {
export function ScopesDashboardsTreeSearch({ disabled, query, onChange }: ScopesDashboardsTreeSearchProps) {
const styles = useStyles2(getStyles);
const [inputState, setInputState] = useState<{ value: string; isDirty: boolean }>({ value: query, isDirty: false });
const [inputState, setInputState] = useState<{ value: string; dirty: boolean }>({ value: query, dirty: false });
const [getDebounceState] = useDebounce(
() => {
if (inputState.isDirty) {
if (inputState.dirty) {
onChange(inputState.value);
}
},
500,
[inputState.isDirty, inputState.value]
[inputState.dirty, inputState.value]
);
useEffect(() => {
if ((getDebounceState() || !inputState.isDirty) && inputState.value !== query) {
setInputState({ value: query, isDirty: false });
if ((getDebounceState() || !inputState.dirty) && inputState.value !== query) {
setInputState({ value: query, dirty: false });
}
}, [getDebounceState, inputState, query]);
@ -40,7 +40,7 @@ export function ScopesDashboardsTreeSearch({ disabled, query, onChange }: Scopes
placeholder={t('scopes.dashboards.search', 'Search')}
value={inputState.value}
data-testid="scopes-dashboards-search"
onChange={(value) => setInputState({ value, isDirty: true })}
onChange={(value) => setInputState({ value, dirty: true })}
/>
</div>
);

@ -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;
}, {});
}

@ -10,26 +10,19 @@ import { NodesMap, SelectedScope } from './types';
export interface ScopesInputProps {
nodes: NodesMap;
scopes: SelectedScope[];
isDisabled: boolean;
isLoading: boolean;
disabled: boolean;
loading: boolean;
onInputClick: () => void;
onRemoveAllClick: () => void;
}
export function ScopesInput({
nodes,
scopes,
isDisabled,
isLoading,
onInputClick,
onRemoveAllClick,
}: ScopesInputProps) {
export function ScopesInput({ nodes, scopes, disabled, loading, onInputClick, onRemoveAllClick }: ScopesInputProps) {
const styles = useStyles2(getStyles);
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
const [tooltipVisible, setTooltipVisible] = useState(false);
useEffect(() => {
setIsTooltipVisible(false);
setTooltipVisible(false);
}, [scopes]);
const scopesPaths = useMemo(() => {
@ -82,13 +75,13 @@ export function ScopesInput({
<Input
readOnly
placeholder={t('scopes.selector.input.placeholder', 'Select scopes...')}
disabled={isDisabled}
loading={isLoading}
disabled={disabled}
loading={loading}
value={scopesTitles}
aria-label={t('scopes.selector.input.placeholder', 'Select scopes...')}
data-testid="scopes-selector-input"
suffix={
scopes.length > 0 && !isDisabled ? (
scopes.length > 0 && !disabled ? (
<IconButton
aria-label={t('scopes.selector.input.removeAll', 'Remove all scopes')}
name="times"
@ -96,20 +89,20 @@ export function ScopesInput({
/>
) : undefined
}
onMouseOver={() => setIsTooltipVisible(true)}
onMouseOut={() => setIsTooltipVisible(false)}
onMouseOver={() => setTooltipVisible(true)}
onMouseOut={() => setTooltipVisible(false)}
onClick={() => {
if (!isDisabled) {
if (!disabled) {
onInputClick();
}
}}
/>
),
[isDisabled, isLoading, onInputClick, onRemoveAllClick, scopes, scopesTitles]
[disabled, loading, onInputClick, onRemoveAllClick, scopes, scopesTitles]
);
return (
<Tooltip content={scopesPaths} show={scopes.length === 0 ? false : isTooltipVisible}>
<Tooltip content={scopesPaths} show={scopes.length === 0 ? false : tooltipVisible}>
{input}
</Tooltip>
);

@ -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;
};
}

@ -27,11 +27,11 @@ export function ScopesTree({
const nodeId = nodePath[nodePath.length - 1];
const node = nodes[nodeId];
const childNodes = Object.values(node.nodes);
const isNodeLoading = loadingNodeName === nodeId;
const nodeLoading = loadingNodeName === nodeId;
const scopeNames = scopes.map(({ scopeName }) => scopeName);
const anyChildExpanded = childNodes.some(({ isExpanded }) => isExpanded);
const anyChildExpanded = childNodes.some(({ expanded }) => expanded);
const groupedNodes: Dictionary<Node[]> = useMemo(() => groupBy(childNodes, 'reason'), [childNodes]);
const isLastExpandedNode = !anyChildExpanded && node.isExpanded;
const lastExpandedNode = !anyChildExpanded && node.expanded;
return (
<>
@ -42,11 +42,11 @@ export function ScopesTree({
onNodeUpdate={onNodeUpdate}
/>
<ScopesTreeLoading isNodeLoading={isNodeLoading}>
<ScopesTreeLoading nodeLoading={nodeLoading}>
<ScopesTreeItem
anyChildExpanded={anyChildExpanded}
groupedNodes={groupedNodes}
isLastExpandedNode={isLastExpandedNode}
lastExpandedNode={lastExpandedNode}
loadingNodeName={loadingNodeName}
node={node}
nodePath={nodePath}
@ -67,7 +67,7 @@ export function ScopesTree({
<ScopesTreeItem
anyChildExpanded={anyChildExpanded}
groupedNodes={groupedNodes}
isLastExpandedNode={isLastExpandedNode}
lastExpandedNode={lastExpandedNode}
loadingNodeName={loadingNodeName}
node={node}
nodePath={nodePath}

@ -11,7 +11,7 @@ import { Node, NodeReason, OnNodeSelectToggle, OnNodeUpdate, TreeScope } from '.
export interface ScopesTreeItemProps {
anyChildExpanded: boolean;
groupedNodes: Dictionary<Node[]>;
isLastExpandedNode: boolean;
lastExpandedNode: boolean;
loadingNodeName: string | undefined;
node: Node;
nodePath: string[];
@ -26,7 +26,7 @@ export interface ScopesTreeItemProps {
export function ScopesTreeItem({
anyChildExpanded,
groupedNodes,
isLastExpandedNode,
lastExpandedNode,
loadingNodeName,
node,
nodePath,
@ -48,9 +48,9 @@ export function ScopesTreeItem({
const children = (
<div role="tree" className={anyChildExpanded ? styles.expandedContainer : undefined}>
{nodes.map((childNode) => {
const isSelected = childNode.isSelectable && scopeNames.includes(childNode.linkId!);
const selected = childNode.selectable && scopeNames.includes(childNode.linkId!);
if (anyChildExpanded && !childNode.isExpanded) {
if (anyChildExpanded && !childNode.expanded) {
return null;
}
@ -62,16 +62,16 @@ export function ScopesTreeItem({
<div
key={childNode.name}
role="treeitem"
aria-selected={childNode.isExpanded}
aria-selected={childNode.expanded}
className={anyChildExpanded ? styles.expandedContainer : undefined}
>
<div className={cx(styles.title, childNode.isSelectable && !childNode.isExpanded && styles.titlePadding)}>
{childNode.isSelectable && !childNode.isExpanded ? (
<div className={cx(styles.title, childNode.selectable && !childNode.expanded && styles.titlePadding)}>
{childNode.selectable && !childNode.expanded ? (
node.disableMultiSelect ? (
<RadioButtonDot
id={radioName}
name={radioName}
checked={isSelected}
checked={selected}
label=""
data-testid={`scopes-tree-${type}-${childNode.name}-radio`}
onClick={() => {
@ -80,7 +80,7 @@ export function ScopesTreeItem({
/>
) : (
<Checkbox
checked={isSelected}
checked={selected}
data-testid={`scopes-tree-${type}-${childNode.name}-checkbox`}
onChange={() => {
onNodeSelectToggle(childNodePath);
@ -89,18 +89,18 @@ export function ScopesTreeItem({
)
) : null}
{childNode.isExpandable ? (
{childNode.expandable ? (
<button
className={styles.expand}
data-testid={`scopes-tree-${type}-${childNode.name}-expand`}
aria-label={
childNode.isExpanded ? t('scopes.tree.collapse', 'Collapse') : t('scopes.tree.expand', 'Expand')
childNode.expanded ? t('scopes.tree.collapse', 'Collapse') : t('scopes.tree.expand', 'Expand')
}
onClick={() => {
onNodeUpdate(childNodePath, !childNode.isExpanded, childNode.query);
onNodeUpdate(childNodePath, !childNode.expanded, childNode.query);
}}
>
<Icon name={!childNode.isExpanded ? 'angle-right' : 'angle-down'} />
<Icon name={!childNode.expanded ? 'angle-right' : 'angle-down'} />
{childNode.title}
</button>
@ -110,7 +110,7 @@ export function ScopesTreeItem({
</div>
<div className={styles.children}>
{childNode.isExpanded && (
{childNode.expanded && (
<ScopesTree
nodes={node.nodes}
nodePath={childNodePath}
@ -127,7 +127,7 @@ export function ScopesTreeItem({
</div>
);
if (isLastExpandedNode) {
if (lastExpandedNode) {
return (
<ScrollContainer
minHeight={`${Math.min(5, nodes.length) * 30}px`}

@ -7,13 +7,13 @@ import { useStyles2 } from '@grafana/ui';
export interface ScopesTreeLoadingProps {
children: ReactNode;
isNodeLoading: boolean;
nodeLoading: boolean;
}
export function ScopesTreeLoading({ children, isNodeLoading }: ScopesTreeLoadingProps) {
export function ScopesTreeLoading({ children, nodeLoading }: ScopesTreeLoadingProps) {
const styles = useStyles2(getStyles);
if (isNodeLoading) {
if (nodeLoading) {
return <Skeleton count={5} className={styles.loader} />;
}

@ -18,22 +18,22 @@ export interface ScopesTreeSearchProps {
export function ScopesTreeSearch({ anyChildExpanded, nodePath, query, onNodeUpdate }: ScopesTreeSearchProps) {
const styles = useStyles2(getStyles);
const [inputState, setInputState] = useState<{ value: string; isDirty: boolean }>({ value: query, isDirty: false });
const [inputState, setInputState] = useState<{ value: string; dirty: boolean }>({ value: query, dirty: false });
useEffect(() => {
if (!inputState.isDirty && inputState.value !== query) {
setInputState({ value: query, isDirty: false });
if (!inputState.dirty && inputState.value !== query) {
setInputState({ value: query, dirty: false });
}
}, [inputState, query]);
useDebounce(
() => {
if (inputState.isDirty) {
if (inputState.dirty) {
onNodeUpdate(nodePath, true, inputState.value);
}
},
500,
[inputState.isDirty, inputState.value]
[inputState.dirty, inputState.value]
);
if (anyChildExpanded) {
@ -48,7 +48,7 @@ export function ScopesTreeSearch({ anyChildExpanded, nodePath, query, onNodeUpda
data-testid="scopes-tree-search"
escapeRegex={false}
onChange={(value) => {
setInputState({ value, isDirty: true });
setInputState({ value, dirty: true });
}}
/>
);

@ -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;

@ -2,8 +2,7 @@ import { config } from '@grafana/runtime';
import { setDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
import { clearMocks, enterEditMode, updateMyVar, updateScopes, updateTimeRange } from './utils/actions';
import { expectDashboardReload, expectNotDashboardReload } from './utils/assertions';
import { enterEditMode, updateMyVar, updateScopes, updateTimeRange } from './utils/actions';
import { getDatasource, getInstanceSettings, getMock } from './utils/mocks';
import { renderDashboard, resetScenes } from './utils/render';
@ -17,6 +16,8 @@ jest.mock('@grafana/runtime', () => ({
}));
describe('Dashboard reload', () => {
let dashboardReloadSpy: jest.SpyInstance;
beforeAll(() => {
config.featureToggles.scopeFilters = true;
config.featureToggles.groupByVariable = true;
@ -39,43 +40,45 @@ describe('Dashboard reload', () => {
config.featureToggles.reloadDashboardsOnParamsChange = reloadDashboardsOnParamsChange;
setDashboardAPI(undefined);
const dashboardScene = renderDashboard({ uid: withUid ? 'dash-1' : undefined }, { reloadOnParamsChange });
const dashboardScene = await renderDashboard({ uid: withUid ? 'dash-1' : undefined }, { reloadOnParamsChange });
dashboardReloadSpy = jest.spyOn(getDashboardScenePageStateManager(), 'reloadDashboard');
if (editMode) {
await enterEditMode(dashboardScene);
}
const shouldReload = reloadDashboardsOnParamsChange && reloadOnParamsChange && withUid && !editMode;
dashboardReloadSpy.mockClear();
await updateTimeRange(dashboardScene);
await jest.advanceTimersToNextTimerAsync();
if (!shouldReload) {
expectNotDashboardReload();
expect(dashboardReloadSpy).not.toHaveBeenCalled();
} else {
expectDashboardReload();
expect(dashboardReloadSpy).toHaveBeenCalled();
}
await updateMyVar(dashboardScene, '2');
await jest.advanceTimersToNextTimerAsync();
if (!shouldReload) {
expectNotDashboardReload();
expect(dashboardReloadSpy).not.toHaveBeenCalled();
} else {
expectDashboardReload();
expect(dashboardReloadSpy).toHaveBeenCalled();
}
await updateScopes(['grafana']);
await jest.advanceTimersToNextTimerAsync();
if (!shouldReload) {
expectNotDashboardReload();
expect(dashboardReloadSpy).not.toHaveBeenCalled();
} else {
expectDashboardReload();
expect(dashboardReloadSpy).toHaveBeenCalled();
}
getDashboardScenePageStateManager().clearDashboardCache();
getDashboardScenePageStateManager().clearSceneCache();
setDashboardAPI(undefined);
await resetScenes();
clearMocks();
await resetScenes([dashboardReloadSpy]);
}
);
});

@ -1,5 +1,7 @@
import { config } from '@grafana/runtime';
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
import {
clearNotFound,
expandDashboardFolder,
@ -21,7 +23,18 @@ import {
expectNoDashboardsNoScopes,
expectNoDashboardsSearch,
} from './utils/assertions';
import { fetchDashboardsSpy, getDatasource, getInstanceSettings, getMock } from './utils/mocks';
import {
alternativeDashboardWithRootFolder,
alternativeDashboardWithTwoFolders,
dashboardWithOneFolder,
dashboardWithoutFolder,
dashboardWithRootFolder,
dashboardWithRootFolderAndOtherFolder,
dashboardWithTwoFolders,
getDatasource,
getInstanceSettings,
getMock,
} from './utils/mocks';
import { renderDashboard, resetScenes } from './utils/render';
jest.mock('@grafana/runtime', () => ({
@ -34,17 +47,20 @@ jest.mock('@grafana/runtime', () => ({
}));
describe('Dashboards list', () => {
let fetchDashboardsSpy: jest.SpyInstance;
beforeAll(() => {
config.featureToggles.scopeFilters = true;
config.featureToggles.groupByVariable = true;
});
beforeEach(() => {
renderDashboard();
beforeEach(async () => {
await renderDashboard();
fetchDashboardsSpy = jest.spyOn(ScopesDashboardsService.instance!, 'fetchDashboardsApi');
});
afterEach(async () => {
await resetScenes();
await resetScenes([fetchDashboardsSpy]);
});
it('Opens container and fetches dashboards list when a scope is selected', async () => {
@ -244,14 +260,13 @@ describe('Dashboards list', () => {
});
it('Does not show the input when there are no dashboards found for scope', async () => {
await toggleDashboards();
await updateScopes(['cloud']);
await toggleDashboards();
expectNoDashboardsForScope();
expectNoDashboardsSearch();
});
it('Shows the input and a message when there are no dashboards found for filter', async () => {
await toggleDashboards();
await updateScopes(['mimir']);
await searchDashboards('unknown');
expectDashboardsSearch();
@ -260,4 +275,359 @@ describe('Dashboards list', () => {
await clearNotFound();
expectDashboardSearchValue('');
});
describe('groupDashboards', () => {
it('Assigns dashboards without groups to root folder', () => {
expect(ScopesDashboardsService.instance?.groupDashboards([dashboardWithoutFolder])).toEqual({
'': {
title: '',
expanded: 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(ScopesDashboardsService.instance?.groupDashboards([dashboardWithRootFolder])).toEqual({
'': {
title: '',
expanded: true,
folders: {},
dashboards: {
[dashboardWithRootFolder.spec.dashboard]: {
dashboard: dashboardWithRootFolder.spec.dashboard,
dashboardTitle: dashboardWithRootFolder.status.dashboardTitle,
items: [dashboardWithRootFolder],
},
},
},
});
});
it('Merges folders from multiple dashboards', () => {
expect(
ScopesDashboardsService.instance?.groupDashboards([dashboardWithOneFolder, dashboardWithTwoFolders])
).toEqual({
'': {
title: '',
expanded: true,
folders: {
'Folder 1': {
title: 'Folder 1',
expanded: 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',
expanded: false,
folders: {},
dashboards: {
[dashboardWithTwoFolders.spec.dashboard]: {
dashboard: dashboardWithTwoFolders.spec.dashboard,
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
items: [dashboardWithTwoFolders],
},
},
},
},
dashboards: {},
},
});
});
it('Merges scopes from multiple dashboards', () => {
expect(
ScopesDashboardsService.instance?.groupDashboards([dashboardWithTwoFolders, alternativeDashboardWithTwoFolders])
).toEqual({
'': {
title: '',
expanded: true,
folders: {
'Folder 1': {
title: 'Folder 1',
expanded: false,
folders: {},
dashboards: {
[dashboardWithTwoFolders.spec.dashboard]: {
dashboard: dashboardWithTwoFolders.spec.dashboard,
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
},
},
},
'Folder 2': {
title: 'Folder 2',
expanded: false,
folders: {},
dashboards: {
[dashboardWithTwoFolders.spec.dashboard]: {
dashboard: dashboardWithTwoFolders.spec.dashboard,
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
},
},
},
},
dashboards: {},
},
});
});
it('Matches snapshot', () => {
expect(
ScopesDashboardsService.instance?.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: {},
expanded: false,
title: 'Folder 1',
},
'Folder 2': {
dashboards: {
[dashboardWithTwoFolders.spec.dashboard]: {
dashboard: dashboardWithTwoFolders.spec.dashboard,
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
},
},
folders: {},
expanded: false,
title: 'Folder 2',
},
'Folder 3': {
dashboards: {
[dashboardWithRootFolderAndOtherFolder.spec.dashboard]: {
dashboard: dashboardWithRootFolderAndOtherFolder.spec.dashboard,
dashboardTitle: dashboardWithRootFolderAndOtherFolder.status.dashboardTitle,
items: [dashboardWithRootFolderAndOtherFolder],
},
},
folders: {},
expanded: false,
title: 'Folder 3',
},
},
expanded: true,
title: '',
},
});
});
});
describe('filterFolders', () => {
it('Shows folders matching criteria', () => {
expect(
ScopesDashboardsService.instance?.filterFolders(
{
'': {
title: '',
expanded: true,
folders: {
'Folder 1': {
title: 'Folder 1',
expanded: false,
folders: {},
dashboards: {
'Dashboard ID': {
dashboard: 'Dashboard ID',
dashboardTitle: 'Dashboard Title',
items: [],
},
},
},
'Folder 2': {
title: 'Folder 2',
expanded: 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: '',
expanded: true,
folders: {
'Folder 1': {
title: 'Folder 1',
expanded: true,
folders: {},
dashboards: {
'Dashboard ID': {
dashboard: 'Dashboard ID',
dashboardTitle: 'Dashboard Title',
items: [],
},
},
},
'Folder 2': {
title: 'Folder 2',
expanded: true,
folders: {},
dashboards: {
'Dashboard ID': {
dashboard: 'Dashboard ID',
dashboardTitle: 'Dashboard Title',
items: [],
},
},
},
},
dashboards: {},
},
});
});
it('Shows dashboards matching criteria', () => {
expect(
ScopesDashboardsService.instance?.filterFolders(
{
'': {
title: '',
expanded: true,
folders: {
'Folder 1': {
title: 'Folder 1',
expanded: false,
folders: {},
dashboards: {
'Dashboard ID': {
dashboard: 'Dashboard ID',
dashboardTitle: 'Dashboard Title',
items: [],
},
},
},
'Folder 2': {
title: 'Folder 2',
expanded: 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: '',
expanded: true,
folders: {
'Folder 1': {
title: 'Folder 1',
expanded: true,
folders: {},
dashboards: {
'Dashboard ID': {
dashboard: 'Dashboard ID',
dashboardTitle: 'Dashboard Title',
items: [],
},
},
},
},
dashboards: {
'Dashboard ID': {
dashboard: 'Dashboard ID',
dashboardTitle: 'Dashboard Title',
items: [],
},
},
},
});
});
});
});

@ -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,13 +1,13 @@
import { config } from '@grafana/runtime';
import { sceneGraph } from '@grafana/scenes';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
import { getClosestScopesFacade } from '../utils';
import { getDashboardScenePageStateManager } from '../../dashboard-scene/pages/DashboardScenePageStateManager';
import { ScopesSelectorService } from '../selector/ScopesSelectorService';
import { applyScopes, cancelScopes, openSelector, selectResultCloud, updateScopes } from './utils/actions';
import { expectNotDashboardReload, expectScopesSelectorValue } from './utils/assertions';
import { fetchSelectedScopesSpy, getDatasource, getInstanceSettings, getMock, mocksScopes } from './utils/mocks';
import { expectScopesSelectorValue } from './utils/assertions';
import { getDatasource, getInstanceSettings, getMock, mocksScopes } from './utils/mocks';
import { renderDashboard, resetScenes } from './utils/render';
import { getListOfScopes } from './utils/selectors';
jest.mock('@grafana/runtime', () => ({
__esModule: true,
@ -19,19 +19,22 @@ jest.mock('@grafana/runtime', () => ({
}));
describe('Selector', () => {
let dashboardScene: DashboardScene;
let fetchSelectedScopesSpy: jest.SpyInstance;
let dashboardReloadSpy: jest.SpyInstance;
beforeAll(() => {
config.featureToggles.scopeFilters = true;
config.featureToggles.groupByVariable = true;
});
beforeEach(() => {
dashboardScene = renderDashboard();
beforeEach(async () => {
await renderDashboard();
fetchSelectedScopesSpy = jest.spyOn(ScopesSelectorService.instance!, 'fetchScopesApi');
dashboardReloadSpy = jest.spyOn(getDashboardScenePageStateManager(), 'reloadDashboard');
});
afterEach(async () => {
await resetScenes();
await resetScenes([fetchSelectedScopesSpy, dashboardReloadSpy]);
});
it('Fetches scope details on save', async () => {
@ -39,9 +42,7 @@ describe('Selector', () => {
await selectResultCloud();
await applyScopes();
expect(fetchSelectedScopesSpy).toHaveBeenCalled();
expect(getClosestScopesFacade(dashboardScene)?.value).toEqual(
mocksScopes.filter(({ metadata: { name } }) => name === 'cloud')
);
expect(getListOfScopes()).toEqual(mocksScopes.filter(({ metadata: { name } }) => name === 'cloud'));
});
it('Does not save the scopes on close', async () => {
@ -49,7 +50,7 @@ describe('Selector', () => {
await selectResultCloud();
await cancelScopes();
expect(fetchSelectedScopesSpy).not.toHaveBeenCalled();
expect(getClosestScopesFacade(dashboardScene)?.value).toEqual([]);
expect(getListOfScopes()).toEqual([]);
});
it('Shows selected scopes', async () => {
@ -59,25 +60,6 @@ describe('Selector', () => {
it('Does not reload the dashboard on scope change', async () => {
await updateScopes(['grafana']);
expectNotDashboardReload();
});
it('Adds scopes to enrichers', async () => {
const queryRunner = sceneGraph.getQueryController(dashboardScene)!;
await updateScopes(['grafana']);
let scopes = mocksScopes.filter(({ metadata: { name } }) => name === 'grafana');
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(scopes);
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(scopes);
await updateScopes(['grafana', 'mimir']);
scopes = mocksScopes.filter(({ metadata: { name } }) => name === 'grafana' || name === 'mimir');
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(scopes);
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(scopes);
await updateScopes(['mimir']);
scopes = mocksScopes.filter(({ metadata: { name } }) => name === 'mimir');
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(scopes);
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(scopes);
expect(dashboardReloadSpy).not.toHaveBeenCalled();
});
});

@ -1,5 +1,7 @@
import { config } from '@grafana/runtime';
import { ScopesSelectorService } from '../selector/ScopesSelectorService';
import {
applyScopes,
clearScopesSearch,
@ -39,7 +41,7 @@ import {
expectSelectedScopePath,
expectTreeScopePath,
} from './utils/assertions';
import { fetchNodesSpy, fetchScopeSpy, getDatasource, getInstanceSettings, getMock } from './utils/mocks';
import { getDatasource, getInstanceSettings, getMock } from './utils/mocks';
import { renderDashboard, resetScenes } from './utils/render';
jest.mock('@grafana/runtime', () => ({
@ -52,17 +54,22 @@ jest.mock('@grafana/runtime', () => ({
}));
describe('Tree', () => {
let fetchNodesSpy: jest.SpyInstance;
let fetchScopeSpy: jest.SpyInstance;
beforeAll(() => {
config.featureToggles.scopeFilters = true;
config.featureToggles.groupByVariable = true;
});
beforeEach(() => {
renderDashboard();
beforeEach(async () => {
await renderDashboard();
fetchNodesSpy = jest.spyOn(ScopesSelectorService.instance!, 'fetchNodeApi');
fetchScopeSpy = jest.spyOn(ScopesSelectorService.instance!, 'fetchScopeApi');
});
afterEach(async () => {
await resetScenes();
await resetScenes([fetchNodesSpy, fetchScopeSpy]);
});
it('Fetches scope details on select', async () => {
@ -126,16 +133,16 @@ describe('Tree', () => {
await openSelector();
await expandResultApplications();
await searchScopes('Cloud');
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
expectResultApplicationsGrafanaNotPresent();
expectResultApplicationsMimirNotPresent();
expectResultApplicationsCloudPresent();
await clearScopesSearch();
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
expect(fetchNodesSpy).toHaveBeenCalledTimes(4);
await searchScopes('Grafana');
expect(fetchNodesSpy).toHaveBeenCalledTimes(4);
expect(fetchNodesSpy).toHaveBeenCalledTimes(5);
expectResultApplicationsGrafanaPresent();
expectResultApplicationsCloudNotPresent();
});
@ -156,7 +163,7 @@ describe('Tree', () => {
await expandResultApplications();
await selectResultApplicationsMimir();
await searchScopes('grafana');
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
expectPersistedApplicationsMimirPresent();
expectPersistedApplicationsGrafanaNotPresent();
expectResultApplicationsMimirNotPresent();
@ -168,7 +175,7 @@ describe('Tree', () => {
await expandResultApplications();
await selectResultApplicationsMimir();
await searchScopes('mimir');
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
expectPersistedApplicationsMimirNotPresent();
expectResultApplicationsMimirPresent();
});
@ -178,10 +185,10 @@ describe('Tree', () => {
await expandResultApplications();
await selectResultApplicationsMimir();
await searchScopes('grafana');
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
await clearScopesSearch();
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
expect(fetchNodesSpy).toHaveBeenCalledTimes(4);
expectPersistedApplicationsMimirNotPresent();
expectPersistedApplicationsGrafanaNotPresent();
expectResultApplicationsMimirPresent();
@ -192,15 +199,15 @@ describe('Tree', () => {
await openSelector();
await expandResultApplications();
await searchScopes('mimir');
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
await selectResultApplicationsMimir();
await searchScopes('unknown');
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
expect(fetchNodesSpy).toHaveBeenCalledTimes(4);
expectPersistedApplicationsMimirPresent();
await clearScopesSearch();
expect(fetchNodesSpy).toHaveBeenCalledTimes(4);
expect(fetchNodesSpy).toHaveBeenCalledTimes(5);
expectResultApplicationsMimirPresent();
expectResultApplicationsGrafanaPresent();
});
@ -210,7 +217,7 @@ describe('Tree', () => {
await expandResultApplications();
await selectResultApplicationsMimir();
await searchScopes('grafana');
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
await selectResultApplicationsGrafana();
await applyScopes();
@ -222,7 +229,7 @@ describe('Tree', () => {
await expandResultApplications();
await selectResultApplicationsMimir();
await searchScopes('grafana');
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
await selectResultApplicationsGrafana();
await applyScopes();
@ -239,11 +246,11 @@ describe('Tree', () => {
expectScopesHeadline('Recommended');
await searchScopes('Applications');
expect(fetchNodesSpy).toHaveBeenCalledTimes(1);
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
expectScopesHeadline('Results');
await searchScopes('unknown');
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
expectScopesHeadline('No results found for your query');
});

@ -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: [],
},
},
},
});
});
});
});

@ -5,16 +5,8 @@ import { MultiValueVariable, sceneGraph, VariableValue } from '@grafana/scenes';
import { defaultTimeZone, TimeZone } from '@grafana/schema';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
import { scopesSelectorScene } from '../../instance';
import { ScopesService } from '../../ScopesService';
import {
dashboardReloadSpy,
fetchDashboardsSpy,
fetchNodesSpy,
fetchScopeSpy,
fetchSelectedScopesSpy,
getMock,
} from './mocks';
import {
getDashboardFolderExpand,
getDashboardsExpand,
@ -37,30 +29,13 @@ import {
getTreeSearch,
} from './selectors';
export const clearMocks = () => {
fetchNodesSpy.mockClear();
fetchScopeSpy.mockClear();
fetchSelectedScopesSpy.mockClear();
fetchDashboardsSpy.mockClear();
dashboardReloadSpy.mockClear();
getMock.mockClear();
};
const click = async (selector: () => HTMLElement) => act(() => fireEvent.click(selector()));
const type = async (selector: () => HTMLInputElement, value: string) => {
await act(() => fireEvent.input(selector(), { target: { value } }));
await jest.runOnlyPendingTimersAsync();
};
export const updateScopes = async (scopes: string[]) =>
act(async () =>
scopesSelectorScene?.updateScopes(
scopes.map((scopeName) => ({
scopeName,
path: [],
}))
)
);
export const updateScopes = async (scopes: string[]) => act(async () => ScopesService.instance?.changeScopes(scopes));
export const openSelector = async () => click(getSelectorInput);
export const applyScopes = async () => {
await click(getSelectorApply);

@ -1,4 +1,3 @@
import { dashboardReloadSpy } from './mocks';
import {
getDashboard,
getDashboardsContainer,
@ -21,7 +20,6 @@ import {
queryDashboard,
queryDashboardFolderExpand,
queryDashboardsContainer,
queryDashboardsExpand,
queryDashboardsSearch,
queryPersistedApplicationsGrafanaSelect,
queryPersistedApplicationsMimirSelect,
@ -29,7 +27,6 @@ import {
queryResultApplicationsGrafanaSelect,
queryResultApplicationsMimirSelect,
querySelectorApply,
querySelectorInput,
} from './selectors';
const expectInDocument = (selector: () => HTMLElement) => expect(selector()).toBeInTheDocument();
@ -42,7 +39,7 @@ const expectTextContent = (selector: () => HTMLElement, text: string) => expect(
const expectDisabled = (selector: () => HTMLElement) => expect(selector()).toBeDisabled();
export const expectScopesSelectorClosed = () => expectNotInDocument(querySelectorApply);
export const expectScopesSelectorNotInDocument = () => expectNotInDocument(querySelectorInput);
export const expectScopesSelectorDisabled = () => expectDisabled(getSelectorInput);
export const expectScopesSelectorValue = (value: string) => expectValue(getSelectorInput, value);
export const expectScopesHeadline = (value: string) => expectTextContent(getTreeHeadline, value);
export const expectPersistedApplicationsGrafanaNotPresent = () =>
@ -65,7 +62,6 @@ export const expectResultCloudOpsSelected = () => expectRadioChecked(getResultCl
export const expectResultCloudOpsNotSelected = () => expectRadioNotChecked(getResultCloudOpsRadio);
export const expectDashboardsDisabled = () => expectDisabled(getDashboardsExpand);
export const expectDashboardsNotInDocument = () => expectNotInDocument(queryDashboardsExpand);
export const expectDashboardsClosed = () => expectNotInDocument(queryDashboardsContainer);
export const expectDashboardsOpen = () => expectInDocument(getDashboardsContainer);
export const expectNoDashboardsSearch = () => expectNotInDocument(queryDashboardsSearch);
@ -81,9 +77,6 @@ export const expectDashboardNotInDocument = (uid: string) => expectNotInDocument
export const expectDashboardLength = (uid: string, length: number) =>
expect(queryAllDashboard(uid)).toHaveLength(length);
export const expectNotDashboardReload = () => expect(dashboardReloadSpy).not.toHaveBeenCalled();
export const expectDashboardReload = () => expect(dashboardReloadSpy).toHaveBeenCalled();
export const expectSelectedScopePath = (name: string, path: string[] | undefined) =>
expect(getSelectedScope(name)?.path).toEqual(path);
export const expectTreeScopePath = (name: string, path: string[] | undefined) =>

@ -2,8 +2,6 @@ import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data';
import { DataSourceRef } from '@grafana/schema/dist/esm/common/common.gen';
import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
import * as api from '../../internal/api';
export const mocksScopes: Scope[] = [
{
metadata: { name: 'cloud' },
@ -369,10 +367,6 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
},
] as const;
export const fetchNodesSpy = jest.spyOn(api, 'fetchNodes');
export const fetchScopeSpy = jest.spyOn(api, 'fetchScope');
export const fetchSelectedScopesSpy = jest.spyOn(api, 'fetchSelectedScopes');
export const fetchDashboardsSpy = jest.spyOn(api, 'fetchDashboards');
export const dashboardReloadSpy = jest.spyOn(getDashboardScenePageStateManager(), 'reloadDashboard');
export const getMock = jest

@ -1,19 +1,21 @@
import { cleanup } from '@testing-library/react';
import { cleanup, waitFor } from '@testing-library/react';
import { KBarProvider } from 'kbar';
import { render } from 'test/test-utils';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { config, setPluginImportUtils } from '@grafana/runtime';
import { sceneGraph } from '@grafana/scenes';
import { defaultDashboard } from '@grafana/schema';
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 { initializeScopes, scopesDashboardsScene, scopesSelectorScene } from '../../instance';
import { getInitialDashboardsState } from '../../internal/ScopesDashboardsScene';
import { initialSelectorState } from '../../internal/ScopesSelectorScene';
import { ScopesContextProvider } from '../../ScopesContextProvider';
import { ScopesService } from '../../ScopesService';
import { ScopesDashboardsService } from '../../dashboards/ScopesDashboardsService';
import { ScopesSelectorService } from '../../selector/ScopesSelectorService';
import { clearMocks } from './actions';
import { getMock } from './mocks';
const getDashboardDTO: (
overrideDashboard: Partial<DashboardDataDTO>,
@ -176,33 +178,38 @@ setPluginImportUtils({
getPanelPluginFromCache: () => undefined,
});
export function renderDashboard(
export async function renderDashboard(
overrideDashboard: Partial<DashboardDataDTO> = {},
overrideMeta: Partial<DashboardMeta> = {}
) {
jest.useFakeTimers({ advanceTimers: true });
jest.spyOn(console, 'error').mockImplementation(jest.fn());
clearMocks();
initializeScopes();
const dto: DashboardDTO = getDashboardDTO(overrideDashboard, overrideMeta);
const scene = transformSaveModelToScene(dto);
render(
<KBarProvider>
<ScopesContextProvider>
<AppChrome>
<scene.Component model={scene} />
</AppChrome>
</ScopesContextProvider>
</KBarProvider>
);
await waitFor(() => expect(sceneGraph.getScopesBridge(scene)).toBeDefined());
return scene;
}
export async function resetScenes() {
export async function resetScenes(spies: jest.SpyInstance[] = []) {
await jest.runOnlyPendingTimersAsync();
jest.useRealTimers();
scopesSelectorScene?.setState(initialSelectorState);
scopesDashboardsScene?.setState(getInitialDashboardsState());
getMock.mockClear();
spies.forEach((spy) => spy.mockClear());
ScopesService.instance?.reset();
ScopesSelectorService.instance?.reset();
ScopesDashboardsService.instance?.reset();
cleanup();
}

@ -1,6 +1,7 @@
import { screen } from '@testing-library/react';
import { scopesSelectorScene } from '../../instance';
import { ScopesService } from '../../ScopesService';
import { ScopesSelectorService } from '../../selector/ScopesSelectorService';
const selectors = {
tree: {
@ -33,14 +34,12 @@ const selectors = {
};
export const getSelectorInput = () => screen.getByTestId<HTMLInputElement>(selectors.selector.input);
export const querySelectorInput = () => screen.queryByTestId<HTMLInputElement>(selectors.selector.input);
export const querySelectorApply = () => screen.queryByTestId(selectors.selector.apply);
export const getSelectorApply = () => screen.getByTestId(selectors.selector.apply);
export const getSelectorCancel = () => screen.getByTestId(selectors.selector.cancel);
export const getDashboardsExpand = () => screen.getByTestId(selectors.dashboards.expand);
export const getDashboardsContainer = () => screen.getByTestId(selectors.dashboards.container);
export const queryDashboardsExpand = () => screen.queryByTestId(selectors.dashboards.expand);
export const queryDashboardsContainer = () => screen.queryByTestId(selectors.dashboards.container);
export const queryDashboardsSearch = () => screen.queryByTestId(selectors.dashboards.search);
export const getDashboardsSearch = () => screen.getByTestId<HTMLInputElement>(selectors.dashboards.search);
@ -88,8 +87,9 @@ export const getResultCloudDevRadio = () =>
export const getResultCloudOpsRadio = () =>
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-ops', 'result'));
export const getListOfSelectedScopes = () => scopesSelectorScene?.state.scopes;
export const getListOfTreeScopes = () => scopesSelectorScene?.state.treeScopes;
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);

@ -1,14 +1,14 @@
import { config } from '@grafana/runtime';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
import { scopesDashboardsScene, scopesSelectorScene } from '../instance';
import { ScopesService } from '../ScopesService';
import { enterEditMode, openSelector, toggleDashboards } from './utils/actions';
import {
expectDashboardsClosed,
expectDashboardsNotInDocument,
expectDashboardsDisabled,
expectScopesSelectorClosed,
expectScopesSelectorNotInDocument,
expectScopesSelectorDisabled,
} from './utils/assertions';
import { getDatasource, getInstanceSettings, getMock } from './utils/mocks';
import { renderDashboard, resetScenes } from './utils/render';
@ -30,8 +30,8 @@ describe('View mode', () => {
config.featureToggles.groupByVariable = true;
});
beforeEach(() => {
dashboardScene = renderDashboard();
beforeEach(async () => {
dashboardScene = await renderDashboard();
});
afterEach(async () => {
@ -40,8 +40,8 @@ describe('View mode', () => {
it('Enters view mode', async () => {
await enterEditMode(dashboardScene);
expect(scopesSelectorScene?.state?.isReadOnly).toEqual(true);
expect(scopesDashboardsScene?.state?.isPanelOpened).toEqual(false);
expect(ScopesService.instance?.state.readOnly).toEqual(true);
expect(ScopesService.instance?.state.drawerOpened).toEqual(false);
});
it('Closes selector on enter', async () => {
@ -58,11 +58,11 @@ describe('View mode', () => {
it('Does not show selector when view mode is active', async () => {
await enterEditMode(dashboardScene);
expectScopesSelectorNotInDocument();
expectScopesSelectorDisabled();
});
it('Does not show the expand button when view mode is active', async () => {
await enterEditMode(dashboardScene);
expectDashboardsNotInDocument();
expectDashboardsDisabled();
});
});

@ -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;
}

@ -31,7 +31,6 @@ import {
VariableValueSelectors,
} from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { getSelectedScopes } from 'app/features/scopes';
import { DataTrailSettings } from './DataTrailSettings';
import { DataTrailHistory } from './DataTrailsHistory';
@ -426,7 +425,11 @@ export class DataTrail extends SceneObjectBase<DataTrailState> implements SceneO
if (timeRange) {
const datasourceUid = sceneGraph.interpolate(trail, VAR_DATASOURCE_EXPR);
const otelTargets = await totalOtelResources(datasourceUid, timeRange);
const deploymentEnvironments = await getDeploymentEnvironments(datasourceUid, timeRange, getSelectedScopes());
const deploymentEnvironments = await getDeploymentEnvironments(
datasourceUid,
timeRange,
sceneGraph.getScopesBridge(trail)?.getValue() ?? []
);
const hasOtelResources = otelTargets.jobs.length > 0 && otelTargets.instances.length > 0;
// loading from the url with otel resources selected will result in turning on OTel experience
const otelResourcesVariable = sceneGraph.lookupVariable(VAR_OTEL_AND_METRIC_FILTERS, this);

@ -1,20 +1,16 @@
import { css } from '@emotion/css';
import { useEffect, useState } from 'react';
import { Routes, Route } from 'react-router-dom-v5-compat';
import {
DataQueryRequest,
DataSourceGetTagKeysOptions,
DataSourceGetTagValuesOptions,
PageLayoutType,
} from '@grafana/data';
import { PageLayoutType } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, SceneObjectState, UrlSyncContextProvider } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui/';
import {
SceneComponentProps,
SceneObjectBase,
SceneObjectState,
SceneScopesBridge,
UrlSyncContextProvider,
} from '@grafana/scenes';
import { Page } from 'app/core/components/Page/Page';
import { getClosestScopesFacade, ScopesFacade, ScopesSelector } from 'app/features/scopes';
import { AppChromeUpdate } from '../../core/components/AppChrome/AppChromeUpdate';
import { DataTrail } from './DataTrail';
import { DataTrailsHome } from './DataTrailsHome';
@ -25,35 +21,14 @@ import { getMetricName, getUrlForTrail, newMetricsTrail } from './utils';
export interface DataTrailsAppState extends SceneObjectState {
trail: DataTrail;
home: DataTrailsHome;
scopesBridge?: SceneScopesBridge | undefined;
}
export class DataTrailsApp extends SceneObjectBase<DataTrailsAppState> {
private _scopesFacade: ScopesFacade | null;
protected _renderBeforeActivation = true;
public constructor(state: DataTrailsAppState) {
super(state);
this._scopesFacade = getClosestScopesFacade(this);
}
public enrichDataRequest(): Partial<DataQueryRequest> {
if (!config.featureToggles.promQLScope) {
return {};
}
return {
scopes: this._scopesFacade?.value,
};
}
public enrichFiltersRequest(): Partial<DataSourceGetTagKeysOptions | DataSourceGetTagValuesOptions> {
if (!config.featureToggles.promQLScope) {
return {};
}
return {
scopes: this._scopesFacade?.value,
};
}
goToUrlForTrail(trail: DataTrail) {
@ -62,9 +37,11 @@ export class DataTrailsApp extends SceneObjectBase<DataTrailsAppState> {
}
static Component = ({ model }: SceneComponentProps<DataTrailsApp>) => {
const { trail, home } = model.useState();
const { trail, home, scopesBridge } = model.useState();
return (
<>
{scopesBridge && <SceneScopesBridge.Component model={scopesBridge} />}
<Routes>
{/* The routes are relative to the HOME_ROUTE */}
<Route
@ -83,12 +60,12 @@ export class DataTrailsApp extends SceneObjectBase<DataTrailsAppState> {
/>
<Route path={TRAILS_ROUTE.replace(HOME_ROUTE, '')} element={<DataTrailView trail={trail} />} />
</Routes>
</>
);
};
}
function DataTrailView({ trail }: { trail: DataTrail }) {
const styles = useStyles2(getStyles);
const [isInitialized, setIsInitialized] = useState(false);
const { metric } = trail.useState();
@ -108,15 +85,6 @@ function DataTrailView({ trail }: { trail: DataTrail }) {
return (
<UrlSyncContextProvider scene={trail}>
<Page navId="explore/metrics" pageNav={{ text: getMetricName(metric) }} layout={PageLayoutType.Custom}>
{config.featureToggles.enableScopesInMetricsExplore && (
<AppChromeUpdate
actions={
<div className={styles.topNavContainer}>
<ScopesSelector />
</div>
}
/>
)}
<trail.Component model={trail} />
</Page>
</UrlSyncContextProvider>
@ -127,37 +95,32 @@ let dataTrailsApp: DataTrailsApp;
export function getDataTrailsApp() {
if (!dataTrailsApp) {
const $behaviors = config.featureToggles.enableScopesInMetricsExplore
? [
new ScopesFacade({
handler: (facade) => {
const trail = facade.parent && 'trail' in facade.parent.state ? facade.parent.state.trail : undefined;
if (trail instanceof DataTrail) {
trail.publishEvent(new RefreshMetricsEvent());
trail.checkDataSourceForOTelResources();
}
},
}),
]
const scopesBridge =
config.featureToggles.scopeFilters && config.featureToggles.enableScopesInMetricsExplore
? new SceneScopesBridge({})
: undefined;
dataTrailsApp = new DataTrailsApp({
trail: newMetricsTrail(),
home: new DataTrailsHome({}),
$behaviors,
scopesBridge,
$behaviors: [
() => {
scopesBridge?.setEnabled(true);
const sub = scopesBridge?.subscribeToValue(() => {
dataTrailsApp.state.trail.publishEvent(new RefreshMetricsEvent());
dataTrailsApp.state.trail.checkDataSourceForOTelResources();
});
return () => {
scopesBridge?.setEnabled(false);
sub?.unsubscribe();
};
},
],
});
}
return dataTrailsApp;
}
const getStyles = () => ({
topNavContainer: css({
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'row',
justifyItems: 'flex-start',
}),
});

@ -27,7 +27,6 @@ import {
} from '@grafana/scenes';
import { Alert, Badge, Field, Icon, IconButton, InlineSwitch, Input, Select, Tooltip, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { getSelectedScopes } from 'app/features/scopes';
import { MetricScene } from '../MetricScene';
import { StatusWrapper } from '../StatusWrapper';
@ -257,7 +256,7 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> i
const response = await getMetricNames(
datasourceUid,
timeRange,
getSelectedScopes(),
sceneGraph.getScopesBridge(this)?.getValue() ?? [],
filters,
jobsList,
instancesList,

@ -50,6 +50,7 @@ describe('limitAdhocProviders', () => {
} as unknown as MetricDatasourceHelper;
dataTrail = {
forEachChild: jest.fn(),
getQueries: jest.fn().mockReturnValue([]),
} as unknown as DataTrail;
});

@ -20,12 +20,12 @@ import {
SceneObject,
SceneObjectState,
SceneObjectUrlValues,
SceneScopesBridge,
SceneTimeRange,
sceneUtils,
SceneVariable,
SceneVariableState,
} from '@grafana/scenes';
import { getClosestScopesFacade } from 'app/features/scopes';
import { getDatasourceSrv } from '../plugins/datasource_srv';
@ -53,6 +53,10 @@ export function getTrailFor(model: SceneObject): DataTrail {
return sceneGraph.getAncestor(model, DataTrail);
}
export function getScopesBridgeFor(model: SceneObject): SceneScopesBridge | undefined {
return sceneGraph.getScopesBridge(getTrailFor(model));
}
export function getTrailSettings(model: SceneObject): DataTrailSettings {
return sceneGraph.getAncestor(model, DataTrail).state.settings;
}
@ -193,7 +197,7 @@ export function limitAdhocProviders(
const opts = {
filters,
scopes: getClosestScopesFacade(variable)?.value,
scopes: sceneGraph.getScopesBridge(dataTrail)?.getValue(),
queries: dataTrail.getQueries(),
};
@ -237,7 +241,7 @@ export function limitAdhocProviders(
const opts = {
key: filter.key,
filters,
scopes: getClosestScopesFacade(variable)?.value,
scopes: sceneGraph.getScopesBridge(dataTrail)?.getValue(),
queries: dataTrail.getQueries(),
};

@ -81,7 +81,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.3, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.16.7, @babel/code-frame@npm:^7.24.2, @babel/code-frame@npm:^7.26.2":
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.3, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.16.7, @babel/code-frame@npm:^7.24.2, @babel/code-frame@npm:^7.25.9, @babel/code-frame@npm:^7.26.2":
version: 7.26.2
resolution: "@babel/code-frame@npm:7.26.2"
dependencies:
@ -362,6 +362,17 @@ __metadata:
languageName: node
linkType: hard
"@babel/parser@npm:^7.25.9":
version: 7.26.7
resolution: "@babel/parser@npm:7.26.7"
dependencies:
"@babel/types": "npm:^7.26.7"
bin:
parser: ./bin/babel-parser.js
checksum: 10/3ccc384366ca9a9b49c54f5b24c9d8cff9a505f2fbdd1cfc04941c8e1897084cc32f100e77900c12bc14a176cf88daa3c155faad680d9a23491b997fd2a59ffc
languageName: node
linkType: hard
"@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.9":
version: 7.25.9
resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.25.9"
@ -1425,7 +1436,18 @@ __metadata:
languageName: node
linkType: hard
"@babel/template@npm:^7.22.5, @babel/template@npm:^7.24.7, @babel/template@npm:^7.25.9, @babel/template@npm:^7.26.9, @babel/template@npm:^7.3.3":
"@babel/template@npm:^7.22.5, @babel/template@npm:^7.24.7, @babel/template@npm:^7.25.9, @babel/template@npm:^7.3.3":
version: 7.25.9
resolution: "@babel/template@npm:7.25.9"
dependencies:
"@babel/code-frame": "npm:^7.25.9"
"@babel/parser": "npm:^7.25.9"
"@babel/types": "npm:^7.25.9"
checksum: 10/e861180881507210150c1335ad94aff80fd9e9be6202e1efa752059c93224e2d5310186ddcdd4c0f0b0fc658ce48cb47823f15142b5c00c8456dde54f5de80b2
languageName: node
linkType: hard
"@babel/template@npm:^7.26.9":
version: 7.26.9
resolution: "@babel/template@npm:7.26.9"
dependencies:
@ -1461,6 +1483,16 @@ __metadata:
languageName: node
linkType: hard
"@babel/types@npm:^7.26.7":
version: 7.26.7
resolution: "@babel/types@npm:7.26.7"
dependencies:
"@babel/helper-string-parser": "npm:^7.25.9"
"@babel/helper-validator-identifier": "npm:^7.25.9"
checksum: 10/2264efd02cc261ca5d1c5bc94497c8995238f28afd2b7483b24ea64dd694cf46b00d51815bf0c87f0d0061ea221569c77893aeecb0d4b4bb254e9c2f938d7669
languageName: node
linkType: hard
"@bcoe/v8-coverage@npm:^0.2.3":
version: 0.2.3
resolution: "@bcoe/v8-coverage@npm:0.2.3"
@ -3597,11 +3629,11 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes-react@npm:6.2.1":
version: 6.2.1
resolution: "@grafana/scenes-react@npm:6.2.1"
"@grafana/scenes-react@npm:6.3.1":
version: 6.3.1
resolution: "@grafana/scenes-react@npm:6.3.1"
dependencies:
"@grafana/scenes": "npm:6.2.1"
"@grafana/scenes": "npm:6.3.1"
lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0"
peerDependencies:
@ -3613,13 +3645,13 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/b8f44f087999fd6074233090a005de03e9d0ed1ba965a05659773cf48afb3c11ed508371fcf0c4cdc3c9a360b3f33e5126ed9082e2f4ee306459d768bd0a5a34
checksum: 10/77dd6f7bbe3699ece25435623a78f2ef5d831ab83b59612baa362581f7c3f6c02cb420b5acfff4e3d266872a694e08b98340370468bb246d102b20e9fcc79438
languageName: node
linkType: hard
"@grafana/scenes@npm:6.2.1":
version: 6.2.1
resolution: "@grafana/scenes@npm:6.2.1"
"@grafana/scenes@npm:6.3.1":
version: 6.3.1
resolution: "@grafana/scenes@npm:6.3.1"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16"
@ -3637,7 +3669,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/44e9a0386dd09a1a7a45bcdcbf285a00996e885cf56640179cbc35deb1f26af37ce34744321193f98aa078a7e02fc8a06b8f07c03b392229cc42ed56733bab05
checksum: 10/98e3e96b9ce12ae67aa458819dc9eed1eabd1b7421e768074bd97a0fa2a0b1e080d4121b1ddd357ce41ccaf9252439163aae7e612f12cba878ee6dca34f73831
languageName: node
linkType: hard
@ -18071,8 +18103,8 @@ __metadata:
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/saga-icons": "workspace:*"
"@grafana/scenes": "npm:6.2.1"
"@grafana/scenes-react": "npm:6.2.1"
"@grafana/scenes": "npm:6.3.1"
"@grafana/scenes-react": "npm:6.3.1"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/tsconfig": "npm:^2.0.0"

Loading…
Cancel
Save