From 3303900c09743a29dc40740b1a1105e4914679fe Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Mon, 12 Aug 2024 14:11:50 +0300 Subject: [PATCH] Scopes: Lift scopes at global level (#90136) --- .betterer.results | 7 +- .github/CODEOWNERS | 1 + public/app/app.ts | 3 + .../core/components/AppChrome/AppChrome.tsx | 2 + .../AppChrome/TopBar/TopSearchBar.tsx | 2 + .../AppChrome/TopBar/TopSearchBarSection.tsx | 5 +- .../embedding/EmbeddedDashboard.tsx | 19 +- .../pages/DashboardScenePageStateManager.ts | 10 +- .../panel-edit/PanelEditorRenderer.tsx | 23 +- .../dashboard-scene/scene/DashboardScene.tsx | 25 +- .../scene/DashboardSceneRenderer.tsx | 32 +-- .../scene/Scopes/ScopesDashboardsScene.tsx | 164 ----------- .../scene/Scopes/ScopesScene.tsx | 148 ---------- .../dashboard-scene/scene/Scopes/utils.ts | 28 -- .../transformSaveModelToScene.ts | 5 + .../features/dashboard/api/dashboard_api.ts | 6 +- .../dashboard/utils/getScopesFromUrl.ts | 13 - .../app/features/scopes/ScopesDashboards.tsx | 9 + .../app/features/scopes/ScopesFacadeScene.ts | 53 ++++ public/app/features/scopes/ScopesSelector.tsx | 9 + public/app/features/scopes/index.ts | 14 + public/app/features/scopes/instance.tsx | 21 ++ .../scopes/internal/ScopesDashboardsScene.tsx | 262 ++++++++++++++++++ .../internal}/ScopesInput.tsx | 36 +-- .../internal/ScopesSelectorScene.tsx} | 192 ++++++++----- .../Scopes => scopes/internal}/ScopesTree.tsx | 0 .../internal}/ScopesTreeHeadline.tsx | 0 .../internal}/ScopesTreeItem.tsx | 0 .../internal}/ScopesTreeLoading.tsx | 0 .../internal}/ScopesTreeSearch.tsx | 0 .../scene/Scopes => scopes/internal}/api.ts | 8 +- public/app/features/scopes/internal/const.ts | 1 + .../scene/Scopes => scopes/internal}/types.ts | 0 public/app/features/scopes/internal/utils.ts | 45 +++ .../scopes.test.tsx} | 244 ++++++++-------- .../scene/Scopes => scopes}/testUtils.tsx | 71 +++-- public/app/features/scopes/utils.ts | 39 +++ public/locales/en-US/grafana.json | 25 +- public/locales/pseudo-LOCALE/grafana.json | 25 +- public/test/test-utils.tsx | 2 +- 40 files changed, 864 insertions(+), 685 deletions(-) delete mode 100644 public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx delete mode 100644 public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx delete mode 100644 public/app/features/dashboard-scene/scene/Scopes/utils.ts delete mode 100644 public/app/features/dashboard/utils/getScopesFromUrl.ts create mode 100644 public/app/features/scopes/ScopesDashboards.tsx create mode 100644 public/app/features/scopes/ScopesFacadeScene.ts create mode 100644 public/app/features/scopes/ScopesSelector.tsx create mode 100644 public/app/features/scopes/index.ts create mode 100644 public/app/features/scopes/instance.tsx create mode 100644 public/app/features/scopes/internal/ScopesDashboardsScene.tsx rename public/app/features/{dashboard-scene/scene/Scopes => scopes/internal}/ScopesInput.tsx (80%) rename public/app/features/{dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx => scopes/internal/ScopesSelectorScene.tsx} (63%) rename public/app/features/{dashboard-scene/scene/Scopes => scopes/internal}/ScopesTree.tsx (100%) rename public/app/features/{dashboard-scene/scene/Scopes => scopes/internal}/ScopesTreeHeadline.tsx (100%) rename public/app/features/{dashboard-scene/scene/Scopes => scopes/internal}/ScopesTreeItem.tsx (100%) rename public/app/features/{dashboard-scene/scene/Scopes => scopes/internal}/ScopesTreeLoading.tsx (100%) rename public/app/features/{dashboard-scene/scene/Scopes => scopes/internal}/ScopesTreeSearch.tsx (100%) rename public/app/features/{dashboard-scene/scene/Scopes => scopes/internal}/api.ts (91%) create mode 100644 public/app/features/scopes/internal/const.ts rename public/app/features/{dashboard-scene/scene/Scopes => scopes/internal}/types.ts (100%) create mode 100644 public/app/features/scopes/internal/utils.ts rename public/app/features/{dashboard-scene/scene/Scopes/ScopesScene.test.tsx => scopes/scopes.test.tsx} (77%) rename public/app/features/{dashboard-scene/scene/Scopes => scopes}/testUtils.tsx (88%) create mode 100644 public/app/features/scopes/utils.ts diff --git a/.betterer.results b/.betterer.results index 73851418191a..c0ff4acd23b3 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2864,9 +2864,6 @@ exports[`better eslint`] = { "public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], @@ -5184,6 +5181,10 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] ], + "public/app/features/scopes/index.ts:5381": [ + [0, 0, 0, "Do not re-export imported variable (\`./instance\`)", "0"], + [0, 0, 0, "Do not re-export imported variable (\`./ScopesDashboards\`)", "1"] + ], "public/app/features/search/page/components/ActionRow.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2b6664d8dbf1..a14dacb44870 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -417,6 +417,7 @@ playwright.config.ts @grafana/plugins-platform-frontend /public/app/features/dashboard/ @grafana/dashboards-squad /public/app/features/dashboard/components/TransformationsEditor/ @grafana/dataviz-squad /public/app/features/dashboard-scene/ @grafana/dashboards-squad +/public/app/features/scopes/ @grafana/dashboards-squad /public/app/features/datasources/ @grafana/plugins-platform-frontend /public/app/features/dimensions/ @grafana/dataviz-squad /public/app/features/dataframe-import/ @grafana/dataviz-squad diff --git a/public/app/app.ts b/public/app/app.ts index a6d2f7512a8a..467f8bcca927 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -92,6 +92,7 @@ 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'; @@ -258,6 +259,8 @@ export class GrafanaApp { setReturnToPreviousHook(useReturnToPreviousInternal); setChromeHeaderHeightHook(useChromeHeaderHeight); + initializeScopes(); + const root = createRoot(document.getElementById('reactRoot')!); root.render( createElement(AppWrapper, { diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index a0e2e3e595fb..82f5be705c73 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -9,6 +9,7 @@ import { useGrafana } from 'app/core/context/GrafanaContext'; import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; import store from 'app/core/store'; import { CommandPalette } from 'app/features/commandPalette/CommandPalette'; +import { ScopesDashboards } from 'app/features/scopes'; import { KioskMode } from 'app/types'; import { AppChromeMenu } from './AppChromeMenu'; @@ -106,6 +107,7 @@ export function AppChrome({ children }: Props) { {menuDockedAndOpen && ( chrome.setMegaMenuOpen(false)} /> )} + {!state.chromeless && }
+ diff --git a/public/app/core/components/AppChrome/TopBar/TopSearchBarSection.tsx b/public/app/core/components/AppChrome/TopBar/TopSearchBarSection.tsx index ec9176039f0d..0ee80b5cdab6 100644 --- a/public/app/core/components/AppChrome/TopBar/TopSearchBarSection.tsx +++ b/public/app/core/components/AppChrome/TopBar/TopSearchBarSection.tsx @@ -1,13 +1,12 @@ import { css, cx } from '@emotion/css'; -import { useState } from 'react'; -import * as React from 'react'; +import { ReactNode, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2, useTheme2 } from '@grafana/ui'; import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; export interface TopSearchBarSectionProps { - children: React.ReactNode; + children: ReactNode; align?: 'left' | 'center' | 'right'; } diff --git a/public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx b/public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx index 11a7859df4f6..a10c8eae6b99 100644 --- a/public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx +++ b/public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx @@ -42,7 +42,7 @@ interface RendererProps extends EmbeddedDashboardProps { function EmbeddedDashboardRenderer({ model, initialState, onStateChange }: RendererProps) { const [isActive, setIsActive] = useState(false); - const { controls, body, scopes } = model.useState(); + const { controls, body } = model.useState(); const styles = useStyles2(getStyles); useEffect(() => { @@ -64,12 +64,9 @@ function EmbeddedDashboardRenderer({ model, initialState, onStateChange }: Rende } return ( -
- {scopes && } +
{controls && ( -
+
)} @@ -121,13 +118,6 @@ function getStyles(theme: GrafanaTheme2) { "panels"`, gridTemplateRows: 'auto 1fr', }), - canvasWithScopes: css({ - gridTemplateAreas: ` - "scopes controls" - "panels panels"`, - gridTemplateColumns: `${theme.spacing(32)} 1fr`, - gridTemplateRows: 'auto 1fr', - }), body: css({ label: 'body', flexGrow: 1, @@ -143,8 +133,5 @@ function getStyles(theme: GrafanaTheme2) { gridArea: 'controls', padding: theme.spacing(2, 0, 2, 2), }), - controlsWrapperWithScopes: css({ - padding: theme.spacing(2, 0), - }), }; } diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 201773bd2f6b..40cd0b07fc1b 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -14,9 +14,9 @@ import { removeDashboardToFetchFromLocalStorage, } from 'app/features/dashboard/state/initDashboard'; import { trackDashboardSceneLoaded } from 'app/features/dashboard/utils/tracking'; +import { getSelectedScopesNames } from 'app/features/scopes'; import { DashboardDTO, DashboardRoutes } from 'app/types'; -import { getScopesFromUrl } from '../../dashboard/utils/getScopesFromUrl'; import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; import { buildNewDashboardSaveModel } from '../serialization/buildNewDashboardSaveModel'; @@ -299,15 +299,13 @@ export class DashboardScenePageStateManager extends StateManagerBase) { const { vizManager, dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal } = model.useState(); const { sourcePanel } = vizManager.useState(); const libraryPanel = getLibraryPanel(sourcePanel.resolve()); - const { controls, scopes } = dashboard.useState(); + const { controls } = dashboard.useState(); const styles = useStyles2(getStyles); const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } = @@ -89,16 +89,9 @@ function VizAndDataPane({ model }: SceneComponentProps) { } return ( -
- {scopes && } +
{controls && ( -
+
)} @@ -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 4f39bb8b104b..9de61780452e 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 a06650ad702d..149c884462fb 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 d9bc1cc88d55..000000000000 --- 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 e661f7039507..000000000000 --- 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 bbe68774966a..000000000000 --- 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 5d45e4a4aa00..d03b55af774d 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 477495fda96b..661a02379a8c 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 e9539037fe5f..000000000000 --- 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 000000000000..e3b5227af866 --- /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 000000000000..db65da5f7a86 --- /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 000000000000..b7b4080d78b0 --- /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 000000000000..ad822091ddb8 --- /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 000000000000..b1c1709b0d11 --- /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 000000000000..eda287026437 --- /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 9e4e3d0e9bc5..e440d38741f4 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 d4dffcfa466c..e9e5f0166c8e 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 d1deda21d73f..634de9272536 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 000000000000..643a106960d9 --- /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 000000000000..5052469ac48f --- /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 0f4fb4d188ed..1b8d91166091 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 f83bf074dfb5..1ab33d8e7ef2 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 000000000000..85d01626fa8a --- /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 84a34e98efbb..b2112d30d29e 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 067128ad03d9..244caed14ca1 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 dae683315548..1e6e5cee3b0b 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(); /**