+
)}
@@ -163,13 +156,6 @@ function getStyles(theme: GrafanaTheme2) {
"panels"`,
gridTemplateRows: 'auto 1fr',
}),
- pageContainerWithScopes: css({
- gridTemplateAreas: `
- "scopes controls"
- "panels panels"`,
- gridTemplateColumns: `${theme.spacing(32)} 1fr`,
- gridTemplateRows: 'auto 1fr',
- }),
container: css({
gridArea: 'panels',
height: '100%',
@@ -225,9 +211,6 @@ function getStyles(theme: GrafanaTheme2) {
gridArea: 'controls',
padding: theme.spacing(2, 0, 2, 2),
}),
- controlsWrapperWithScopes: css({
- padding: theme.spacing(2, 0),
- }),
openDataPaneButton: css({
width: theme.spacing(8),
justifyContent: 'center',
diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx
index 4f39bb8b104..9de61780452 100644
--- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx
@@ -34,6 +34,7 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { deleteDashboard } from 'app/features/manage-dashboards/state/actions';
+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';
@@ -74,7 +75,6 @@ import { DashboardSceneRenderer } from './DashboardSceneRenderer';
import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
import { LibraryVizPanel } from './LibraryVizPanel';
import { RowRepeaterBehavior } from './RowRepeaterBehavior';
-import { ScopesScene } from './Scopes/ScopesScene';
import { ViewPanelScene } from './ViewPanelScene';
import { setupKeyboardShortcuts } from './keyboardShortcuts';
@@ -123,8 +123,6 @@ export interface DashboardSceneState extends SceneObjectState {
overlay?: SceneObject;
/** The dashboard doesn't have panels */
isEmpty?: boolean;
- /** Scene object that handles the scopes selector */
- scopes?: ScopesScene;
/** Kiosk mode */
kioskMode?: KioskMode;
}
@@ -163,6 +161,11 @@ export class DashboardScene extends SceneObjectBase
{
*/
private _fromExplore = false;
+ /**
+ * A reference to the scopes facade
+ */
+ private _scopesFacade: ScopesFacade | null;
+
public constructor(state: Partial) {
super({
title: 'Dashboard',
@@ -170,10 +173,11 @@ export class DashboardScene extends SceneObjectBase {
editable: true,
body: state.body ?? new SceneFlexLayout({ children: [] }),
links: state.links ?? [],
- scopes: state.uid && config.featureToggles.scopeFilters ? new ScopesScene() : undefined,
...state,
});
+ this._scopesFacade = getClosestScopesFacade(this);
+
this._changeTracker = new DashboardSceneChangeTracker(this);
this.addActivationHandler(() => this._activationHandler());
@@ -229,6 +233,9 @@ export class DashboardScene extends SceneObjectBase {
// Propagate change edit mode change to children
this.propagateEditModeChange();
+ // Propagate edit mode to scopes
+ this._scopesFacade?.enterReadOnly();
+
this._changeTracker.startTrackingChanges();
};
@@ -274,6 +281,7 @@ export class DashboardScene extends SceneObjectBase {
if (!this.state.isDirty || skipConfirm) {
this.exitEditModeConfirmed(restoreInitialState || this.state.isDirty);
+ this._scopesFacade?.exitReadOnly();
return;
}
@@ -283,7 +291,10 @@ export class DashboardScene extends SceneObjectBase {
text: `You have unsaved changes to this dashboard. Are you sure you want to discard them?`,
icon: 'trash-alt',
yesText: 'Discard',
- onConfirm: this.exitEditModeConfirmed.bind(this),
+ onConfirm: () => {
+ this.exitEditModeConfirmed();
+ this._scopesFacade?.exitReadOnly();
+ },
})
);
}
@@ -846,13 +857,13 @@ export class DashboardScene extends SceneObjectBase {
dashboardUID: this.state.uid,
panelId,
panelPluginId: panel?.state.pluginId,
- scopes: this.state.scopes?.getSelectedScopes(),
+ scopes: this._scopesFacade?.value,
};
}
public enrichFiltersRequest(): Partial {
return {
- scopes: this.state.scopes?.getSelectedScopes(),
+ 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 a06650ad702..149c884462f 100644
--- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx
@@ -17,8 +17,7 @@ import { DashboardScene } from './DashboardScene';
import { NavToolbarActions } from './NavToolbarActions';
export function DashboardSceneRenderer({ model }: SceneComponentProps) {
- const { controls, overlay, editview, editPanel, isEmpty, scopes, meta } = model.useState();
- const { isExpanded: isScopesExpanded } = scopes?.useState() ?? {};
+ const { controls, overlay, editview, editPanel, isEmpty, meta } = model.useState();
const styles = useStyles2(getStyles);
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
@@ -60,20 +59,10 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps
{editPanel && }
{!editPanel && (
-
- {scopes && !meta.dashboardNotFound &&
}
+
{controls && (
-
+
)}
@@ -118,18 +107,6 @@ function getStyles(theme: GrafanaTheme2) {
"panels"`,
gridTemplateRows: 'auto 1fr',
}),
- pageContainerWithScopes: css({
- gridTemplateAreas: `
- "scopes controls"
- "panels panels"`,
- gridTemplateColumns: `${theme.spacing(32)} 1fr`,
- gridTemplateRows: 'auto 1fr',
- }),
- pageContainerWithScopesExpanded: css({
- gridTemplateAreas: `
- "scopes controls"
- "scopes panels"`,
- }),
panelsContainer: css({
gridArea: 'panels',
}),
@@ -143,9 +120,6 @@ function getStyles(theme: GrafanaTheme2) {
display: 'none',
},
}),
- controlsWrapperWithScopes: css({
- padding: theme.spacing(2, 2, 2, 0),
- }),
canvasContent: css({
label: 'canvas-content',
display: 'flex',
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx
deleted file mode 100644
index d9bc1cc88d5..00000000000
--- a/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-import { css } from '@emotion/css';
-import { Link } from 'react-router-dom';
-
-import { GrafanaTheme2, Scope, urlUtil } from '@grafana/data';
-import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
-import { Button, CustomScrollbar, FilterInput, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
-import { useQueryParams } from 'app/core/hooks/useQueryParams';
-import { t, Trans } from 'app/core/internationalization';
-
-import { fetchSuggestedDashboards } from './api';
-import { SuggestedDashboard } from './types';
-
-export interface ScopesDashboardsSceneState extends SceneObjectState {
- dashboards: SuggestedDashboard[];
- filteredDashboards: SuggestedDashboard[];
- isLoading: boolean;
- scopesSelected: boolean;
- searchQuery: string;
-}
-
-export class ScopesDashboardsScene extends SceneObjectBase
{
- static Component = ScopesDashboardsSceneRenderer;
-
- constructor() {
- super({
- dashboards: [],
- filteredDashboards: [],
- isLoading: false,
- scopesSelected: false,
- searchQuery: '',
- });
- }
-
- public async fetchDashboards(scopes: Scope[]) {
- if (scopes.length === 0) {
- return this.setState({ dashboards: [], filteredDashboards: [], isLoading: false, scopesSelected: false });
- }
-
- this.setState({ isLoading: true });
-
- const dashboards = await fetchSuggestedDashboards(scopes);
-
- this.setState({
- dashboards,
- filteredDashboards: this.filterDashboards(dashboards, this.state.searchQuery),
- isLoading: false,
- scopesSelected: scopes.length > 0,
- });
- }
-
- public changeSearchQuery(searchQuery: string) {
- this.setState({
- filteredDashboards: searchQuery
- ? this.filterDashboards(this.state.dashboards, searchQuery)
- : this.state.dashboards,
- searchQuery: searchQuery ?? '',
- });
- }
-
- private filterDashboards(dashboards: SuggestedDashboard[], searchQuery: string): SuggestedDashboard[] {
- const lowerCasedSearchQuery = searchQuery.toLowerCase();
-
- return dashboards.filter(({ dashboardTitle }) => dashboardTitle.toLowerCase().includes(lowerCasedSearchQuery));
- }
-}
-
-export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps) {
- const { dashboards, filteredDashboards, isLoading, searchQuery, scopesSelected } = model.useState();
- const styles = useStyles2(getStyles);
-
- const [queryParams] = useQueryParams();
-
- if (!isLoading) {
- if (!scopesSelected) {
- return (
-
- No scopes selected
-
- );
- } else if (dashboards.length === 0) {
- return (
-
-
- No dashboards found for the selected scopes
-
-
- );
- }
- }
-
- return (
- <>
-
- model.changeSearchQuery(value)}
- />
-
-
- {isLoading ? (
-
- ) : filteredDashboards.length > 0 ? (
-
- {filteredDashboards.map(({ dashboard, dashboardTitle }) => (
-
- {dashboardTitle}
-
- ))}
-
- ) : (
-
- No results found for your query
-
-
-
- )}
- >
- );
-}
-
-const getStyles = (theme: GrafanaTheme2) => {
- return {
- noResultsContainer: css({
- alignItems: 'center',
- display: 'flex',
- flexDirection: 'column',
- gap: theme.spacing(1),
- justifyContent: 'center',
- textAlign: 'center',
- }),
- searchInputContainer: css({
- flex: '0 1 auto',
- }),
- loadingIndicator: css({
- alignSelf: 'center',
- }),
- dashboardItem: css({
- padding: theme.spacing(1, 0),
- borderBottom: `1px solid ${theme.colors.border.weak}`,
-
- '& :is(:first-child)': {
- paddingTop: 0,
- },
- }),
- };
-};
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx
deleted file mode 100644
index e661f703950..00000000000
--- a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import { css, cx } from '@emotion/css';
-
-import { GrafanaTheme2 } from '@grafana/data';
-import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
-import { IconButton, useStyles2 } from '@grafana/ui';
-import { t } from 'app/core/internationalization';
-
-import { ScopesDashboardsScene } from './ScopesDashboardsScene';
-import { ScopesFiltersScene } from './ScopesFiltersScene';
-
-export interface ScopesSceneState extends SceneObjectState {
- dashboards: ScopesDashboardsScene;
- filters: ScopesFiltersScene;
- isExpanded: boolean;
- isViewing: boolean;
-}
-
-export class ScopesScene extends SceneObjectBase {
- static Component = ScopesSceneRenderer;
-
- constructor() {
- super({
- dashboards: new ScopesDashboardsScene(),
- filters: new ScopesFiltersScene(),
- isExpanded: false,
- isViewing: false,
- });
-
- this.addActivationHandler(() => {
- this._subs.add(
- this.state.filters.subscribeToState((newState, prevState) => {
- if (!newState.isLoadingScopes && newState.scopes !== prevState.scopes) {
- if (this.state.isExpanded) {
- this.state.dashboards.fetchDashboards(this.state.filters.getSelectedScopes());
- }
-
- sceneGraph.getTimeRange(this.parent!).onRefresh();
- }
- })
- );
-
- this._subs.add(
- this.parent?.subscribeToState((newState) => {
- const isEditing = 'isEditing' in newState ? !!newState.isEditing : false;
-
- if (isEditing !== this.state.isViewing) {
- if (isEditing) {
- this.enterViewMode();
- } else {
- this.exitViewMode();
- }
- }
- })
- );
- });
- }
-
- public getSelectedScopes() {
- return this.state.filters.getSelectedScopes();
- }
-
- public toggleIsExpanded() {
- const isExpanded = !this.state.isExpanded;
-
- if (isExpanded) {
- this.state.dashboards.fetchDashboards(this.getSelectedScopes());
- }
-
- this.setState({ isExpanded });
- }
-
- private enterViewMode() {
- this.setState({ isExpanded: false, isViewing: true });
-
- this.state.filters.enterViewMode();
- }
-
- private exitViewMode() {
- this.setState({ isViewing: false });
- }
-}
-
-export function ScopesSceneRenderer({ model }: SceneComponentProps) {
- const { filters, dashboards, isExpanded, isViewing } = model.useState();
- const styles = useStyles2(getStyles);
-
- return (
-
-
- {!isViewing && (
- model.toggleIsExpanded()}
- />
- )}
-
-
-
- {isExpanded && !isViewing && (
-
-
-
- )}
-
- );
-}
-
-const getStyles = (theme: GrafanaTheme2) => {
- return {
- container: css({
- display: 'flex',
- flexDirection: 'column',
- gridArea: 'scopes',
- }),
- containerExpanded: css({
- backgroundColor: theme.colors.background.primary,
- height: '100%',
- }),
- filtersContainer: css({
- display: 'flex',
- flex: '0 1 auto',
- flexDirection: 'row',
- padding: theme.spacing(2, 2, 2, 2),
- }),
- filtersContainerExpanded: css({
- borderBottom: `1px solid ${theme.colors.border.weak}`,
- padding: theme.spacing(2),
- }),
- iconNotExpanded: css({
- transform: 'scaleX(-1)',
- }),
- dashboardsContainer: css({
- display: 'flex',
- flex: '1 1 auto',
- flexDirection: 'column',
- gap: theme.spacing(3),
- overflow: 'hidden',
- padding: theme.spacing(2),
- }),
- };
-};
diff --git a/public/app/features/dashboard-scene/scene/Scopes/utils.ts b/public/app/features/dashboard-scene/scene/Scopes/utils.ts
deleted file mode 100644
index bbe68774966..00000000000
--- a/public/app/features/dashboard-scene/scene/Scopes/utils.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { Scope } from '@grafana/data';
-
-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,
- },
- };
-}
diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
index 5d45e4a4aa0..d03b55af774 100644
--- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
@@ -28,8 +28,10 @@ import {
UserActionEvent,
GroupByVariable,
AdHocFiltersVariable,
+ sceneGraph,
} from '@grafana/scenes';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
+import { ScopesFacade } from 'app/features/scopes';
import { DashboardDTO, DashboardDataDTO } from 'app/types';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
@@ -281,6 +283,9 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
registerPanelInteractionsReporter,
new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }),
preserveDashboardSceneStateInLocalStorage,
+ new ScopesFacade({
+ handler: (facade) => sceneGraph.getTimeRange(facade).onRefresh(),
+ }),
],
$data: new DashboardDataLayerSet({ annotationLayers, alertStatesLayer }),
controls: new DashboardControls({
diff --git a/public/app/features/dashboard/api/dashboard_api.ts b/public/app/features/dashboard/api/dashboard_api.ts
index 477495fda96..661a02379a8 100644
--- a/public/app/features/dashboard/api/dashboard_api.ts
+++ b/public/app/features/dashboard/api/dashboard_api.ts
@@ -10,10 +10,9 @@ import {
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types';
+import { getSelectedScopesNames } from 'app/features/scopes';
import { DashboardDTO, DashboardDataDTO, SaveDashboardResponseDTO } from 'app/types';
-import { getScopesFromUrl } from '../utils/getScopesFromUrl';
-
export interface DashboardAPI {
/** Get a dashboard with the access control metadata */
getDashboardDTO(uid: string): Promise;
@@ -43,8 +42,7 @@ class LegacyDashboardAPI implements DashboardAPI {
}
getDashboardDTO(uid: string): Promise {
- const scopesSearchParams = getScopesFromUrl();
- const scopes = scopesSearchParams?.getAll('scopes') ?? [];
+ const scopes = getSelectedScopesNames();
const queryParams = scopes.length > 0 ? { scopes } : undefined;
return getBackendSrv().get(`/api/dashboards/uid/${uid}`, queryParams);
diff --git a/public/app/features/dashboard/utils/getScopesFromUrl.ts b/public/app/features/dashboard/utils/getScopesFromUrl.ts
deleted file mode 100644
index e9539037fe5..00000000000
--- a/public/app/features/dashboard/utils/getScopesFromUrl.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { config, locationService } from '@grafana/runtime';
-
-export function getScopesFromUrl(): URLSearchParams | undefined {
- if (!config.featureToggles.scopeFilters || !config.featureToggles.passScopeToDashboardApi) {
- return undefined;
- }
-
- const queryParams = locationService.getSearchObject();
- const rawScopes = queryParams['scopes'] ?? [];
- const scopes = Array.isArray(rawScopes) ? rawScopes : [rawScopes];
-
- return new URLSearchParams(scopes.map((scope) => ['scopes', String(scope)]));
-}
diff --git a/public/app/features/scopes/ScopesDashboards.tsx b/public/app/features/scopes/ScopesDashboards.tsx
new file mode 100644
index 00000000000..e3b5227af86
--- /dev/null
+++ b/public/app/features/scopes/ScopesDashboards.tsx
@@ -0,0 +1,9 @@
+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
new file mode 100644
index 00000000000..db65da5f7a8
--- /dev/null
+++ b/public/app/features/scopes/ScopesFacadeScene.ts
@@ -0,0 +1,53 @@
+import { SceneObjectBase, SceneObjectState } 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;
+}
+
+export class ScopesFacade extends SceneObjectBase {
+ public constructor(state: ScopesFacadeState) {
+ super(state);
+
+ this.addActivationHandler(this._activationHandler);
+ }
+
+ private _activationHandler = () => {
+ this.enable();
+
+ this._subs.add(
+ scopesSelectorScene?.subscribeToState((newState, prevState) => {
+ if (!newState.isLoadingScopes && (prevState.isLoadingScopes || newState.scopes !== prevState.scopes)) {
+ 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
new file mode 100644
index 00000000000..b7b4080d78b
--- /dev/null
+++ b/public/app/features/scopes/ScopesSelector.tsx
@@ -0,0 +1,9 @@
+import { scopesSelectorScene } from './instance';
+
+export function ScopesSelector() {
+ if (!scopesSelectorScene) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/public/app/features/scopes/index.ts b/public/app/features/scopes/index.ts
new file mode 100644
index 00000000000..ad822091ddb
--- /dev/null
+++ b/public/app/features/scopes/index.ts
@@ -0,0 +1,14 @@
+export { initializeScopes } from './instance';
+export { ScopesDashboards } from './ScopesDashboards';
+/* eslint-disable */
+export { ScopesFacade } from './ScopesFacadeScene';
+export { ScopesSelector } from './ScopesSelector';
+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
new file mode 100644
index 00000000000..b1c1709b0d1
--- /dev/null
+++ b/public/app/features/scopes/instance.tsx
@@ -0,0 +1,21 @@
+import { config } from '@grafana/runtime';
+import { UrlSyncManager } from '@grafana/scenes';
+
+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() });
+
+ const urlSyncManager = new UrlSyncManager();
+ urlSyncManager.initSync(scopesSelectorScene!);
+ }
+}
diff --git a/public/app/features/scopes/internal/ScopesDashboardsScene.tsx b/public/app/features/scopes/internal/ScopesDashboardsScene.tsx
new file mode 100644
index 00000000000..eda28702643
--- /dev/null
+++ b/public/app/features/scopes/internal/ScopesDashboardsScene.tsx
@@ -0,0 +1,262 @@
+import { css, cx } from '@emotion/css';
+import { isEqual } from 'lodash';
+import { Link } from 'react-router-dom';
+
+import { GrafanaTheme2, urlUtil } from '@grafana/data';
+import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState } from '@grafana/scenes';
+import { Button, CustomScrollbar, FilterInput, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
+import { useQueryParams } from 'app/core/hooks/useQueryParams';
+import { t, Trans } from 'app/core/internationalization';
+
+import { ScopesSelectorScene } from './ScopesSelectorScene';
+import { fetchSuggestedDashboards } from './api';
+import { DASHBOARDS_OPENED_KEY } from './const';
+import { SuggestedDashboard } from './types';
+import { getScopeNamesFromSelectedScopes } from './utils';
+
+export interface ScopesDashboardsSceneState extends SceneObjectState {
+ selector: SceneObjectRef | null;
+ dashboards: SuggestedDashboard[];
+ filteredDashboards: SuggestedDashboard[];
+ forScopeNames: string[];
+ isLoading: boolean;
+ isPanelOpened: boolean;
+ isEnabled: boolean;
+ scopesSelected: boolean;
+ searchQuery: string;
+}
+
+export const getInitialDashboardsState: () => Omit = () => ({
+ dashboards: [],
+ filteredDashboards: [],
+ forScopeNames: [],
+ isLoading: false,
+ isPanelOpened: localStorage.getItem(DASHBOARDS_OPENED_KEY) === 'true',
+ isEnabled: false,
+ scopesSelected: false,
+ searchQuery: '',
+});
+
+export class ScopesDashboardsScene extends SceneObjectBase {
+ static Component = ScopesDashboardsSceneRenderer;
+
+ constructor() {
+ super({
+ selector: null,
+ ...getInitialDashboardsState(),
+ });
+
+ this.addActivationHandler(() => {
+ if (this.state.isPanelOpened) {
+ this.fetchDashboards();
+ }
+
+ const resolvedSelector = this.state.selector?.resolve();
+
+ if (resolvedSelector) {
+ this._subs.add(
+ resolvedSelector.subscribeToState((newState, prevState) => {
+ if (
+ this.state.isPanelOpened &&
+ !newState.isLoadingScopes &&
+ (prevState.isLoadingScopes || newState.scopes !== prevState.scopes)
+ ) {
+ this.fetchDashboards();
+ }
+ })
+ );
+ }
+ });
+ }
+
+ public async fetchDashboards() {
+ const scopeNames = getScopeNamesFromSelectedScopes(this.state.selector?.resolve().state.scopes ?? []);
+
+ if (isEqual(scopeNames, this.state.forScopeNames)) {
+ return;
+ }
+
+ if (scopeNames.length === 0) {
+ return this.setState({
+ dashboards: [],
+ filteredDashboards: [],
+ forScopeNames: [],
+ isLoading: false,
+ scopesSelected: false,
+ });
+ }
+
+ this.setState({ isLoading: true });
+
+ const dashboards = await fetchSuggestedDashboards(scopeNames);
+
+ this.setState({
+ dashboards,
+ filteredDashboards: this.filterDashboards(dashboards, this.state.searchQuery),
+ forScopeNames: scopeNames,
+ isLoading: false,
+ scopesSelected: scopeNames.length > 0,
+ });
+ }
+
+ public changeSearchQuery(searchQuery: string) {
+ this.setState({
+ filteredDashboards: searchQuery
+ ? this.filterDashboards(this.state.dashboards, searchQuery)
+ : this.state.dashboards,
+ searchQuery: searchQuery ?? '',
+ });
+ }
+
+ public togglePanel() {
+ if (this.state.isPanelOpened) {
+ this.closePanel();
+ } else {
+ this.openPanel();
+ }
+ }
+
+ public openPanel() {
+ this.fetchDashboards();
+ this.setState({ isPanelOpened: true });
+ localStorage.setItem(DASHBOARDS_OPENED_KEY, JSON.stringify(true));
+ }
+
+ public closePanel() {
+ this.setState({ isPanelOpened: false });
+ localStorage.setItem(DASHBOARDS_OPENED_KEY, JSON.stringify(false));
+ }
+
+ public enable() {
+ this.setState({ isEnabled: true });
+ }
+
+ public disable() {
+ this.setState({ isEnabled: false });
+ }
+
+ private filterDashboards(dashboards: SuggestedDashboard[], searchQuery: string): SuggestedDashboard[] {
+ const lowerCasedSearchQuery = searchQuery.toLowerCase();
+
+ return dashboards.filter(({ dashboardTitle }) => dashboardTitle.toLowerCase().includes(lowerCasedSearchQuery));
+ }
+}
+
+export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps) {
+ const { dashboards, filteredDashboards, isLoading, isPanelOpened, isEnabled, searchQuery, scopesSelected } =
+ model.useState();
+ const styles = useStyles2(getStyles);
+
+ const [queryParams] = useQueryParams();
+
+ if (!isEnabled || !isPanelOpened) {
+ return null;
+ }
+
+ if (!isLoading) {
+ if (!scopesSelected) {
+ return (
+
+ No scopes selected
+
+ );
+ } else if (dashboards.length === 0) {
+ return (
+
+ No dashboards found for the selected scopes
+
+ );
+ }
+ }
+
+ return (
+
+
+ model.changeSearchQuery(value)}
+ />
+
+
+ {isLoading ? (
+
+ ) : filteredDashboards.length > 0 ? (
+
+ {filteredDashboards.map(({ dashboard, dashboardTitle }) => (
+
+ {dashboardTitle}
+
+ ))}
+
+ ) : (
+
+ 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',
+ 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',
+ }),
+ searchInputContainer: css({
+ flex: '0 1 auto',
+ }),
+ loadingIndicator: css({
+ alignSelf: 'center',
+ }),
+ dashboardItem: css({
+ padding: theme.spacing(1, 0),
+ borderBottom: `1px solid ${theme.colors.border.weak}`,
+
+ '& :is(:first-child)': {
+ paddingTop: 0,
+ },
+ }),
+ };
+};
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx b/public/app/features/scopes/internal/ScopesInput.tsx
similarity index 80%
rename from public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx
rename to public/app/features/scopes/internal/ScopesInput.tsx
index 9e4e3d0e9bc..e440d38741f 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx
+++ b/public/app/features/scopes/internal/ScopesInput.tsx
@@ -40,20 +40,21 @@ export function ScopesInput({
let titles: string[];
if (path.length > 0) {
- titles = path
- .map((nodeName) => {
- const cl = currentLevel[nodeName];
- if (!cl) {
- return null;
- }
+ titles = path.reduce((acc, nodeName) => {
+ const cl = currentLevel[nodeName];
- const { title, nodes } = cl;
+ if (!cl) {
+ return acc;
+ }
- currentLevel = nodes;
+ const { title, nodes } = cl;
- return title;
- })
- .filter((title) => title !== null) as string[];
+ currentLevel = nodes;
+
+ acc.push(title);
+
+ return acc;
+ }, []);
if (titles[0] === '') {
titles.splice(0, 1);
@@ -90,15 +91,16 @@ export function ScopesInput({
() => (
0 && !isDisabled ? (
onRemoveAllClick()}
/>
@@ -127,8 +129,8 @@ const getStyles = (theme: GrafanaTheme2) => {
return {
scopePath: css({
color: theme.colors.text.primary,
- fontSize: theme.typography.pxToRem(14),
- margin: theme.spacing(1, 0),
+ fontSize: theme.typography.pxToRem(12),
+ margin: theme.spacing(0, 0),
}),
};
};
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx b/public/app/features/scopes/internal/ScopesSelectorScene.tsx
similarity index 63%
rename from public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx
rename to public/app/features/scopes/internal/ScopesSelectorScene.tsx
index d4dffcfa466..e9e5f0166c8 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx
+++ b/public/app/features/scopes/internal/ScopesSelectorScene.tsx
@@ -2,66 +2,72 @@ import { css } from '@emotion/css';
import { isEqual } from 'lodash';
import { finalize, from, Subscription } from 'rxjs';
-import { GrafanaTheme2, Scope } from '@grafana/data';
+import { GrafanaTheme2 } from '@grafana/data';
import {
SceneComponentProps,
- sceneGraph,
SceneObjectBase,
+ SceneObjectRef,
SceneObjectState,
SceneObjectUrlSyncConfig,
SceneObjectUrlValues,
SceneObjectWithUrlSync,
} from '@grafana/scenes';
-import { Button, Drawer, Spinner, useStyles2 } from '@grafana/ui';
+import { Button, Drawer, IconButton, Spinner, useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
+import { ScopesDashboardsScene } from './ScopesDashboardsScene';
import { ScopesInput } from './ScopesInput';
-import { ScopesScene } from './ScopesScene';
import { ScopesTree } from './ScopesTree';
import { fetchNodes, fetchScope, fetchSelectedScopes } from './api';
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types';
-import { getBasicScope } from './utils';
+import { getBasicScope, getScopeNamesFromSelectedScopes, getTreeScopesFromSelectedScopes } from './utils';
-export interface ScopesFiltersSceneState extends SceneObjectState {
+export interface ScopesSelectorSceneState extends SceneObjectState {
+ dashboards: SceneObjectRef | null;
nodes: NodesMap;
loadingNodeName: string | undefined;
scopes: SelectedScope[];
treeScopes: TreeScope[];
+ isReadOnly: boolean;
isLoadingScopes: boolean;
- isOpened: boolean;
+ isPickerOpened: boolean;
+ isEnabled: boolean;
}
-export class ScopesFiltersScene extends SceneObjectBase implements SceneObjectWithUrlSync {
- static Component = ScopesFiltersSceneRenderer;
+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 implements SceneObjectWithUrlSync {
+ static Component = ScopesSelectorSceneRenderer;
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scopes'] });
private nodesFetchingSub: Subscription | undefined;
- get scopesParent(): ScopesScene {
- return sceneGraph.getAncestor(this, ScopesScene);
- }
-
constructor() {
super({
- nodes: {
- '': {
- name: '',
- reason: NodeReason.Result,
- nodeType: 'container',
- title: '',
- isExpandable: true,
- isSelectable: false,
- isExpanded: true,
- query: '',
- nodes: {},
- },
- },
- loadingNodeName: undefined,
- scopes: [],
- treeScopes: [],
- isLoadingScopes: false,
- isOpened: false,
+ dashboards: null,
+ ...initialSelectorState,
});
this.addActivationHandler(() => {
@@ -75,7 +81,7 @@ export class ScopesFiltersScene extends SceneObjectBase
public getUrlState() {
return {
- scopes: this.state.scopes.map(({ scope }) => scope.metadata.name),
+ scopes: this.state.isEnabled ? getScopeNamesFromSelectedScopes(this.state.scopes) : [],
};
}
@@ -177,8 +183,8 @@ export class ScopesFiltersScene extends SceneObjectBase
}
}
- public open() {
- if (!this.scopesParent.state.isViewing) {
+ public openPicker() {
+ if (!this.state.isReadOnly) {
let nodes = { ...this.state.nodes };
// First close all nodes
@@ -191,20 +197,16 @@ export class ScopesFiltersScene extends SceneObjectBase
// Expand the nodes to the selected scope
nodes = this.expandNodes(nodes, path);
- this.setState({ isOpened: true, nodes });
+ this.setState({ isPickerOpened: true, nodes });
}
}
- public close() {
- this.setState({ isOpened: false });
- }
-
- public getSelectedScopes(): Scope[] {
- return this.state.scopes.map(({ scope }) => scope);
+ public closePicker() {
+ this.setState({ isPickerOpened: false });
}
public async updateScopes(treeScopes = this.state.treeScopes) {
- if (isEqual(treeScopes, this.getTreeScopes())) {
+ if (isEqual(treeScopes, getTreeScopesFromSelectedScopes(this.state.scopes))) {
return;
}
@@ -221,15 +223,27 @@ export class ScopesFiltersScene extends SceneObjectBase
}
public resetDirtyScopeNames() {
- this.setState({ treeScopes: this.getTreeScopes() });
+ this.setState({ treeScopes: getTreeScopesFromSelectedScopes(this.state.scopes) });
}
public removeAllScopes() {
this.setState({ scopes: [], treeScopes: [], isLoadingScopes: false });
}
- public enterViewMode() {
- this.setState({ isOpened: false });
+ 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 {
@@ -260,42 +274,68 @@ export class ScopesFiltersScene extends SceneObjectBase
return nodes;
}
-
- private getTreeScopes(): TreeScope[] {
- return this.state.scopes.map(({ scope, path }) => ({
- scopeName: scope.metadata.name,
- path,
- }));
- }
}
-export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps) {
+export function ScopesSelectorSceneRenderer({ model }: SceneComponentProps) {
const styles = useStyles2(getStyles);
- const { nodes, loadingNodeName, treeScopes, isLoadingScopes, isOpened, scopes } = model.useState();
- const { isViewing } = model.scopesParent.useState();
+ 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.open()}
+ onInputClick={() => model.openPicker()}
onRemoveAllClick={() => model.removeAllScopes()}
/>
- {isOpened && (
+ {isPickerOpened && (
{
- model.close();
+ model.closePicker();
model.resetDirtyScopeNames();
}}
>
{isLoadingScopes ? (
-
+
) : (
)}
- >
+
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
+ container: css({
+ borderLeft: `1px solid ${theme.colors.border.weak}`,
+ display: 'flex',
+ flexDirection: 'row',
+ paddingLeft: theme.spacing(2),
+ }),
+ dashboards: css({
+ color: theme.colors.text.secondary,
+ marginRight: theme.spacing(2),
+
+ '&:hover': css({
+ color: theme.colors.text.primary,
+ }),
+ }),
buttonGroup: css({
display: 'flex',
gap: theme.spacing(1),
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTree.tsx b/public/app/features/scopes/internal/ScopesTree.tsx
similarity index 100%
rename from public/app/features/dashboard-scene/scene/Scopes/ScopesTree.tsx
rename to public/app/features/scopes/internal/ScopesTree.tsx
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeHeadline.tsx b/public/app/features/scopes/internal/ScopesTreeHeadline.tsx
similarity index 100%
rename from public/app/features/dashboard-scene/scene/Scopes/ScopesTreeHeadline.tsx
rename to public/app/features/scopes/internal/ScopesTreeHeadline.tsx
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeItem.tsx b/public/app/features/scopes/internal/ScopesTreeItem.tsx
similarity index 100%
rename from public/app/features/dashboard-scene/scene/Scopes/ScopesTreeItem.tsx
rename to public/app/features/scopes/internal/ScopesTreeItem.tsx
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLoading.tsx b/public/app/features/scopes/internal/ScopesTreeLoading.tsx
similarity index 100%
rename from public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLoading.tsx
rename to public/app/features/scopes/internal/ScopesTreeLoading.tsx
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeSearch.tsx b/public/app/features/scopes/internal/ScopesTreeSearch.tsx
similarity index 100%
rename from public/app/features/dashboard-scene/scene/Scopes/ScopesTreeSearch.tsx
rename to public/app/features/scopes/internal/ScopesTreeSearch.tsx
diff --git a/public/app/features/dashboard-scene/scene/Scopes/api.ts b/public/app/features/scopes/internal/api.ts
similarity index 91%
rename from public/app/features/dashboard-scene/scene/Scopes/api.ts
rename to public/app/features/scopes/internal/api.ts
index d1deda21d73..634de927253 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/api.ts
+++ b/public/app/features/scopes/internal/api.ts
@@ -87,10 +87,10 @@ export async function fetchSelectedScopes(treeScopes: TreeScope[]): Promise
{
+export async function fetchDashboards(scopeNames: string[]): Promise {
try {
const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(dashboardsEndpoint, {
- scope: scopes.map(({ metadata: { name } }) => name),
+ scope: scopeNames,
});
return response?.items ?? [];
@@ -99,8 +99,8 @@ export async function fetchDashboards(scopes: Scope[]): Promise {
- const items = await fetchDashboards(scopes);
+export async function fetchSuggestedDashboards(scopeNames: string[]): Promise {
+ const items = await fetchDashboards(scopeNames);
return Object.values(
items.reduce>((acc, item) => {
diff --git a/public/app/features/scopes/internal/const.ts b/public/app/features/scopes/internal/const.ts
new file mode 100644
index 00000000000..643a106960d
--- /dev/null
+++ b/public/app/features/scopes/internal/const.ts
@@ -0,0 +1 @@
+export const DASHBOARDS_OPENED_KEY = 'grafana.scopes.dashboards.opened';
diff --git a/public/app/features/dashboard-scene/scene/Scopes/types.ts b/public/app/features/scopes/internal/types.ts
similarity index 100%
rename from public/app/features/dashboard-scene/scene/Scopes/types.ts
rename to public/app/features/scopes/internal/types.ts
diff --git a/public/app/features/scopes/internal/utils.ts b/public/app/features/scopes/internal/utils.ts
new file mode 100644
index 00000000000..5052469ac48
--- /dev/null
+++ b/public/app/features/scopes/internal/utils.ts
@@ -0,0 +1,45 @@
+import { Scope } from '@grafana/data';
+
+import { SelectedScope, 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);
+}
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx b/public/app/features/scopes/scopes.test.tsx
similarity index 77%
rename from public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx
rename to public/app/features/scopes/scopes.test.tsx
index 0f4fb4d188e..1b8d9116609 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx
+++ b/public/app/features/scopes/scopes.test.tsx
@@ -6,8 +6,7 @@ import { sceneGraph } from '@grafana/scenes';
import { getDashboardAPI, setDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
-import { ScopesFiltersScene } from './ScopesFiltersScene';
-import { ScopesScene } from './ScopesScene';
+import { initializeScopes, scopesDashboardsScene, scopesSelectorScene } from './instance';
import {
buildTestScene,
fetchNodesSpy,
@@ -15,12 +14,8 @@ import {
fetchSelectedScopesSpy,
fetchSuggestedDashboardsSpy,
getDashboard,
- getDashboardsContainer,
getDashboardsExpand,
getDashboardsSearch,
- getFiltersApply,
- getFiltersCancel,
- getFiltersInput,
getMock,
getNotFoundForFilter,
getNotFoundForFilterClear,
@@ -43,22 +38,26 @@ import {
getResultClustersSlothClusterEastRadio,
getResultClustersSlothClusterNorthRadio,
getResultClustersSlothClusterSouthRadio,
+ getSelectorApply,
+ getSelectorCancel,
+ getSelectorInput,
getTreeHeadline,
getTreeSearch,
mocksScopes,
queryAllDashboard,
queryDashboard,
queryDashboardsContainer,
- queryDashboardsExpand,
queryDashboardsSearch,
- queryFiltersApply,
queryPersistedApplicationsSlothPictureFactoryTitle,
queryPersistedApplicationsSlothVoteTrackerTitle,
queryResultApplicationsClustersTitle,
queryResultApplicationsSlothPictureFactoryTitle,
queryResultApplicationsSlothVoteTrackerTitle,
+ querySelectorApply,
renderDashboard,
+ resetScenes,
} from './testUtils';
+import { getClosestScopesFacade } from './utils';
jest.mock('@grafana/runtime', () => ({
__esModule: true,
@@ -66,25 +65,26 @@ jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => ({
get: getMock,
}),
+ usePluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
}));
-describe('ScopesScene', () => {
+describe('Scopes', () => {
describe('Feature flag off', () => {
beforeAll(() => {
config.featureToggles.scopeFilters = false;
+
+ initializeScopes();
});
it('Does not initialize', () => {
const dashboardScene = buildTestScene();
dashboardScene.activate();
- expect(dashboardScene.state.scopes).toBeUndefined();
+ expect(scopesSelectorScene).toBeNull();
});
});
describe('Feature flag on', () => {
let dashboardScene: DashboardScene;
- let scopesScene: ScopesScene;
- let filtersScene: ScopesFiltersScene;
beforeAll(() => {
config.featureToggles.scopeFilters = true;
@@ -99,27 +99,28 @@ describe('ScopesScene', () => {
fetchSuggestedDashboardsSpy.mockClear();
getMock.mockClear();
+ initializeScopes();
+
dashboardScene = buildTestScene();
- scopesScene = dashboardScene.state.scopes!;
- filtersScene = scopesScene.state.filters;
renderDashboard(dashboardScene);
});
afterEach(() => {
+ resetScenes();
cleanup();
});
describe('Tree', () => {
it('Navigates through scopes nodes', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsClustersExpand());
await userEvents.click(getResultApplicationsExpand());
});
it('Fetches scope details on select', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
await waitFor(() => expect(fetchScopeSpy).toHaveBeenCalledTimes(1));
@@ -127,49 +128,49 @@ describe('ScopesScene', () => {
it('Selects the proper scopes', async () => {
await act(async () =>
- filtersScene.updateScopes([
+ scopesSelectorScene?.updateScopes([
{ scopeName: 'slothPictureFactory', path: [] },
{ scopeName: 'slothVoteTracker', path: [] },
])
);
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
expect(getResultApplicationsSlothVoteTrackerSelect()).toBeChecked();
expect(getResultApplicationsSlothPictureFactorySelect()).toBeChecked();
});
it('Can select scopes from same level', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsClustersSelect());
- await userEvents.click(getFiltersApply());
- expect(getFiltersInput().value).toBe('slothVoteTracker, slothPictureFactory, Cluster Index Helper');
+ await userEvents.click(getSelectorApply());
+ expect(getSelectorInput().value).toBe('slothVoteTracker, slothPictureFactory, Cluster Index Helper');
});
it('Can select a node from an inner level', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
await userEvents.click(getResultApplicationsClustersExpand());
await userEvents.click(getResultApplicationsClustersSlothClusterNorthSelect());
- await userEvents.click(getFiltersApply());
- expect(getFiltersInput().value).toBe('slothClusterNorth');
+ await userEvents.click(getSelectorApply());
+ expect(getSelectorInput().value).toBe('slothClusterNorth');
});
it('Can select a node from an upper level', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultClustersSelect());
- await userEvents.click(getFiltersApply());
- expect(getFiltersInput().value).toBe('Cluster Index Helper');
+ await userEvents.click(getSelectorApply());
+ expect(getSelectorInput().value).toBe('Cluster Index Helper');
});
it('Respects only one select per container', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultClustersExpand());
await userEvents.click(getResultClustersSlothClusterNorthRadio());
expect(getResultClustersSlothClusterNorthRadio().checked).toBe(true);
@@ -180,7 +181,7 @@ describe('ScopesScene', () => {
});
it('Search works', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.type(getTreeSearch(), 'Clusters');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
@@ -196,18 +197,18 @@ describe('ScopesScene', () => {
});
it('Opens to a selected scope', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultClustersExpand());
- await userEvents.click(getFiltersApply());
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorApply());
+ await userEvents.click(getSelectorInput());
expect(queryResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
});
it('Persists a scope', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
@@ -219,7 +220,7 @@ describe('ScopesScene', () => {
});
it('Does not persist a retrieved scope', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.type(getTreeSearch(), 'slothPictureFactory');
@@ -229,7 +230,7 @@ describe('ScopesScene', () => {
});
it('Removes persisted nodes', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
@@ -243,7 +244,7 @@ describe('ScopesScene', () => {
});
it('Persists nodes from search', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.type(getTreeSearch(), 'sloth');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
@@ -260,33 +261,33 @@ describe('ScopesScene', () => {
});
it('Selects a persisted scope', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
- await userEvents.click(getFiltersApply());
- expect(getFiltersInput().value).toBe('slothPictureFactory, slothVoteTracker');
+ await userEvents.click(getSelectorApply());
+ expect(getSelectorInput().value).toBe('slothPictureFactory, slothVoteTracker');
});
it('Deselects a persisted scope', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
- await userEvents.click(getFiltersApply());
- expect(getFiltersInput().value).toBe('slothPictureFactory, slothVoteTracker');
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorApply());
+ expect(getSelectorInput().value).toBe('slothPictureFactory, slothVoteTracker');
+ await userEvents.click(getSelectorInput());
await userEvents.click(getPersistedApplicationsSlothPictureFactorySelect());
- await userEvents.click(getFiltersApply());
- expect(getFiltersInput().value).toBe('slothVoteTracker');
+ await userEvents.click(getSelectorApply());
+ expect(getSelectorInput().value).toBe('slothVoteTracker');
});
it('Shows the proper headline', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
expect(getTreeHeadline()).toHaveTextContent('Recommended');
await userEvents.type(getTreeSearch(), 'Applications');
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(2));
@@ -297,90 +298,90 @@ describe('ScopesScene', () => {
});
});
- describe('Filters', () => {
+ describe('Selector', () => {
it('Opens', async () => {
- await userEvents.click(getFiltersInput());
- expect(getFiltersApply()).toBeInTheDocument();
+ await userEvents.click(getSelectorInput());
+ expect(getSelectorApply()).toBeInTheDocument();
});
it('Fetches scope details on save', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultClustersSelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
await waitFor(() => expect(fetchSelectedScopesSpy).toHaveBeenCalled());
- expect(filtersScene.getSelectedScopes()).toEqual(
+ expect(getClosestScopesFacade(dashboardScene)?.value).toEqual(
mocksScopes.filter(({ metadata: { name } }) => name === 'indexHelperCluster')
);
});
it("Doesn't save the scopes on close", async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultClustersSelect());
- await userEvents.click(getFiltersCancel());
+ await userEvents.click(getSelectorCancel());
await waitFor(() => expect(fetchSelectedScopesSpy).not.toHaveBeenCalled());
- expect(filtersScene.getSelectedScopes()).toEqual([]);
+ expect(getClosestScopesFacade(dashboardScene)?.value).toEqual([]);
});
it('Shows selected scopes', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultClustersSelect());
- await userEvents.click(getFiltersApply());
- expect(getFiltersInput().value).toEqual('Cluster Index Helper');
+ await userEvents.click(getSelectorApply());
+ expect(getSelectorInput().value).toEqual('Cluster Index Helper');
});
});
describe('Dashboards list', () => {
it('Toggles expanded state', async () => {
await userEvents.click(getDashboardsExpand());
- expect(getDashboardsContainer()).toBeInTheDocument();
+ expect(getNotFoundNoScopes()).toBeInTheDocument();
});
it('Does not fetch dashboards list when the list is not expanded', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
await waitFor(() => expect(fetchSuggestedDashboardsSpy).not.toHaveBeenCalled());
});
it('Fetches dashboards list when the list is expanded', async () => {
await userEvents.click(getDashboardsExpand());
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled());
});
it('Fetches dashboards list when the list is expanded after scope selection', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
await userEvents.click(getDashboardsExpand());
await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled());
});
it('Shows dashboards for multiple scopes', async () => {
await userEvents.click(getDashboardsExpand());
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
expect(getDashboard('1')).toBeInTheDocument();
expect(getDashboard('2')).toBeInTheDocument();
expect(queryDashboard('3')).not.toBeInTheDocument();
expect(queryDashboard('4')).not.toBeInTheDocument();
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
expect(getDashboard('1')).toBeInTheDocument();
expect(getDashboard('2')).toBeInTheDocument();
expect(getDashboard('3')).toBeInTheDocument();
expect(getDashboard('4')).toBeInTheDocument();
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
expect(queryDashboard('1')).not.toBeInTheDocument();
expect(queryDashboard('2')).not.toBeInTheDocument();
expect(getDashboard('3')).toBeInTheDocument();
@@ -389,10 +390,10 @@ describe('ScopesScene', () => {
it('Filters the dashboards list', async () => {
await userEvents.click(getDashboardsExpand());
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
expect(getDashboard('1')).toBeInTheDocument();
expect(getDashboard('2')).toBeInTheDocument();
await userEvents.type(getDashboardsSearch(), '1');
@@ -401,12 +402,12 @@ describe('ScopesScene', () => {
it('Deduplicates the dashboards list', async () => {
await userEvents.click(getDashboardsExpand());
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsClustersExpand());
await userEvents.click(getResultApplicationsClustersSlothClusterNorthSelect());
await userEvents.click(getResultApplicationsClustersSlothClusterSouthSelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
expect(queryAllDashboard('5')).toHaveLength(1);
expect(queryAllDashboard('6')).toHaveLength(1);
expect(queryAllDashboard('7')).toHaveLength(1);
@@ -421,20 +422,20 @@ describe('ScopesScene', () => {
it('Does not show the input when there are no dashboards found for scope', async () => {
await userEvents.click(getDashboardsExpand());
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultClustersExpand());
await userEvents.click(getResultClustersSlothClusterEastRadio());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
expect(getNotFoundForScope()).toBeInTheDocument();
expect(queryDashboardsSearch()).not.toBeInTheDocument();
});
it('Does show the input and a message when there are no dashboards found for filter', async () => {
await userEvents.click(getDashboardsExpand());
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
await userEvents.type(getDashboardsSearch(), 'unknown');
expect(queryDashboardsSearch()).toBeInTheDocument();
expect(getNotFoundForFilter()).toBeInTheDocument();
@@ -446,14 +447,14 @@ describe('ScopesScene', () => {
describe('View mode', () => {
it('Enters view mode', async () => {
await act(async () => dashboardScene.onEnterEditMode());
- expect(scopesScene.state.isViewing).toEqual(true);
- expect(scopesScene.state.isExpanded).toEqual(false);
+ expect(scopesSelectorScene?.state?.isReadOnly).toEqual(true);
+ expect(scopesDashboardsScene?.state?.isPanelOpened).toEqual(false);
});
- it('Closes filters on enter', async () => {
- await userEvents.click(getFiltersInput());
+ it('Closes selector on enter', async () => {
+ await userEvents.click(getSelectorInput());
await act(async () => dashboardScene.onEnterEditMode());
- expect(queryFiltersApply()).not.toBeInTheDocument();
+ expect(querySelectorApply()).not.toBeInTheDocument();
});
it('Closes dashboards list on enter', async () => {
@@ -462,24 +463,24 @@ describe('ScopesScene', () => {
expect(queryDashboardsContainer()).not.toBeInTheDocument();
});
- it('Does not open filters when view mode is active', async () => {
+ it('Does not open selector when view mode is active', async () => {
await act(async () => dashboardScene.onEnterEditMode());
- await userEvents.click(getFiltersInput());
- expect(queryFiltersApply()).not.toBeInTheDocument();
+ await userEvents.click(getSelectorInput());
+ expect(querySelectorApply()).not.toBeInTheDocument();
});
- it('Hides the expand button when view mode is active', async () => {
+ it('Disables the expand button when view mode is active', async () => {
await act(async () => dashboardScene.onEnterEditMode());
- expect(queryDashboardsExpand()).not.toBeInTheDocument();
+ expect(getDashboardsExpand()).toBeDisabled();
});
});
describe('Enrichers', () => {
it('Data requests', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
await waitFor(() => {
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(
@@ -487,9 +488,9 @@ describe('ScopesScene', () => {
);
});
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
await waitFor(() => {
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(
@@ -499,9 +500,9 @@ describe('ScopesScene', () => {
);
});
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
await waitFor(() => {
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(
@@ -511,19 +512,19 @@ describe('ScopesScene', () => {
});
it('Filters requests', async () => {
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsExpand());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
await waitFor(() => {
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(
mocksScopes.filter(({ metadata: { name } }) => name === 'slothPictureFactory')
);
});
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
await waitFor(() => {
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(
mocksScopes.filter(
@@ -532,9 +533,9 @@ describe('ScopesScene', () => {
);
});
- await userEvents.click(getFiltersInput());
+ await userEvents.click(getSelectorInput());
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
- await userEvents.click(getFiltersApply());
+ await userEvents.click(getSelectorApply());
await waitFor(() => {
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(
mocksScopes.filter(({ metadata: { name } }) => name === 'slothVoteTracker')
@@ -556,17 +557,24 @@ describe('ScopesScene', () => {
locationService.push('/?scopes=scope1&scopes=scope2&scopes=scope3');
});
- it('Legacy API should not pass the scopes', () => {
- config.featureToggles.kubernetesDashboards = false;
- getDashboardAPI().getDashboardDTO('1');
- expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', undefined);
+ afterEach(() => {
+ resetScenes();
+ cleanup();
});
- it('K8s API should not pass the scopes', () => {
+ it('Legacy API should not pass the scopes', async () => {
+ config.featureToggles.kubernetesDashboards = false;
+ getDashboardAPI().getDashboardDTO('1');
+ await waitFor(() => expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', undefined));
+ });
+
+ it('K8s API should not pass the scopes', async () => {
config.featureToggles.kubernetesDashboards = true;
getDashboardAPI().getDashboardDTO('1');
- expect(getMock).toHaveBeenCalledWith(
- '/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto'
+ await waitFor(() =>
+ expect(getMock).toHaveBeenCalledWith(
+ '/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto'
+ )
);
});
});
@@ -580,19 +588,29 @@ describe('ScopesScene', () => {
beforeEach(() => {
setDashboardAPI(undefined);
locationService.push('/?scopes=scope1&scopes=scope2&scopes=scope3');
+ initializeScopes();
});
- it('Legacy API should pass the scopes', () => {
+ afterEach(() => {
+ resetScenes();
+ cleanup();
+ });
+
+ it('Legacy API should pass the scopes', async () => {
config.featureToggles.kubernetesDashboards = false;
getDashboardAPI().getDashboardDTO('1');
- expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', { scopes: ['scope1', 'scope2', 'scope3'] });
+ await waitFor(() =>
+ expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', { scopes: ['scope1', 'scope2', 'scope3'] })
+ );
});
- it('K8s API should not pass the scopes', () => {
+ it('K8s API should not pass the scopes', async () => {
config.featureToggles.kubernetesDashboards = true;
getDashboardAPI().getDashboardDTO('1');
- expect(getMock).toHaveBeenCalledWith(
- '/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto'
+ await waitFor(() =>
+ expect(getMock).toHaveBeenCalledWith(
+ '/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto'
+ )
);
});
});
diff --git a/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx b/public/app/features/scopes/testUtils.tsx
similarity index 88%
rename from public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx
rename to public/app/features/scopes/testUtils.tsx
index f83bf074dfb..1ab33d8e7ef 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx
+++ b/public/app/features/scopes/testUtils.tsx
@@ -1,11 +1,13 @@
import { screen } from '@testing-library/react';
-import { render } from 'test/test-utils';
+import { KBarProvider } from 'kbar';
+import { getWrapper, render } from 'test/test-utils';
import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data';
import {
AdHocFiltersVariable,
behaviors,
GroupByVariable,
+ sceneGraph,
SceneGridItem,
SceneGridLayout,
SceneQueryRunner,
@@ -13,10 +15,18 @@ import {
SceneVariableSet,
VizPanel,
} from '@grafana/scenes';
+import { AppChrome } from 'app/core/components/AppChrome/AppChrome';
+import { AppChromeService } from 'app/core/components/AppChrome/AppChromeService';
import { DashboardControls } from 'app/features/dashboard-scene/scene//DashboardControls';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
+import { configureStore } from 'app/store/configureStore';
-import * as api from './api';
+import { ScopesFacade } from './ScopesFacadeScene';
+import { scopesDashboardsScene, scopesSelectorScene } from './instance';
+import { getInitialDashboardsState } from './internal/ScopesDashboardsScene';
+import { initialSelectorState } from './internal/ScopesSelectorScene';
+import * as api from './internal/api';
+import { DASHBOARDS_OPENED_KEY } from './internal/const';
export const mocksScopes: Scope[] = [
{
@@ -321,12 +331,12 @@ const selectors = {
expand: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-expand`,
title: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-title`,
},
- filters: {
- input: 'scopes-filters-input',
- container: 'scopes-filters-container',
- loading: 'scopes-filters-loading',
- apply: 'scopes-filters-apply',
- cancel: 'scopes-filters-cancel',
+ selector: {
+ input: 'scopes-selector-input',
+ container: 'scopes-selector-container',
+ loading: 'scopes-selector-loading',
+ apply: 'scopes-selector-apply',
+ cancel: 'scopes-selector-cancel',
},
dashboards: {
expand: 'scopes-dashboards-expand',
@@ -341,15 +351,13 @@ const selectors = {
},
};
-export const getFiltersInput = () => screen.getByTestId(selectors.filters.input);
-export const queryFiltersApply = () => screen.queryByTestId(selectors.filters.apply);
-export const getFiltersApply = () => screen.getByTestId(selectors.filters.apply);
-export const getFiltersCancel = () => screen.getByTestId(selectors.filters.cancel);
+export const getSelectorInput = () => screen.getByTestId(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 queryDashboardsExpand = () => screen.queryByTestId(selectors.dashboards.expand);
export const getDashboardsExpand = () => screen.getByTestId(selectors.dashboards.expand);
export const queryDashboardsContainer = () => screen.queryByTestId(selectors.dashboards.container);
-export const getDashboardsContainer = () => screen.getByTestId(selectors.dashboards.container);
export const queryDashboardsSearch = () => screen.queryByTestId(selectors.dashboards.search);
export const getDashboardsSearch = () => screen.getByTestId(selectors.dashboards.search);
export const queryAllDashboard = (uid: string) => screen.queryAllByTestId(selectors.dashboards.dashboard(uid));
@@ -416,7 +424,12 @@ export function buildTestScene(overrides: Partial = {}) {
timeZone: 'browser',
}),
controls: new DashboardControls({}),
- $behaviors: [new behaviors.CursorSync({})],
+ $behaviors: [
+ new behaviors.CursorSync({}),
+ new ScopesFacade({
+ handler: (facade) => sceneGraph.getTimeRange(facade).onRefresh(),
+ }),
+ ],
$variables: new SceneVariableSet({
variables: [
new AdHocFiltersVariable({
@@ -451,5 +464,31 @@ export function buildTestScene(overrides: Partial = {}) {
}
export function renderDashboard(dashboardScene: DashboardScene) {
- return render();
+ const store = configureStore();
+ const chrome = new AppChromeService();
+ chrome.update({ chromeless: false });
+ const Wrapper = getWrapper({ store, renderWithRouter: true, grafanaContext: { chrome } });
+
+ return render(
+
+
+
+
+
+
+ ,
+ {
+ historyOptions: {
+ initialEntries: ['/'],
+ },
+ }
+ );
+}
+
+export function resetScenes() {
+ scopesSelectorScene?.setState(initialSelectorState);
+
+ localStorage.removeItem(DASHBOARDS_OPENED_KEY);
+
+ scopesDashboardsScene?.setState(getInitialDashboardsState());
}
diff --git a/public/app/features/scopes/utils.ts b/public/app/features/scopes/utils.ts
new file mode 100644
index 00000000000..85d01626fa8
--- /dev/null
+++ b/public/app/features/scopes/utils.ts
@@ -0,0 +1,39 @@
+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?.enable();
+}
+
+export function enterScopesReadOnly() {
+ scopesSelectorScene?.enterReadOnly();
+ scopesDashboardsScene?.disable();
+}
+
+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/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 84a34e98efb..b2112d30d29 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -1911,16 +1911,7 @@
"dismissable-button": "Close"
},
"scopes": {
- "filters": {
- "apply": "Apply",
- "cancel": "Cancel",
- "input": {
- "placeholder": "Select scopes...",
- "removeAll": "Remove all scopes"
- },
- "title": "Select scopes"
- },
- "suggestedDashboards": {
+ "dashboards": {
"loading": "Loading dashboards",
"noResultsForFilter": "No results found for your query",
"noResultsForFilterClear": "Clear search",
@@ -1928,10 +1919,20 @@
"noResultsNoScopes": "No scopes selected",
"search": "Search",
"toggle": {
- "collapse": "Collapse scope filters",
- "expand": "Expand scope filters"
+ "collapse": "Collapse suggested dashboards list",
+ "disabled": "Suggested dashboards list is disabled due to read only mode",
+ "expand": "Expand suggested dashboards list"
}
},
+ "selector": {
+ "apply": "Apply",
+ "cancel": "Cancel",
+ "input": {
+ "placeholder": "Select scopes...",
+ "removeAll": "Remove all scopes"
+ },
+ "title": "Select scopes"
+ },
"tree": {
"collapse": "Collapse",
"expand": "Expand",
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index 067128ad03d..244caed14ca 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -1911,16 +1911,7 @@
"dismissable-button": "Cľőşę"
},
"scopes": {
- "filters": {
- "apply": "Åppľy",
- "cancel": "Cäʼnčęľ",
- "input": {
- "placeholder": "Ŝęľęčŧ şčőpęş...",
- "removeAll": "Ŗęmővę äľľ şčőpęş"
- },
- "title": "Ŝęľęčŧ şčőpęş"
- },
- "suggestedDashboards": {
+ "dashboards": {
"loading": "Ŀőäđįʼnģ đäşĥþőäřđş",
"noResultsForFilter": "Ńő řęşūľŧş ƒőūʼnđ ƒőř yőūř qūęřy",
"noResultsForFilterClear": "Cľęäř şęäřčĥ",
@@ -1928,10 +1919,20 @@
"noResultsNoScopes": "Ńő şčőpęş şęľęčŧęđ",
"search": "Ŝęäřčĥ",
"toggle": {
- "collapse": "Cőľľäpşę şčőpę ƒįľŧęřş",
- "expand": "Ēχpäʼnđ şčőpę ƒįľŧęřş"
+ "collapse": "Cőľľäpşę şūģģęşŧęđ đäşĥþőäřđş ľįşŧ",
+ "disabled": "Ŝūģģęşŧęđ đäşĥþőäřđş ľįşŧ įş đįşäþľęđ đūę ŧő řęäđ őʼnľy mőđę",
+ "expand": "Ēχpäʼnđ şūģģęşŧęđ đäşĥþőäřđş ľįşŧ"
}
},
+ "selector": {
+ "apply": "Åppľy",
+ "cancel": "Cäʼnčęľ",
+ "input": {
+ "placeholder": "Ŝęľęčŧ şčőpęş...",
+ "removeAll": "Ŗęmővę äľľ şčőpęş"
+ },
+ "title": "Ŝęľęčŧ şčőpęş"
+ },
"tree": {
"collapse": "Cőľľäpşę",
"expand": "Ēχpäʼnđ",
diff --git a/public/test/test-utils.tsx b/public/test/test-utils.tsx
index dae68331554..1e6e5cee3b0 100644
--- a/public/test/test-utils.tsx
+++ b/public/test/test-utils.tsx
@@ -46,7 +46,7 @@ const getWrapper = ({
historyOptions,
grafanaContext,
}: ExtendedRenderOptions & {
- grafanaContext?: GrafanaContextType;
+ grafanaContext?: Partial;
}) => {
const reduxStore = store || configureStore();
/**