diff --git a/.betterer.results b/.betterer.results index 98094fcc972..c0486d07719 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5676,10 +5676,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], [0, 0, 0, "No untranslated strings. Wrap text with ", "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 ", "0"] ], diff --git a/package.json b/package.json index 591bdddd3ea..eed73cf59f3 100644 --- a/package.json +++ b/package.json @@ -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:*", diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index 227eaeedeb2..fdbf6bf3b2d 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -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,17 +124,19 @@ export class AppWrapper extends Component { - -
- {config.featureToggles.appSidecar ? ( - - ) : ( - - )} - - -
-
+ + +
+ {config.featureToggles.appSidecar ? ( + + ) : ( + + )} + + +
+
+
diff --git a/public/app/app.ts b/public/app/app.ts index 02c491c90c0..f576ba415a0 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -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(); } diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index 3eeea0af2fe..dae29866cb1 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -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 && {state.actions}} + {(state.actions || scopes?.state.enabled) && {state.actions}} )} diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBarActions.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBarActions.tsx index 51e5bfad6d3..363327f23a1 100644 --- a/public/app/core/components/AppChrome/TopBar/SingleTopBarActions.tsx +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBarActions.tsx @@ -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 ? : undefined; + const childrenRender = children ? ( + + {children} + + ) : undefined; return (
- - {children} - + {scopesRender ? ( + + {scopesRender} + {children} + + ) : ( + childrenRender + )}
); } diff --git a/public/app/features/dashboard-scene/scene/DashboardReloadBehavior.ts b/public/app/features/dashboard-scene/scene/DashboardReloadBehavior.ts index 71b13b7ecd0..ba2bffd15cd 100644 --- a/public/app/features/dashboard-scene/scene/DashboardReloadBehavior.ts +++ b/public/app/features/dashboard-scene/scene/DashboardReloadBehavior.ts @@ -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,40 +21,52 @@ export interface DashboardReloadBehaviorState extends SceneObjectState { } export class DashboardReloadBehavior extends SceneObjectBase { - 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) { - this.addActivationHandler(() => { - getClosestScopesFacade(this)?.setState({ - handler: this.reloadDashboard, - }); - - this._variableDependency = new VariableDependencyConfig(this, { - onAnyVariableChanged: this.reloadDashboard, - }); - - this._subs.add( - sceneGraph.getTimeRange(this).subscribeToState((newState, prevState) => { - if (!isEqual(newState.value, prevState.value)) { - this.reloadDashboard(); - } - }) - ); - - this.reloadDashboard(); + const shouldReload = !!this.state.uid && !!this.state.reloadOnParamsChange; + + this.addActivationHandler(() => { + 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( + this._timeRange.subscribeToState((newState, prevState) => { + if (!isEqual(newState.value, prevState.value)) { + this.reloadDashboard(); + } + }) + ); + + this.reloadDashboard(); + }); } private isEditing() { - return this.parent && 'isEditing' in this.parent.state && this.parent.state.isEditing; + return !!this._dashboardScene?.state.isEditing; } private isWaitingForVariables() { @@ -56,28 +76,28 @@ export class DashboardReloadBehavior extends SceneObjectBase { - getDashboardScenePageStateManager().reloadDashboard({ - version: this.state.version!, - scopes: getClosestScopesFacade(this)?.value.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(), - }, - variables: sceneGraph.getVariables(this).state.variables.reduce( - (acc, variable) => ({ - ...acc, - ...variable.urlSync?.getUrlState(), - }), - {} - ), - }); - }); + 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: 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: this._timeRange!.state.value.from.toISOString(), + to: this._timeRange!.state.value.to.toISOString(), + }, + variables: sceneGraph.getVariables(this).state.variables.reduce( + (acc, variable) => ({ + ...acc, + ...variable.urlSync?.getUrlState(), + }), + {} + ), + }); + }); } } diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 454eb0a25e2..0ffc53520c4 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -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 implements LayoutParent { @@ -169,16 +161,14 @@ export class DashboardScene extends SceneObjectBase 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['metadata'] @@ -194,16 +184,17 @@ export class DashboardScene extends SceneObjectBase 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 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 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 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 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 impleme yesText: 'Discard', onConfirm: () => { this.exitEditModeConfirmed(); - this._scopesFacade?.exitReadOnly(); + this.state.scopesBridge?.setReadOnly(false); }, }) ); @@ -650,13 +644,6 @@ export class DashboardScene extends SceneObjectBase impleme panelId, panelName: panel?.state?.title, panelPluginId: panel?.state.pluginId, - scopes: this._scopesFacade?.value, - }; - } - - public enrichFiltersRequest(): Partial { - return { - scopes: this._scopesFacade?.value, }; } diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx index e30068924e7..f5082717e23 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx @@ -14,7 +14,7 @@ import { PanelSearchLayout } from './PanelSearchLayout'; import { DashboardAngularDeprecationBanner } from './angular/DashboardAngularDeprecationBanner'; export function DashboardSceneRenderer({ model }: SceneComponentProps) { - 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 + {scopesBridge && } {overlay && } @@ -62,6 +63,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps + {scopesBridge && } {editPanel && } {!editPanel && ( { - 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, - }); - } -} diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 65eed8a797d..72da8e22114 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -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 ( - {showScopesSelector && } {leftActionsElements.length > 0 && {leftActionsElements}} {rightActionsElements} diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index 43219038d22..84ec56090fa 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -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 { + return {children}; +}; diff --git a/public/app/features/scopes/ScopesDashboards.tsx b/public/app/features/scopes/ScopesDashboards.tsx deleted file mode 100644 index e3b5227af86..00000000000 --- a/public/app/features/scopes/ScopesDashboards.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { scopesDashboardsScene } from './instance'; - -export function ScopesDashboards() { - if (!scopesDashboardsScene) { - return null; - } - - return ; -} diff --git a/public/app/features/scopes/ScopesFacadeScene.ts b/public/app/features/scopes/ScopesFacadeScene.ts deleted file mode 100644 index 0d8a5f4c0e2..00000000000 --- a/public/app/features/scopes/ScopesFacadeScene.ts +++ /dev/null @@ -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 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(); - } -} diff --git a/public/app/features/scopes/ScopesSelector.tsx b/public/app/features/scopes/ScopesSelector.tsx deleted file mode 100644 index b7b4080d78b..00000000000 --- a/public/app/features/scopes/ScopesSelector.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { scopesSelectorScene } from './instance'; - -export function ScopesSelector() { - if (!scopesSelectorScene) { - return null; - } - - return ; -} diff --git a/public/app/features/scopes/ScopesService.ts b/public/app/features/scopes/ScopesService.ts new file mode 100644 index 00000000000..83563b18f87 --- /dev/null +++ b/public/app/features/scopes/ScopesService.ts @@ -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 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; + }; +} diff --git a/public/app/features/scopes/ScopesServiceBase.ts b/public/app/features/scopes/ScopesServiceBase.ts new file mode 100644 index 00000000000..4c0f637c92e --- /dev/null +++ b/public/app/features/scopes/ScopesServiceBase.ts @@ -0,0 +1,31 @@ +import { BehaviorSubject, Observable, pairwise, Subscription } from 'rxjs'; + +import { getAPINamespace } from '../../api/utils'; + +export abstract class ScopesServiceBase { + private _state: BehaviorSubject; + protected _fetchSub: Subscription | undefined; + protected _apiGroup = 'scope.grafana.app'; + protected _apiVersion = 'v0alpha1'; + protected _apiNamespace = getAPINamespace(); + + protected constructor(initialState: T) { + this._state = new BehaviorSubject(Object.freeze(initialState)); + } + + public get state(): T { + return this._state.getValue(); + } + + public get stateObservable(): Observable { + 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) => { + this._state.next(Object.freeze({ ...this.state, ...newState })); + }; +} diff --git a/public/app/features/scopes/dashboards/ScopesDashboards.tsx b/public/app/features/scopes/dashboards/ScopesDashboards.tsx new file mode 100644 index 00000000000..d27eba0e10e --- /dev/null +++ b/public/app/features/scopes/dashboards/ScopesDashboards.tsx @@ -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 ( +
+ No scopes selected +
+ ); + } else if (dashboards.length === 0) { + return ( +
+ No dashboards found for the selected scopes +
+ ); + } + } + + return ( +
+ + + {loading ? ( + + ) : filteredFolders[''] ? ( + + + + ) : ( +

+ No results found for your query + + +

+ )} +
+ ); +} + +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', + }), + }; +}; diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts new file mode 100644 index 00000000000..e2817635473 --- /dev/null +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts @@ -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 { + 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( + (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((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 => { + 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; + }; +} diff --git a/public/app/features/scopes/internal/ScopesDashboardsTree.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx similarity index 100% rename from public/app/features/scopes/internal/ScopesDashboardsTree.tsx rename to public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx diff --git a/public/app/features/scopes/internal/ScopesDashboardsTreeDashboardItem.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTreeDashboardItem.tsx similarity index 100% rename from public/app/features/scopes/internal/ScopesDashboardsTreeDashboardItem.tsx rename to public/app/features/scopes/dashboards/ScopesDashboardsTreeDashboardItem.tsx diff --git a/public/app/features/scopes/internal/ScopesDashboardsTreeFolderItem.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx similarity index 84% rename from public/app/features/scopes/internal/ScopesDashboardsTreeFolderItem.tsx rename to public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx index 8278cab8f82..333a2d3f6bc 100644 --- a/public/app/features/scopes/internal/ScopesDashboardsTreeFolderItem.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx @@ -23,23 +23,23 @@ export function ScopesDashboardsTreeFolderItem({ const styles = useStyles2(getStyles); return ( -
+
- {folder.isExpanded && ( + {folder.expanded && (
diff --git a/public/app/features/scopes/internal/ScopesDashboardsTreeSearch.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTreeSearch.tsx similarity index 77% rename from public/app/features/scopes/internal/ScopesDashboardsTreeSearch.tsx rename to public/app/features/scopes/dashboards/ScopesDashboardsTreeSearch.tsx index d9b971642a5..50e80b1e154 100644 --- a/public/app/features/scopes/internal/ScopesDashboardsTreeSearch.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboardsTreeSearch.tsx @@ -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 })} />
); diff --git a/public/app/features/scopes/dashboards/types.ts b/public/app/features/scopes/dashboards/types.ts new file mode 100644 index 00000000000..acf478e346a --- /dev/null +++ b/public/app/features/scopes/dashboards/types.ts @@ -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; +export type SuggestedDashboardsFoldersMap = Record; + +export type OnFolderUpdate = (path: string[], expanded: boolean) => void; diff --git a/public/app/features/scopes/index.ts b/public/app/features/scopes/index.ts deleted file mode 100644 index ef7b408291d..00000000000 --- a/public/app/features/scopes/index.ts +++ /dev/null @@ -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'; diff --git a/public/app/features/scopes/instance.tsx b/public/app/features/scopes/instance.tsx deleted file mode 100644 index fa29bf23c45..00000000000 --- a/public/app/features/scopes/instance.tsx +++ /dev/null @@ -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() }); - } -} diff --git a/public/app/features/scopes/internal/ScopesDashboardsScene.tsx b/public/app/features/scopes/internal/ScopesDashboardsScene.tsx deleted file mode 100644 index 666fdc7927d..00000000000 --- a/public/app/features/scopes/internal/ScopesDashboardsScene.tsx +++ /dev/null @@ -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 | 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 = () => ({ - dashboards: [], - folders: {}, - filteredFolders: {}, - forScopeNames: [], - isLoading: false, - isPanelOpened: false, - isEnabled: false, - isReadOnly: false, - searchQuery: '', -}); - -export class ScopesDashboardsScene extends SceneObjectBase { - 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) { - 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 ( -
- No scopes selected -
- ); - } else if (dashboards.length === 0) { - return ( -
- No dashboards found for the selected scopes -
- ); - } - } - - return ( -
- model.changeSearchQuery(value)} - /> - - {isLoading ? ( - - ) : filteredFolders[''] ? ( - - model.updateFolder(path, isExpanded)} - /> - - ) : ( -

- No results found for your query - - -

- )} -
- ); -} - -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', - }), - }; -}; diff --git a/public/app/features/scopes/internal/ScopesSelectorScene.tsx b/public/app/features/scopes/internal/ScopesSelectorScene.tsx deleted file mode 100644 index 41b0214fc90..00000000000 --- a/public/app/features/scopes/internal/ScopesSelectorScene.tsx +++ /dev/null @@ -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 | null; - nodes: NodesMap; - loadingNodeName: string | undefined; - scopes: SelectedScope[]; - treeScopes: TreeScope[]; - isReadOnly: boolean; - isLoadingScopes: boolean; - isPickerOpened: boolean; - isEnabled: boolean; -} - -export const initialSelectorState: Omit = { - 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 { - 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((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((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) { - 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 ( -
- dashboards?.togglePanel()} - /> - - model.openPicker()} - onRemoveAllClick={() => model.removeAllScopes()} - /> - - {isPickerOpened && ( - { - model.closePicker(); - model.resetDirtyScopeNames(); - }} - > -
-
- {isLoadingScopes ? ( - - ) : ( - model.updateNode(path, isExpanded, query)} - onNodeSelectToggle={(path) => model.toggleNodeSelect(path)} - /> - )} -
- -
- - -
-
-
- )} -
- ); -} - -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), - }), - }; -}; diff --git a/public/app/features/scopes/internal/api.ts b/public/app/features/scopes/internal/api.ts deleted file mode 100644 index 1b0dae4cce9..00000000000 --- a/public/app/features/scopes/internal/api.ts +++ /dev/null @@ -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({ - group, - version, - resource: 'scopes', -}); - -const scopesCache = new Map>(); - -async function fetchScopeNodes(parent: string, query: string): Promise { - try { - return (await getBackendSrv().get<{ items: ScopeNode[] }>(nodesEndpoint, { parent, query }))?.items ?? []; - } catch (err) { - return []; - } -} - -export async function fetchNodes(parent: string, query: string): Promise { - return (await fetchScopeNodes(parent, query)).reduce((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 { - if (scopesCache.has(name)) { - return scopesCache.get(name)!; - } - - const response = new Promise(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 { - return await Promise.all(names.map(fetchScope)); -} - -export async function fetchSelectedScopes(treeScopes: TreeScope[]): Promise { - const scopes = await fetchScopes(treeScopes.map(({ scopeName }) => scopeName)); - - return scopes.reduce((acc, scope, idx) => { - acc.push({ - scope, - path: treeScopes[idx].path, - }); - - return acc; - }, []); -} - -export async function fetchDashboards(scopeNames: string[]): Promise { - try { - const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(dashboardsEndpoint, { - scope: scopeNames, - }); - - return response?.items ?? []; - } catch (err) { - return []; - } -} diff --git a/public/app/features/scopes/internal/types.ts b/public/app/features/scopes/internal/types.ts deleted file mode 100644 index ffa200ad18c..00000000000 --- a/public/app/features/scopes/internal/types.ts +++ /dev/null @@ -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; - -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; -export type SuggestedDashboardsFoldersMap = Record; - -export type OnNodeUpdate = (path: string[], isExpanded: boolean, query: string) => void; -export type OnNodeSelectToggle = (path: string[]) => void; -export type OnFolderUpdate = (path: string[], isExpanded: boolean) => void; diff --git a/public/app/features/scopes/internal/utils.ts b/public/app/features/scopes/internal/utils.ts deleted file mode 100644 index 764febf77bf..00000000000 --- a/public/app/features/scopes/internal/utils.ts +++ /dev/null @@ -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>((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( - (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((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; - }, {}); -} diff --git a/public/app/features/scopes/internal/ScopesInput.tsx b/public/app/features/scopes/selector/ScopesInput.tsx similarity index 81% rename from public/app/features/scopes/internal/ScopesInput.tsx rename to public/app/features/scopes/selector/ScopesInput.tsx index 922dbf19a3b..0c163fcc5bc 100644 --- a/public/app/features/scopes/internal/ScopesInput.tsx +++ b/public/app/features/scopes/selector/ScopesInput.tsx @@ -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({ 0 && !isDisabled ? ( + scopes.length > 0 && !disabled ? ( ) : 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 ( - + {input} ); diff --git a/public/app/features/scopes/selector/ScopesSelector.tsx b/public/app/features/scopes/selector/ScopesSelector.tsx new file mode 100644 index 00000000000..002a9ed73fd --- /dev/null +++ b/public/app/features/scopes/selector/ScopesSelector.tsx @@ -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 ( +
+ + + + + {opened && ( + +
+
+ {loading ? ( + + ) : ( + + )} +
+ +
+ + +
+
+
+ )} +
+ ); +}; + +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), + }), + }; +}; diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts new file mode 100644 index 00000000000..705145829d3 --- /dev/null +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -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 { + static #instance: ScopesSelectorService | undefined = undefined; + + private _scopesCache = new Map>(); + + 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((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((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>((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 => { + 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((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 => { + if (this._scopesCache.has(name)) { + return this._scopesCache.get(name)!; + } + + const response = new Promise(async (resolve) => { + const basicScope = this.getBasicScope(name); + + try { + const serverScope = await getBackendSrv().get( + `/apis/${this._apiGroup}/${this._apiVersion}/namespaces/${this._apiNamespace}/scopes/${name}` + ); + + const scope = { + ...basicScope, + ...serverScope, + metadata: { + ...basicScope.metadata, + ...serverScope.metadata, + }, + spec: { + ...basicScope.spec, + ...serverScope.spec, + }, + }; + + resolve(scope); + } catch (err) { + this._scopesCache.delete(name); + + resolve(basicScope); + } + }); + + this._scopesCache.set(name, response); + + return response; + }; + + public fetchScopesApi = async (treeScopes: TreeScope[]): Promise => { + const scopes = await Promise.all(treeScopes.map(({ scopeName }) => this.fetchScopeApi(scopeName))); + + return scopes.reduce((acc, scope, idx) => { + acc.push({ + scope, + path: treeScopes[idx].path, + }); + + return acc; + }, []); + }; + + public reset = () => { + ScopesSelectorService.#instance = undefined; + }; +} diff --git a/public/app/features/scopes/internal/ScopesTree.tsx b/public/app/features/scopes/selector/ScopesTree.tsx similarity index 86% rename from public/app/features/scopes/internal/ScopesTree.tsx rename to public/app/features/scopes/selector/ScopesTree.tsx index d4116f6ba1e..ed37df63729 100644 --- a/public/app/features/scopes/internal/ScopesTree.tsx +++ b/public/app/features/scopes/selector/ScopesTree.tsx @@ -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 = 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} /> - + ; - 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 = (
{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({
-
- {childNode.isSelectable && !childNode.isExpanded ? ( +
+ {childNode.selectable && !childNode.expanded ? ( node.disableMultiSelect ? ( { @@ -80,7 +80,7 @@ export function ScopesTreeItem({ /> ) : ( { onNodeSelectToggle(childNodePath); @@ -89,18 +89,18 @@ export function ScopesTreeItem({ ) ) : null} - {childNode.isExpandable ? ( + {childNode.expandable ? ( @@ -110,7 +110,7 @@ export function ScopesTreeItem({
- {childNode.isExpanded && ( + {childNode.expanded && ( ); - if (isLastExpandedNode) { + if (lastExpandedNode) { return ( ; } diff --git a/public/app/features/scopes/internal/ScopesTreeSearch.tsx b/public/app/features/scopes/selector/ScopesTreeSearch.tsx similarity index 82% rename from public/app/features/scopes/internal/ScopesTreeSearch.tsx rename to public/app/features/scopes/selector/ScopesTreeSearch.tsx index 273d2fa24b0..f8bc5c071ec 100644 --- a/public/app/features/scopes/internal/ScopesTreeSearch.tsx +++ b/public/app/features/scopes/selector/ScopesTreeSearch.tsx @@ -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 }); }} /> ); diff --git a/public/app/features/scopes/selector/types.ts b/public/app/features/scopes/selector/types.ts new file mode 100644 index 00000000000..672a6f158b4 --- /dev/null +++ b/public/app/features/scopes/selector/types.ts @@ -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; + +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; diff --git a/public/app/features/scopes/tests/dashboardReload.test.ts b/public/app/features/scopes/tests/dashboardReload.test.ts index 24b07bca51a..7973842c33e 100644 --- a/public/app/features/scopes/tests/dashboardReload.test.ts +++ b/public/app/features/scopes/tests/dashboardReload.test.ts @@ -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]); } ); }); diff --git a/public/app/features/scopes/tests/dashboardsList.test.ts b/public/app/features/scopes/tests/dashboardsList.test.ts index 3b5f2fe1885..895337f69d2 100644 --- a/public/app/features/scopes/tests/dashboardsList.test.ts +++ b/public/app/features/scopes/tests/dashboardsList.test.ts @@ -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: [], + }, + }, + }, + }); + }); + }); }); diff --git a/public/app/features/scopes/tests/featureFlag.test.ts b/public/app/features/scopes/tests/featureFlag.test.ts deleted file mode 100644 index efe209a6f5b..00000000000 --- a/public/app/features/scopes/tests/featureFlag.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/public/app/features/scopes/tests/selector.test.ts b/public/app/features/scopes/tests/selector.test.ts index 6435de62803..951c6f54b2e 100644 --- a/public/app/features/scopes/tests/selector.test.ts +++ b/public/app/features/scopes/tests/selector.test.ts @@ -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(); }); }); diff --git a/public/app/features/scopes/tests/tree.test.ts b/public/app/features/scopes/tests/tree.test.ts index 983393ca204..186e6fe06f0 100644 --- a/public/app/features/scopes/tests/tree.test.ts +++ b/public/app/features/scopes/tests/tree.test.ts @@ -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'); }); diff --git a/public/app/features/scopes/tests/utils.test.ts b/public/app/features/scopes/tests/utils.test.ts deleted file mode 100644 index 7b37541fb05..00000000000 --- a/public/app/features/scopes/tests/utils.test.ts +++ /dev/null @@ -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: [], - }, - }, - }, - }); - }); - }); -}); diff --git a/public/app/features/scopes/tests/utils/actions.ts b/public/app/features/scopes/tests/utils/actions.ts index 9781fee2b6d..5972a430fc5 100644 --- a/public/app/features/scopes/tests/utils/actions.ts +++ b/public/app/features/scopes/tests/utils/actions.ts @@ -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); diff --git a/public/app/features/scopes/tests/utils/assertions.ts b/public/app/features/scopes/tests/utils/assertions.ts index f58d73de50f..c842e81b3fa 100644 --- a/public/app/features/scopes/tests/utils/assertions.ts +++ b/public/app/features/scopes/tests/utils/assertions.ts @@ -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) => diff --git a/public/app/features/scopes/tests/utils/mocks.ts b/public/app/features/scopes/tests/utils/mocks.ts index 0ac0a785237..f61e251a8bb 100644 --- a/public/app/features/scopes/tests/utils/mocks.ts +++ b/public/app/features/scopes/tests/utils/mocks.ts @@ -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 = [ }, ] 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 diff --git a/public/app/features/scopes/tests/utils/render.tsx b/public/app/features/scopes/tests/utils/render.tsx index 32ff0010d48..66072bd43b2 100644 --- a/public/app/features/scopes/tests/utils/render.tsx +++ b/public/app/features/scopes/tests/utils/render.tsx @@ -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, @@ -176,33 +178,38 @@ setPluginImportUtils({ getPanelPluginFromCache: () => undefined, }); -export function renderDashboard( +export async function renderDashboard( overrideDashboard: Partial = {}, overrideMeta: Partial = {} ) { jest.useFakeTimers({ advanceTimers: true }); jest.spyOn(console, 'error').mockImplementation(jest.fn()); - clearMocks(); - initializeScopes(); const dto: DashboardDTO = getDashboardDTO(overrideDashboard, overrideMeta); const scene = transformSaveModelToScene(dto); render( - - - + + + + + ); + 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(); } diff --git a/public/app/features/scopes/tests/utils/selectors.ts b/public/app/features/scopes/tests/utils/selectors.ts index 689e82ca87f..e5ef25e26bb 100644 --- a/public/app/features/scopes/tests/utils/selectors.ts +++ b/public/app/features/scopes/tests/utils/selectors.ts @@ -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(selectors.selector.input); -export const querySelectorInput = () => screen.queryByTestId(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(selectors.dashboards.search); @@ -88,8 +87,9 @@ export const getResultCloudDevRadio = () => export const getResultCloudOpsRadio = () => screen.getByTestId(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); diff --git a/public/app/features/scopes/tests/viewMode.test.ts b/public/app/features/scopes/tests/viewMode.test.ts index 89d698b9b0f..ba115534f98 100644 --- a/public/app/features/scopes/tests/viewMode.test.ts +++ b/public/app/features/scopes/tests/viewMode.test.ts @@ -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(); }); }); diff --git a/public/app/features/scopes/useScopesDashboardsState.ts b/public/app/features/scopes/useScopesDashboardsState.ts deleted file mode 100644 index 7fc2e7d385a..00000000000 --- a/public/app/features/scopes/useScopesDashboardsState.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { scopesDashboardsScene } from './instance'; - -export const useScopesDashboardsState = () => { - return scopesDashboardsScene?.useState(); -}; diff --git a/public/app/features/scopes/utils.ts b/public/app/features/scopes/utils.ts deleted file mode 100644 index 29ddd616382..00000000000 --- a/public/app/features/scopes/utils.ts +++ /dev/null @@ -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; -} diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx index aef53bad053..3a6502f894f 100644 --- a/public/app/features/trails/DataTrail.tsx +++ b/public/app/features/trails/DataTrail.tsx @@ -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 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); diff --git a/public/app/features/trails/DataTrailsApp.tsx b/public/app/features/trails/DataTrailsApp.tsx index b1fdf17a467..156a77e3e47 100644 --- a/public/app/features/trails/DataTrailsApp.tsx +++ b/public/app/features/trails/DataTrailsApp.tsx @@ -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 { - private _scopesFacade: ScopesFacade | null; + protected _renderBeforeActivation = true; public constructor(state: DataTrailsAppState) { super(state); - - this._scopesFacade = getClosestScopesFacade(this); - } - - public enrichDataRequest(): Partial { - if (!config.featureToggles.promQLScope) { - return {}; - } - - return { - scopes: this._scopesFacade?.value, - }; - } - - public enrichFiltersRequest(): Partial { - if (!config.featureToggles.promQLScope) { - return {}; - } - - return { - scopes: this._scopesFacade?.value, - }; } goToUrlForTrail(trail: DataTrail) { @@ -62,33 +37,35 @@ export class DataTrailsApp extends SceneObjectBase { } static Component = ({ model }: SceneComponentProps) => { - const { trail, home } = model.useState(); + const { trail, home, scopesBridge } = model.useState(); return ( - - {/* The routes are relative to the HOME_ROUTE */} - null} - subTitle="" - > - - - } - /> - } /> - + <> + {scopesBridge && } + + {/* The routes are relative to the HOME_ROUTE */} + null} + subTitle="" + > + + + } + /> + } /> + + ); }; } 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 ( - {config.featureToggles.enableScopesInMetricsExplore && ( - - -
- } - /> - )} @@ -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(); - } - }, - }), - ] - : undefined; + 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', - }), -}); diff --git a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx index 8a3fcd462e3..9cea458adaa 100644 --- a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx +++ b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx @@ -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 i const response = await getMetricNames( datasourceUid, timeRange, - getSelectedScopes(), + sceneGraph.getScopesBridge(this)?.getValue() ?? [], filters, jobsList, instancesList, diff --git a/public/app/features/trails/utils.test.ts b/public/app/features/trails/utils.test.ts index 04b15995982..9552b691641 100644 --- a/public/app/features/trails/utils.test.ts +++ b/public/app/features/trails/utils.test.ts @@ -50,6 +50,7 @@ describe('limitAdhocProviders', () => { } as unknown as MetricDatasourceHelper; dataTrail = { + forEachChild: jest.fn(), getQueries: jest.fn().mockReturnValue([]), } as unknown as DataTrail; }); diff --git a/public/app/features/trails/utils.ts b/public/app/features/trails/utils.ts index 860dc630a30..2b721161203 100644 --- a/public/app/features/trails/utils.ts +++ b/public/app/features/trails/utils.ts @@ -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(), }; diff --git a/yarn.lock b/yarn.lock index 3171da05a1d..a65a3d0f5dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"