Scopes: Refactor scopes to plain React and move it to runtime (#97176)
* Scopes: Refactor scopes * Scopes: Refactor scopes * Scopes: Refactor scopes * Revert data changes * Refactorings * Refactorings * Refactorings * Remove unused * Refactorings * Refactors * Fixes * Update scenes * Rebase * Update .betterer.results * Fix tests * Move scopes selector to appchrome * More fixes * Fix tests * Remove ScopesFacade * use latest canary scenes version to pass tests * Make fields private * Update scenes version --------- Co-authored-by: Tobias Skarhed <tobias.skarhed@gmail.com> Co-authored-by: Victor Marin <victor.marin@grafana.com> Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com>
This commit is contained in:
co-authored by
Tobias Skarhed
Victor Marin
Andrej Ocenas
parent
210c886bb7
commit
312d80e0e1
@@ -5676,10 +5676,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/scopes/index.ts:5381": [
|
||||
[0, 0, 0, "Do not re-export imported variable (\`./ScopesDashboards\`)", "0"],
|
||||
[0, 0, 0, "Do not re-export imported variable (\`./instance\`)", "1"]
|
||||
],
|
||||
"public/app/features/search/page/components/ActionRow.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
|
||||
+2
-2
@@ -277,8 +277,8 @@
|
||||
"@grafana/prometheus": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/saga-icons": "workspace:*",
|
||||
"@grafana/scenes": "6.2.1",
|
||||
"@grafana/scenes-react": "6.2.1",
|
||||
"@grafana/scenes": "6.3.1",
|
||||
"@grafana/scenes-react": "6.3.1",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/sql": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
|
||||
+14
-11
@@ -24,6 +24,7 @@ import { ThemeProvider } from './core/utils/ConfigProvider';
|
||||
import { LiveConnectionWarning } from './features/live/LiveConnectionWarning';
|
||||
import { ExtensionRegistriesProvider } from './features/plugins/extensions/ExtensionRegistriesContext';
|
||||
import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
|
||||
import { ScopesContextProvider } from './features/scopes/ScopesContextProvider';
|
||||
import { ExperimentalSplitPaneRouterWrapper, RouterWrapper } from './routes/RoutesWrapper';
|
||||
|
||||
interface AppWrapperProps {
|
||||
@@ -123,17 +124,19 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
|
||||
<GlobalStyles />
|
||||
<MaybeTimeRangeProvider>
|
||||
<SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}>
|
||||
<ExtensionRegistriesProvider registries={pluginExtensionRegistries}>
|
||||
<div className="grafana-app">
|
||||
{config.featureToggles.appSidecar ? (
|
||||
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
|
||||
) : (
|
||||
<RouterWrapper {...routerWrapperProps} />
|
||||
)}
|
||||
<LiveConnectionWarning />
|
||||
<PortalContainer />
|
||||
</div>
|
||||
</ExtensionRegistriesProvider>
|
||||
<ScopesContextProvider>
|
||||
<ExtensionRegistriesProvider registries={pluginExtensionRegistries}>
|
||||
<div className="grafana-app">
|
||||
{config.featureToggles.appSidecar ? (
|
||||
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
|
||||
) : (
|
||||
<RouterWrapper {...routerWrapperProps} />
|
||||
)}
|
||||
<LiveConnectionWarning />
|
||||
<PortalContainer />
|
||||
</div>
|
||||
</ExtensionRegistriesProvider>
|
||||
</ScopesContextProvider>
|
||||
</SidecarContext_EXPERIMENTAL.Provider>
|
||||
</MaybeTimeRangeProvider>
|
||||
</KBarProvider>
|
||||
|
||||
@@ -91,7 +91,6 @@ import { preloadPlugins } from './features/plugins/pluginPreloader';
|
||||
import { QueryRunner } from './features/query/state/QueryRunner';
|
||||
import { runRequest } from './features/query/state/runRequest';
|
||||
import { initWindowRuntime } from './features/runtime/init';
|
||||
import { initializeScopes } from './features/scopes';
|
||||
import { cleanupOldExpandedFolders } from './features/search/utils';
|
||||
import { variableAdapters } from './features/variables/adapters';
|
||||
import { createAdHocVariableAdapter } from './features/variables/adhoc/adapter';
|
||||
@@ -255,8 +254,6 @@ export class GrafanaApp {
|
||||
setReturnToPreviousHook(useReturnToPreviousInternal);
|
||||
setChromeHeaderHeightHook(useChromeHeaderHeight);
|
||||
|
||||
initializeScopes();
|
||||
|
||||
if (config.featureToggles.crashDetection) {
|
||||
initializeCrashDetection();
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@ import classNames from 'classnames';
|
||||
import { PropsWithChildren, useEffect } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { locationSearchToObject, locationService } from '@grafana/runtime';
|
||||
import { locationSearchToObject, locationService, useScopes } from '@grafana/runtime';
|
||||
import { useStyles2, LinkButton, useTheme2 } from '@grafana/ui';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import store from 'app/core/store';
|
||||
import { CommandPalette } from 'app/features/commandPalette/CommandPalette';
|
||||
import { ScopesDashboards, useScopesDashboardsState } from 'app/features/scopes';
|
||||
import { ScopesDashboards } from 'app/features/scopes/dashboards/ScopesDashboards';
|
||||
|
||||
import { AppChromeMenu } from './AppChromeMenu';
|
||||
import { DOCKED_LOCAL_STORAGE_KEY, DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY } from './AppChromeService';
|
||||
@@ -27,14 +27,14 @@ export function AppChrome({ children }: Props) {
|
||||
const { chrome } = useGrafana();
|
||||
const state = chrome.useState();
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(getStyles, Boolean(state.actions));
|
||||
const scopes = useScopes();
|
||||
const styles = useStyles2(getStyles, Boolean(state.actions) || !!scopes?.state.enabled);
|
||||
|
||||
const dockedMenuBreakpoint = theme.breakpoints.values.xl;
|
||||
const dockedMenuLocalStorageState = store.getBool(DOCKED_LOCAL_STORAGE_KEY, true);
|
||||
const menuDockedAndOpen = !state.chromeless && state.megaMenuDocked && state.megaMenuOpen;
|
||||
const scopesDashboardsState = useScopesDashboardsState();
|
||||
const isScopesDashboardsOpen = Boolean(
|
||||
scopesDashboardsState?.isEnabled && scopesDashboardsState?.isPanelOpened && !scopesDashboardsState?.isReadOnly
|
||||
scopes?.state.enabled && scopes?.state.drawerOpened && !scopes?.state.readOnly
|
||||
);
|
||||
useMediaQueryChange({
|
||||
breakpoint: dockedMenuBreakpoint,
|
||||
@@ -101,7 +101,7 @@ export function AppChrome({ children }: Props) {
|
||||
onToggleMegaMenu={handleMegaMenu}
|
||||
onToggleKioskMode={chrome.onToggleKioskMode}
|
||||
/>
|
||||
{state.actions && <SingleTopBarActions>{state.actions}</SingleTopBarActions>}
|
||||
{(state.actions || scopes?.state.enabled) && <SingleTopBarActions>{state.actions}</SingleTopBarActions>}
|
||||
</header>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,18 +3,39 @@ import { PropsWithChildren } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Components } from '@grafana/e2e-selectors';
|
||||
import { useScopes } from '@grafana/runtime';
|
||||
import { Stack, useStyles2 } from '@grafana/ui';
|
||||
import { ScopesSelector } from 'app/features/scopes/selector/ScopesSelector';
|
||||
|
||||
import { TOP_BAR_LEVEL_HEIGHT } from '../types';
|
||||
|
||||
export function SingleTopBarActions({ children }: PropsWithChildren) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const scopes = useScopes();
|
||||
|
||||
const scopesRender = scopes?.state.enabled ? <ScopesSelector /> : undefined;
|
||||
const childrenRender = children ? (
|
||||
<Stack
|
||||
alignItems="center"
|
||||
justifyContent={scopes?.state.enabled ? 'space-between' : 'flex-end'}
|
||||
flex={1}
|
||||
wrap="nowrap"
|
||||
minWidth={0}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div data-testid={Components.NavToolbar.container} className={styles.actionsBar}>
|
||||
<Stack alignItems="center" justifyContent="flex-end" flex={1} wrap="nowrap" minWidth={0}>
|
||||
{children}
|
||||
</Stack>
|
||||
{scopesRender ? (
|
||||
<Stack alignItems="center" justifyContent="flex-start" flex={1} wrap="nowrap" minWidth={0}>
|
||||
{scopesRender}
|
||||
{children}
|
||||
</Stack>
|
||||
) : (
|
||||
childrenRender
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { debounce, isEqual } from 'lodash';
|
||||
|
||||
import { UrlQueryMap } from '@grafana/data';
|
||||
import { sceneGraph, SceneObjectBase, SceneObjectState, VariableDependencyConfig } from '@grafana/scenes';
|
||||
import { getClosestScopesFacade } from 'app/features/scopes';
|
||||
import {
|
||||
sceneGraph,
|
||||
SceneObjectBase,
|
||||
SceneObjectState,
|
||||
SceneScopesBridge,
|
||||
SceneTimeRangeLike,
|
||||
VariableDependencyConfig,
|
||||
} from '@grafana/scenes';
|
||||
|
||||
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
|
||||
export interface DashboardReloadBehaviorState extends SceneObjectState {
|
||||
reloadOnParamsChange?: boolean;
|
||||
uid?: string;
|
||||
@@ -13,40 +21,52 @@ export interface DashboardReloadBehaviorState extends SceneObjectState {
|
||||
}
|
||||
|
||||
export class DashboardReloadBehavior extends SceneObjectBase<DashboardReloadBehaviorState> {
|
||||
constructor(state: DashboardReloadBehaviorState) {
|
||||
const shouldReload = state.reloadOnParamsChange && state.uid;
|
||||
private _timeRange: SceneTimeRangeLike | undefined;
|
||||
private _scopesBridge: SceneScopesBridge | undefined;
|
||||
private _dashboardScene: DashboardScene | undefined;
|
||||
|
||||
constructor(state: DashboardReloadBehaviorState) {
|
||||
super(state);
|
||||
|
||||
// Sometimes the reload is triggered multiple subsequent times
|
||||
// Debouncing it prevents double/triple reloads
|
||||
this.reloadDashboard = debounce(this.reloadDashboard).bind(this);
|
||||
|
||||
if (shouldReload) {
|
||||
this.addActivationHandler(() => {
|
||||
getClosestScopesFacade(this)?.setState({
|
||||
handler: this.reloadDashboard,
|
||||
});
|
||||
const shouldReload = !!this.state.uid && !!this.state.reloadOnParamsChange;
|
||||
|
||||
this._variableDependency = new VariableDependencyConfig(this, {
|
||||
onAnyVariableChanged: this.reloadDashboard,
|
||||
});
|
||||
this.addActivationHandler(() => {
|
||||
if (!shouldReload) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._subs.add(
|
||||
sceneGraph.getTimeRange(this).subscribeToState((newState, prevState) => {
|
||||
if (!isEqual(newState.value, prevState.value)) {
|
||||
this.reloadDashboard();
|
||||
}
|
||||
})
|
||||
);
|
||||
this._timeRange = sceneGraph.getTimeRange(this);
|
||||
this._scopesBridge = sceneGraph.getScopesBridge(this);
|
||||
this._dashboardScene = sceneGraph.getAncestor(this, DashboardScene);
|
||||
|
||||
this.reloadDashboard();
|
||||
this._variableDependency = new VariableDependencyConfig(this, {
|
||||
onAnyVariableChanged: this.reloadDashboard,
|
||||
});
|
||||
}
|
||||
|
||||
this._scopesBridge?.subscribeToValue(() => {
|
||||
if (shouldReload) {
|
||||
this.reloadDashboard();
|
||||
}
|
||||
});
|
||||
|
||||
this._subs.add(
|
||||
this._timeRange.subscribeToState((newState, prevState) => {
|
||||
if (!isEqual(newState.value, prevState.value)) {
|
||||
this.reloadDashboard();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.reloadDashboard();
|
||||
});
|
||||
}
|
||||
|
||||
private isEditing() {
|
||||
return this.parent && 'isEditing' in this.parent.state && this.parent.state.isEditing;
|
||||
return !!this._dashboardScene?.state.isEditing;
|
||||
}
|
||||
|
||||
private isWaitingForVariables() {
|
||||
@@ -56,28 +76,28 @@ export class DashboardReloadBehavior extends SceneObjectBase<DashboardReloadBeha
|
||||
}
|
||||
|
||||
private reloadDashboard() {
|
||||
if (!this.isEditing() && !this.isWaitingForVariables()) {
|
||||
const timeRange = sceneGraph.getTimeRange(this);
|
||||
|
||||
// This is wrapped in setTimeout in order to allow variables and scopes to be set in the URL before actually reloading the dashboard
|
||||
setTimeout(() => {
|
||||
getDashboardScenePageStateManager().reloadDashboard({
|
||||
version: this.state.version!,
|
||||
scopes: getClosestScopesFacade(this)?.value.map((scope) => scope.metadata.name) ?? [],
|
||||
// We're not using the getUrlState from timeRange since it makes more sense to pass the absolute timestamps as opposed to relative time
|
||||
timeRange: {
|
||||
from: timeRange.state.value.from.toISOString(),
|
||||
to: timeRange.state.value.to.toISOString(),
|
||||
},
|
||||
variables: sceneGraph.getVariables(this).state.variables.reduce<UrlQueryMap>(
|
||||
(acc, variable) => ({
|
||||
...acc,
|
||||
...variable.urlSync?.getUrlState(),
|
||||
}),
|
||||
{}
|
||||
),
|
||||
});
|
||||
});
|
||||
if (this.isEditing() || this.isWaitingForVariables()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This is wrapped in setTimeout in order to allow variables and scopes to be set in the URL before actually reloading the dashboard
|
||||
setTimeout(() => {
|
||||
getDashboardScenePageStateManager().reloadDashboard({
|
||||
version: this.state.version!,
|
||||
scopes: this._scopesBridge?.getValue().map((scope) => scope.metadata.name) ?? [],
|
||||
// We're not using the getUrlState from timeRange since it makes more sense to pass the absolute timestamps as opposed to relative time
|
||||
timeRange: {
|
||||
from: this._timeRange!.state.value.from.toISOString(),
|
||||
to: this._timeRange!.state.value.to.toISOString(),
|
||||
},
|
||||
variables: sceneGraph.getVariables(this).state.variables.reduce<UrlQueryMap>(
|
||||
(acc, variable) => ({
|
||||
...acc,
|
||||
...variable.urlSync?.getUrlState(),
|
||||
}),
|
||||
{}
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import * as H from 'history';
|
||||
|
||||
import {
|
||||
AppEvents,
|
||||
CoreApp,
|
||||
DataQueryRequest,
|
||||
NavIndex,
|
||||
NavModelItem,
|
||||
locationUtil,
|
||||
DataSourceGetTagKeysOptions,
|
||||
DataSourceGetTagValuesOptions,
|
||||
} from '@grafana/data';
|
||||
import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil } from '@grafana/data';
|
||||
import { config, locationService, RefreshEvent } from '@grafana/runtime';
|
||||
import {
|
||||
sceneGraph,
|
||||
@@ -17,6 +8,7 @@ import {
|
||||
SceneObjectBase,
|
||||
SceneObjectRef,
|
||||
SceneObjectState,
|
||||
SceneScopesBridge,
|
||||
SceneTimeRange,
|
||||
sceneUtils,
|
||||
SceneVariable,
|
||||
@@ -37,7 +29,6 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import { DashboardModel, ScopeMeta } from 'app/features/dashboard/state/DashboardModel';
|
||||
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||
import { getClosestScopesFacade, ScopesFacade } from 'app/features/scopes';
|
||||
import { VariablesChanged } from 'app/features/variables/types';
|
||||
import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types';
|
||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||
@@ -142,6 +133,7 @@ export interface DashboardSceneState extends SceneObjectState {
|
||||
panelsPerRow?: number;
|
||||
/** options pane */
|
||||
editPane: DashboardEditPane;
|
||||
scopesBridge: SceneScopesBridge | undefined;
|
||||
}
|
||||
|
||||
export class DashboardScene extends SceneObjectBase<DashboardSceneState> implements LayoutParent {
|
||||
@@ -169,16 +161,14 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
*/
|
||||
private _changeTracker: DashboardSceneChangeTracker;
|
||||
|
||||
/**
|
||||
* A reference to the scopes facade
|
||||
*/
|
||||
private _scopesFacade: ScopesFacade | null;
|
||||
/**
|
||||
* Remember scroll position when going into panel edit
|
||||
*/
|
||||
private _scrollRef?: ScrollRefElement;
|
||||
private _prevScrollPos?: number;
|
||||
|
||||
protected _renderBeforeActivation = true;
|
||||
|
||||
private _serializer: DashboardSceneSerializerLike<
|
||||
Dashboard | DashboardV2Spec,
|
||||
DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata']
|
||||
@@ -194,16 +184,17 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
links: state.links ?? [],
|
||||
...state,
|
||||
editPane: new DashboardEditPane(),
|
||||
scopesBridge: config.featureToggles.scopeFilters ? new SceneScopesBridge({}) : undefined,
|
||||
});
|
||||
|
||||
this._scopesFacade = getClosestScopesFacade(this);
|
||||
|
||||
this._changeTracker = new DashboardSceneChangeTracker(this);
|
||||
|
||||
this.addActivationHandler(() => this._activationHandler());
|
||||
}
|
||||
|
||||
private _activationHandler() {
|
||||
this.state.scopesBridge?.setEnabled(true);
|
||||
|
||||
let prevSceneContext = window.__grafanaSceneContext;
|
||||
const isNew = locationService.getLocation().pathname === '/dashboard/new';
|
||||
|
||||
@@ -212,6 +203,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
this._initializePanelSearch();
|
||||
|
||||
if (this.state.isEditing) {
|
||||
this.state.scopesBridge?.setReadOnly(true);
|
||||
this._initialUrlState = locationService.getLocation();
|
||||
this._changeTracker.startTrackingChanges();
|
||||
}
|
||||
@@ -236,6 +228,8 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
|
||||
// Deactivation logic
|
||||
return () => {
|
||||
this.state.scopesBridge?.setReadOnly(false);
|
||||
this.state.scopesBridge?.setEnabled(false);
|
||||
window.__grafanaSceneContext = prevSceneContext;
|
||||
clearKeyBindings();
|
||||
this._changeTracker.terminate();
|
||||
@@ -269,7 +263,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
this.state.body.editModeChanged?.(true);
|
||||
|
||||
// Propagate edit mode to scopes
|
||||
this._scopesFacade?.enterReadOnly();
|
||||
this.state.scopesBridge?.setReadOnly(true);
|
||||
|
||||
this._changeTracker.startTrackingChanges();
|
||||
};
|
||||
@@ -306,7 +300,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
|
||||
if (!this.state.isDirty || skipConfirm) {
|
||||
this.exitEditModeConfirmed(restoreInitialState || this.state.isDirty);
|
||||
this._scopesFacade?.exitReadOnly();
|
||||
this.state.scopesBridge?.setReadOnly(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -318,7 +312,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
yesText: 'Discard',
|
||||
onConfirm: () => {
|
||||
this.exitEditModeConfirmed();
|
||||
this._scopesFacade?.exitReadOnly();
|
||||
this.state.scopesBridge?.setReadOnly(false);
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -650,13 +644,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
panelId,
|
||||
panelName: panel?.state?.title,
|
||||
panelPluginId: panel?.state.pluginId,
|
||||
scopes: this._scopesFacade?.value,
|
||||
};
|
||||
}
|
||||
|
||||
public enrichFiltersRequest(): Partial<DataSourceGetTagKeysOptions | DataSourceGetTagValuesOptions> {
|
||||
return {
|
||||
scopes: this._scopesFacade?.value,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { PanelSearchLayout } from './PanelSearchLayout';
|
||||
import { DashboardAngularDeprecationBanner } from './angular/DashboardAngularDeprecationBanner';
|
||||
|
||||
export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
|
||||
const { controls, overlay, editview, editPanel, viewPanelScene, panelSearch, panelsPerRow, isEditing } =
|
||||
const { controls, overlay, editview, editPanel, viewPanelScene, panelSearch, panelsPerRow, isEditing, scopesBridge } =
|
||||
model.useState();
|
||||
const { type } = useParams();
|
||||
const location = useLocation();
|
||||
@@ -41,6 +41,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
|
||||
if (editview) {
|
||||
return (
|
||||
<>
|
||||
{scopesBridge && <scopesBridge.Component model={scopesBridge} />}
|
||||
<editview.Component model={editview} />
|
||||
{overlay && <overlay.Component model={overlay} />}
|
||||
</>
|
||||
@@ -62,6 +63,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
|
||||
|
||||
return (
|
||||
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Custom}>
|
||||
{scopesBridge && <scopesBridge.Component model={scopesBridge} />}
|
||||
{editPanel && <editPanel.Component model={editPanel} />}
|
||||
{!editPanel && (
|
||||
<DashboardEditPaneSplitter
|
||||
|
||||
@@ -141,6 +141,8 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
|
||||
this._scene.onEnterEditMode();
|
||||
}
|
||||
|
||||
this._scene.state.scopesBridge?.setReadOnly(true);
|
||||
|
||||
const libPanelBehavior = getLibraryPanelBehavior(panel);
|
||||
if (libPanelBehavior && !libPanelBehavior?.state.isLoaded) {
|
||||
this._waitForLibPanelToLoadBeforeEnteringPanelEdit(panel, libPanelBehavior);
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes';
|
||||
import { ScopesFacade } from 'app/features/scopes';
|
||||
|
||||
import { getDashboardSceneFor } from '../utils/utils';
|
||||
|
||||
import { convertScopesToAdHocFilters } from './convertScopesToAdHocFilters';
|
||||
|
||||
export interface DashboardScopesFacadeState {
|
||||
reloadOnParamsChange?: boolean;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
export class DashboardScopesFacade extends ScopesFacade {
|
||||
constructor({ reloadOnParamsChange, uid }: DashboardScopesFacadeState) {
|
||||
super({
|
||||
handler: (facade) => {
|
||||
if (!reloadOnParamsChange || !uid) {
|
||||
sceneGraph.getTimeRange(facade).onRefresh();
|
||||
}
|
||||
|
||||
// push filters as soon as they come
|
||||
this.pushScopeFiltersToAdHocVariable();
|
||||
},
|
||||
});
|
||||
|
||||
this.addActivationHandler(() => {
|
||||
// also try to push filters on activation, for
|
||||
// when the dashboard is changed
|
||||
this.pushScopeFiltersToAdHocVariable();
|
||||
});
|
||||
}
|
||||
|
||||
private pushScopeFiltersToAdHocVariable() {
|
||||
const dashboard = getDashboardSceneFor(this);
|
||||
|
||||
const adhoc = dashboard.state.$variables?.state.variables.find((v) => v instanceof AdHocFiltersVariable);
|
||||
|
||||
if (!adhoc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filters = convertScopesToAdHocFilters(this.value);
|
||||
|
||||
adhoc.setState({
|
||||
baseFilters: filters,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import { contextSrv } from 'app/core/core';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
|
||||
import { ScopesSelector } from 'app/features/scopes';
|
||||
|
||||
import { shareDashboardType } from '../../dashboard/components/ShareModal/utils';
|
||||
import { PanelEditor, buildPanelEditScene } from '../panel-edit/PanelEditor';
|
||||
@@ -76,7 +75,6 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
// Means we are not in settings view, fullscreen panel or edit panel
|
||||
const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel;
|
||||
const isEditingAndShowingDashboard = isEditing && isShowingDashboard;
|
||||
const showScopesSelector = config.featureToggles.scopeFilters && !isEditing;
|
||||
const dashboardNewLayouts = config.featureToggles.dashboardNewLayouts;
|
||||
const isManaged = Boolean(dashboard.isManaged());
|
||||
|
||||
@@ -643,11 +641,10 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
|
||||
const rightActionsElements: ReactNode[] = renderActionElements(toolbarActions);
|
||||
const leftActionsElements: ReactNode[] = renderActionElements(leftActions);
|
||||
const hasActionsToLeftAndRight = showScopesSelector || leftActionsElements.length > 0;
|
||||
const hasActionsToLeftAndRight = leftActionsElements.length > 0;
|
||||
|
||||
return (
|
||||
<Stack flex={1} minWidth={0} justifyContent={hasActionsToLeftAndRight ? 'space-between' : 'flex-end'}>
|
||||
{showScopesSelector && <ScopesSelector />}
|
||||
{leftActionsElements.length > 0 && <ToolbarButtonRow alignment="left">{leftActionsElements}</ToolbarButtonRow>}
|
||||
<ToolbarButtonRow alignment="right">{rightActionsElements}</ToolbarButtonRow>
|
||||
</Stack>
|
||||
|
||||
@@ -66,7 +66,6 @@ import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehavi
|
||||
import { registerDashboardMacro } from '../scene/DashboardMacro';
|
||||
import { DashboardReloadBehavior } from '../scene/DashboardReloadBehavior';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { DashboardScopesFacade } from '../scene/DashboardScopesFacade';
|
||||
import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager';
|
||||
import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState';
|
||||
import { getIntervalsFromQueryString } from '../utils/utils';
|
||||
@@ -188,10 +187,6 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
|
||||
new behaviors.LiveNowTimer({ enabled: dashboard.liveNow }),
|
||||
preserveDashboardSceneStateInLocalStorage,
|
||||
addPanelsOnLoadBehavior,
|
||||
new DashboardScopesFacade({
|
||||
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange,
|
||||
uid: dashboardId?.toString(),
|
||||
}),
|
||||
new DashboardReloadBehavior({
|
||||
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange,
|
||||
uid: dashboardId?.toString(),
|
||||
|
||||
@@ -36,7 +36,6 @@ import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
|
||||
import { registerDashboardMacro } from '../scene/DashboardMacro';
|
||||
import { DashboardReloadBehavior } from '../scene/DashboardReloadBehavior';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { DashboardScopesFacade } from '../scene/DashboardScopesFacade';
|
||||
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
|
||||
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
|
||||
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
|
||||
@@ -241,10 +240,6 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
|
||||
new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }),
|
||||
preserveDashboardSceneStateInLocalStorage,
|
||||
addPanelsOnLoadBehavior,
|
||||
new DashboardScopesFacade({
|
||||
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange && oldModel.meta.reloadOnParamsChange,
|
||||
uid,
|
||||
}),
|
||||
new DashboardReloadBehavior({
|
||||
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange && oldModel.meta.reloadOnParamsChange,
|
||||
uid,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { ScopesContext } from '@grafana/runtime';
|
||||
|
||||
import { ScopesService } from './ScopesService';
|
||||
|
||||
interface ScopesContextProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ScopesContextProvider = ({ children }: ScopesContextProviderProps) => {
|
||||
return <ScopesContext.Provider value={ScopesService.instance}>{children}</ScopesContext.Provider>;
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { scopesDashboardsScene } from './instance';
|
||||
|
||||
export function ScopesDashboards() {
|
||||
if (!scopesDashboardsScene) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <scopesDashboardsScene.Component model={scopesDashboardsScene} />;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import {
|
||||
SceneObjectBase,
|
||||
SceneObjectState,
|
||||
SceneObjectUrlSyncConfig,
|
||||
SceneObjectUrlValues,
|
||||
SceneObjectWithUrlSync,
|
||||
} from '@grafana/scenes';
|
||||
|
||||
import { scopesSelectorScene } from './instance';
|
||||
import { disableScopes, enableScopes, enterScopesReadOnly, exitScopesReadOnly, getSelectedScopes } from './utils';
|
||||
|
||||
interface ScopesFacadeState extends SceneObjectState {
|
||||
// A callback that will be executed when new scopes are set
|
||||
handler?: (facade: ScopesFacade) => void;
|
||||
// The render count is a workaround to force the URL sync manager to update the URL with the latest scopes
|
||||
// Basically it starts at 0, and it is increased with every scopes value update
|
||||
renderCount?: number;
|
||||
}
|
||||
|
||||
export class ScopesFacade extends SceneObjectBase<ScopesFacadeState> implements SceneObjectWithUrlSync {
|
||||
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scopes'] });
|
||||
|
||||
public constructor(state: ScopesFacadeState) {
|
||||
super({
|
||||
...state,
|
||||
renderCount: 0,
|
||||
});
|
||||
|
||||
this.addActivationHandler(this._activationHandler);
|
||||
}
|
||||
|
||||
public getUrlState() {
|
||||
return {
|
||||
scopes: this.value.map(({ metadata }) => metadata.name),
|
||||
};
|
||||
}
|
||||
|
||||
public updateFromUrl(values: SceneObjectUrlValues) {
|
||||
if (!values.scopes && !scopesSelectorScene?.state.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scopeNames = values.scopes ?? [];
|
||||
scopeNames = Array.isArray(scopeNames) ? scopeNames : [scopeNames];
|
||||
|
||||
scopesSelectorScene?.updateScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] })));
|
||||
}
|
||||
|
||||
private _activationHandler = () => {
|
||||
this.enable();
|
||||
|
||||
this._subs.add(
|
||||
scopesSelectorScene?.subscribeToState((newState, prevState) => {
|
||||
if (!newState.isLoadingScopes && (prevState.isLoadingScopes || !isEqual(newState.scopes, prevState.scopes))) {
|
||||
this.setState({ renderCount: (this.state.renderCount ?? 0) + 1 });
|
||||
this.state.handler?.(this);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
this.disable();
|
||||
};
|
||||
};
|
||||
|
||||
public get value() {
|
||||
return getSelectedScopes();
|
||||
}
|
||||
|
||||
public enable() {
|
||||
enableScopes();
|
||||
}
|
||||
|
||||
public disable() {
|
||||
disableScopes();
|
||||
}
|
||||
|
||||
public enterReadOnly() {
|
||||
enterScopesReadOnly();
|
||||
}
|
||||
|
||||
public exitReadOnly() {
|
||||
exitScopesReadOnly();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { scopesSelectorScene } from './instance';
|
||||
|
||||
export function ScopesSelector() {
|
||||
if (!scopesSelectorScene) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <scopesSelectorScene.Component model={scopesSelectorScene} />;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Scope } from '@grafana/data';
|
||||
import { config, ScopesContextValue, ScopesContextValueState } from '@grafana/runtime';
|
||||
|
||||
import { ScopesServiceBase } from './ScopesServiceBase';
|
||||
import { ScopesSelectorService } from './selector/ScopesSelectorService';
|
||||
|
||||
export class ScopesService extends ScopesServiceBase<ScopesContextValueState> implements ScopesContextValue {
|
||||
static #instance: ScopesService | undefined = undefined;
|
||||
|
||||
private constructor() {
|
||||
super({
|
||||
drawerOpened: false,
|
||||
enabled: false,
|
||||
loading: false,
|
||||
readOnly: false,
|
||||
value: [],
|
||||
});
|
||||
}
|
||||
|
||||
public static get instance(): ScopesService | undefined {
|
||||
if (!ScopesService.#instance && config.featureToggles.scopeFilters) {
|
||||
ScopesService.#instance = new ScopesService();
|
||||
}
|
||||
|
||||
return ScopesService.#instance;
|
||||
}
|
||||
|
||||
public changeScopes = (scopeNames: string[]) => ScopesSelectorService.instance?.changeScopes(scopeNames);
|
||||
|
||||
public setReadOnly = (readOnly: boolean) => {
|
||||
if (this.state.readOnly !== readOnly) {
|
||||
this.updateState({ readOnly });
|
||||
}
|
||||
|
||||
if (readOnly && ScopesSelectorService.instance?.state.opened) {
|
||||
ScopesSelectorService.instance?.closeAndReset();
|
||||
}
|
||||
};
|
||||
|
||||
public setEnabled = (enabled: boolean) => {
|
||||
if (this.state.enabled !== enabled) {
|
||||
this.updateState({ enabled });
|
||||
}
|
||||
};
|
||||
|
||||
public setScopes = (scopes: Scope[]) => this.updateState({ value: scopes });
|
||||
|
||||
public setLoading = (loading: boolean) => {
|
||||
if (this.state.loading !== loading) {
|
||||
this.updateState({ loading });
|
||||
}
|
||||
};
|
||||
|
||||
public setDrawerOpened = (drawerOpened: boolean) => {
|
||||
if (this.state.drawerOpened !== drawerOpened) {
|
||||
this.updateState({ drawerOpened });
|
||||
}
|
||||
};
|
||||
|
||||
public reset = () => {
|
||||
ScopesService.#instance = undefined;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BehaviorSubject, Observable, pairwise, Subscription } from 'rxjs';
|
||||
|
||||
import { getAPINamespace } from '../../api/utils';
|
||||
|
||||
export abstract class ScopesServiceBase<T> {
|
||||
private _state: BehaviorSubject<T>;
|
||||
protected _fetchSub: Subscription | undefined;
|
||||
protected _apiGroup = 'scope.grafana.app';
|
||||
protected _apiVersion = 'v0alpha1';
|
||||
protected _apiNamespace = getAPINamespace();
|
||||
|
||||
protected constructor(initialState: T) {
|
||||
this._state = new BehaviorSubject<T>(Object.freeze(initialState));
|
||||
}
|
||||
|
||||
public get state(): T {
|
||||
return this._state.getValue();
|
||||
}
|
||||
|
||||
public get stateObservable(): Observable<T> {
|
||||
return this._state.asObservable();
|
||||
}
|
||||
|
||||
public subscribeToState = (cb: (newState: T, prevState: T) => void): Subscription => {
|
||||
return this._state.pipe(pairwise()).subscribe(([prevState, newState]) => cb(newState, prevState));
|
||||
};
|
||||
|
||||
protected updateState = (newState: Partial<T>) => {
|
||||
this._state.next(Object.freeze({ ...this.state, ...newState }));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useObservable } from 'react-use';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useScopes } from '@grafana/runtime';
|
||||
import { Button, LoadingPlaceholder, ScrollContainer, useStyles2 } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { ScopesDashboardsService } from './ScopesDashboardsService';
|
||||
import { ScopesDashboardsTree } from './ScopesDashboardsTree';
|
||||
import { ScopesDashboardsTreeSearch } from './ScopesDashboardsTreeSearch';
|
||||
|
||||
export function ScopesDashboards() {
|
||||
const styles = useStyles2(getStyles);
|
||||
const scopes = useScopes();
|
||||
|
||||
const scopesDashboardsService = ScopesDashboardsService.instance;
|
||||
|
||||
useObservable(scopesDashboardsService?.stateObservable ?? new Observable(), scopesDashboardsService?.state);
|
||||
|
||||
if (
|
||||
!scopes ||
|
||||
!scopesDashboardsService ||
|
||||
!scopes.state.enabled ||
|
||||
!scopes.state.drawerOpened ||
|
||||
scopes.state.readOnly
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { loading, forScopeNames, dashboards, searchQuery, filteredFolders } = scopesDashboardsService.state;
|
||||
const { changeSearchQuery, updateFolder, clearSearchQuery } = scopesDashboardsService;
|
||||
|
||||
if (!loading) {
|
||||
if (forScopeNames.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.container, styles.noResultsContainer)}
|
||||
data-testid="scopes-dashboards-notFoundNoScopes"
|
||||
>
|
||||
<Trans i18nKey="scopes.dashboards.noResultsNoScopes">No scopes selected</Trans>
|
||||
</div>
|
||||
);
|
||||
} else if (dashboards.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.container, styles.noResultsContainer)}
|
||||
data-testid="scopes-dashboards-notFoundForScope"
|
||||
>
|
||||
<Trans i18nKey="scopes.dashboards.noResultsForScopes">No dashboards found for the selected scopes</Trans>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-testid="scopes-dashboards-container">
|
||||
<ScopesDashboardsTreeSearch disabled={loading} query={searchQuery} onChange={changeSearchQuery} />
|
||||
|
||||
{loading ? (
|
||||
<LoadingPlaceholder
|
||||
className={styles.loadingIndicator}
|
||||
text={t('scopes.dashboards.loading', 'Loading dashboards')}
|
||||
data-testid="scopes-dashboards-loading"
|
||||
/>
|
||||
) : filteredFolders[''] ? (
|
||||
<ScrollContainer>
|
||||
<ScopesDashboardsTree folders={filteredFolders} folderPath={['']} onFolderUpdate={updateFolder} />
|
||||
</ScrollContainer>
|
||||
) : (
|
||||
<p className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForFilter">
|
||||
<Trans i18nKey="scopes.dashboards.noResultsForFilter">No results found for your query</Trans>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={clearSearchQuery}
|
||||
data-testid="scopes-dashboards-notFoundForFilter-clear"
|
||||
>
|
||||
<Trans i18nKey="scopes.dashboards.noResultsForFilterClear">Clear search</Trans>
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
container: css({
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
borderRight: `1px solid ${theme.colors.border.weak}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(2),
|
||||
width: theme.spacing(37.5),
|
||||
}),
|
||||
noResultsContainer: css({
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
margin: 0,
|
||||
textAlign: 'center',
|
||||
}),
|
||||
loadingIndicator: css({
|
||||
alignSelf: 'center',
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
import { isEqual } from 'lodash';
|
||||
import { finalize, from } from 'rxjs';
|
||||
|
||||
import { ScopeDashboardBinding } from '@grafana/data';
|
||||
import { config, getBackendSrv } from '@grafana/runtime';
|
||||
|
||||
import { ScopesService } from '../ScopesService';
|
||||
import { ScopesServiceBase } from '../ScopesServiceBase';
|
||||
|
||||
import { SuggestedDashboardsFoldersMap } from './types';
|
||||
|
||||
interface ScopesDashboardsServiceState {
|
||||
// by keeping a track of the raw response, it's much easier to check if we got any dashboards for the currently selected scopes
|
||||
dashboards: ScopeDashboardBinding[];
|
||||
// a filtered version of the `folders` property. this prevents a lot of unnecessary parsings in React renders
|
||||
filteredFolders: SuggestedDashboardsFoldersMap;
|
||||
// this is a grouping in folders of the `dashboards` property. it is used for filtering the dashboards and folders when the search query changes
|
||||
folders: SuggestedDashboardsFoldersMap;
|
||||
forScopeNames: string[];
|
||||
loading: boolean;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsServiceState> {
|
||||
static #instance: ScopesDashboardsService | undefined = undefined;
|
||||
|
||||
private constructor() {
|
||||
super({
|
||||
dashboards: [],
|
||||
filteredFolders: {},
|
||||
folders: {},
|
||||
forScopeNames: [],
|
||||
loading: false,
|
||||
searchQuery: '',
|
||||
});
|
||||
}
|
||||
|
||||
public static get instance(): ScopesDashboardsService | undefined {
|
||||
if (!ScopesDashboardsService.#instance && config.featureToggles.scopeFilters) {
|
||||
ScopesDashboardsService.#instance = new ScopesDashboardsService();
|
||||
}
|
||||
|
||||
return ScopesDashboardsService.#instance;
|
||||
}
|
||||
|
||||
public updateFolder = (path: string[], expanded: boolean) => {
|
||||
let folders = { ...this.state.folders };
|
||||
let filteredFolders = { ...this.state.filteredFolders };
|
||||
let currentLevelFolders: SuggestedDashboardsFoldersMap = folders;
|
||||
let currentLevelFilteredFolders: SuggestedDashboardsFoldersMap = filteredFolders;
|
||||
|
||||
for (let idx = 0; idx < path.length - 1; idx++) {
|
||||
currentLevelFolders = currentLevelFolders[path[idx]].folders;
|
||||
currentLevelFilteredFolders = currentLevelFilteredFolders[path[idx]].folders;
|
||||
}
|
||||
|
||||
const name = path[path.length - 1];
|
||||
const currentFolder = currentLevelFolders[name];
|
||||
const currentFilteredFolder = currentLevelFilteredFolders[name];
|
||||
|
||||
currentFolder.expanded = expanded;
|
||||
currentFilteredFolder.expanded = expanded;
|
||||
|
||||
this.updateState({ folders, filteredFolders });
|
||||
};
|
||||
|
||||
public changeSearchQuery = (searchQuery: string) => {
|
||||
searchQuery = searchQuery ?? '';
|
||||
|
||||
const filteredFolders = this.filterFolders(this.state.folders, searchQuery);
|
||||
|
||||
this.updateState({ filteredFolders, searchQuery });
|
||||
};
|
||||
|
||||
public clearSearchQuery = () => {
|
||||
this.changeSearchQuery('');
|
||||
};
|
||||
|
||||
public fetchDashboards = async (forScopeNames: string[]) => {
|
||||
if (isEqual(this.state.forScopeNames, forScopeNames)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._fetchSub?.unsubscribe();
|
||||
|
||||
if (forScopeNames.length === 0) {
|
||||
this.updateState({
|
||||
dashboards: [],
|
||||
filteredFolders: {},
|
||||
folders: {},
|
||||
forScopeNames: [],
|
||||
loading: false,
|
||||
});
|
||||
|
||||
ScopesService.instance?.setDrawerOpened(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateState({ forScopeNames, loading: true });
|
||||
|
||||
this._fetchSub = from(this.fetchDashboardsApi(forScopeNames))
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
this.updateState({ loading: false });
|
||||
})
|
||||
)
|
||||
.subscribe((dashboards) => {
|
||||
const folders = this.groupDashboards(dashboards);
|
||||
const filteredFolders = this.filterFolders(folders, this.state.searchQuery);
|
||||
|
||||
this.updateState({ dashboards, filteredFolders, folders, loading: false });
|
||||
|
||||
ScopesService.instance?.setDrawerOpened(dashboards.length > 0);
|
||||
|
||||
this._fetchSub?.unsubscribe();
|
||||
});
|
||||
};
|
||||
|
||||
public groupDashboards = (dashboards: ScopeDashboardBinding[]): SuggestedDashboardsFoldersMap => {
|
||||
return dashboards.reduce<SuggestedDashboardsFoldersMap>(
|
||||
(acc, dashboard) => {
|
||||
const rootNode = acc[''];
|
||||
const groups = dashboard.status.groups ?? [];
|
||||
|
||||
groups.forEach((group) => {
|
||||
if (group && !rootNode.folders[group]) {
|
||||
rootNode.folders[group] = {
|
||||
title: group,
|
||||
expanded: false,
|
||||
folders: {},
|
||||
dashboards: {},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const targets =
|
||||
groups.length > 0
|
||||
? groups.map((group) => (group === '' ? rootNode.dashboards : rootNode.folders[group].dashboards))
|
||||
: [rootNode.dashboards];
|
||||
|
||||
targets.forEach((target) => {
|
||||
if (!target[dashboard.spec.dashboard]) {
|
||||
target[dashboard.spec.dashboard] = {
|
||||
dashboard: dashboard.spec.dashboard,
|
||||
dashboardTitle: dashboard.status.dashboardTitle,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
target[dashboard.spec.dashboard].items.push(dashboard);
|
||||
});
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
'': {
|
||||
title: '',
|
||||
expanded: true,
|
||||
folders: {},
|
||||
dashboards: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
public filterFolders = (folders: SuggestedDashboardsFoldersMap, query: string): SuggestedDashboardsFoldersMap => {
|
||||
query = (query ?? '').toLowerCase();
|
||||
|
||||
return Object.entries(folders).reduce<SuggestedDashboardsFoldersMap>((acc, [folderId, folder]) => {
|
||||
// If folder matches the query, we show everything inside
|
||||
if (folder.title.toLowerCase().includes(query)) {
|
||||
acc[folderId] = {
|
||||
...folder,
|
||||
expanded: true,
|
||||
};
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
const filteredFolders = this.filterFolders(folder.folders, query);
|
||||
const filteredDashboards = Object.entries(folder.dashboards).filter(([_, dashboard]) =>
|
||||
dashboard.dashboardTitle.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
if (Object.keys(filteredFolders).length > 0 || filteredDashboards.length > 0) {
|
||||
acc[folderId] = {
|
||||
...folder,
|
||||
expanded: true,
|
||||
folders: filteredFolders,
|
||||
dashboards: Object.fromEntries(filteredDashboards),
|
||||
};
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
public fetchDashboardsApi = async (scopeNames: string[]): Promise<ScopeDashboardBinding[]> => {
|
||||
try {
|
||||
const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(
|
||||
`/apis/${this._apiGroup}/${this._apiVersion}/namespaces/${this._apiNamespace}/find/scope_dashboard_bindings`,
|
||||
{
|
||||
scope: scopeNames,
|
||||
}
|
||||
);
|
||||
|
||||
return response?.items ?? [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
public reset = () => {
|
||||
ScopesDashboardsService.#instance = undefined;
|
||||
};
|
||||
}
|
||||
+5
-5
@@ -23,23 +23,23 @@ export function ScopesDashboardsTreeFolderItem({
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.container} role="treeitem" aria-selected={folder.isExpanded}>
|
||||
<div className={styles.container} role="treeitem" aria-selected={folder.expanded}>
|
||||
<button
|
||||
className={styles.expand}
|
||||
data-testid={`scopes-dashboards-${folder.title}-expand`}
|
||||
aria-label={
|
||||
folder.isExpanded ? t('scopes.dashboards.collapse', 'Collapse') : t('scopes.dashboards.expand', 'Expand')
|
||||
folder.expanded ? t('scopes.dashboards.collapse', 'Collapse') : t('scopes.dashboards.expand', 'Expand')
|
||||
}
|
||||
onClick={() => {
|
||||
onFolderUpdate(folderPath, !folder.isExpanded);
|
||||
onFolderUpdate(folderPath, !folder.expanded);
|
||||
}}
|
||||
>
|
||||
<Icon name={!folder.isExpanded ? 'angle-right' : 'angle-down'} className={styles.icon} />
|
||||
<Icon name={!folder.expanded ? 'angle-right' : 'angle-down'} className={styles.icon} />
|
||||
|
||||
{folder.title}
|
||||
</button>
|
||||
|
||||
{folder.isExpanded && (
|
||||
{folder.expanded && (
|
||||
<div className={styles.children}>
|
||||
<ScopesDashboardsTree folders={folders} folderPath={folderPath} onFolderUpdate={onFolderUpdate} />
|
||||
</div>
|
||||
+6
-6
@@ -15,21 +15,21 @@ export interface ScopesDashboardsTreeSearchProps {
|
||||
export function ScopesDashboardsTreeSearch({ disabled, query, onChange }: ScopesDashboardsTreeSearchProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [inputState, setInputState] = useState<{ value: string; isDirty: boolean }>({ value: query, isDirty: false });
|
||||
const [inputState, setInputState] = useState<{ value: string; dirty: boolean }>({ value: query, dirty: false });
|
||||
|
||||
const [getDebounceState] = useDebounce(
|
||||
() => {
|
||||
if (inputState.isDirty) {
|
||||
if (inputState.dirty) {
|
||||
onChange(inputState.value);
|
||||
}
|
||||
},
|
||||
500,
|
||||
[inputState.isDirty, inputState.value]
|
||||
[inputState.dirty, inputState.value]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if ((getDebounceState() || !inputState.isDirty) && inputState.value !== query) {
|
||||
setInputState({ value: query, isDirty: false });
|
||||
if ((getDebounceState() || !inputState.dirty) && inputState.value !== query) {
|
||||
setInputState({ value: query, dirty: false });
|
||||
}
|
||||
}, [getDebounceState, inputState, query]);
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ScopesDashboardsTreeSearch({ disabled, query, onChange }: Scopes
|
||||
placeholder={t('scopes.dashboards.search', 'Search')}
|
||||
value={inputState.value}
|
||||
data-testid="scopes-dashboards-search"
|
||||
onChange={(value) => setInputState({ value, isDirty: true })}
|
||||
onChange={(value) => setInputState({ value, dirty: true })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ScopeDashboardBinding } from '@grafana/data';
|
||||
|
||||
export interface SuggestedDashboard {
|
||||
dashboard: string;
|
||||
dashboardTitle: string;
|
||||
items: ScopeDashboardBinding[];
|
||||
}
|
||||
|
||||
export interface SuggestedDashboardsFolder {
|
||||
title: string;
|
||||
expanded: boolean;
|
||||
folders: SuggestedDashboardsFoldersMap;
|
||||
dashboards: SuggestedDashboardsMap;
|
||||
}
|
||||
|
||||
export type SuggestedDashboardsMap = Record<string, SuggestedDashboard>;
|
||||
export type SuggestedDashboardsFoldersMap = Record<string, SuggestedDashboardsFolder>;
|
||||
|
||||
export type OnFolderUpdate = (path: string[], expanded: boolean) => void;
|
||||
@@ -1,15 +0,0 @@
|
||||
export { initializeScopes } from './instance';
|
||||
export { ScopesDashboards } from './ScopesDashboards';
|
||||
/* eslint-disable */
|
||||
export { ScopesFacade } from './ScopesFacadeScene';
|
||||
export { ScopesSelector } from './ScopesSelector';
|
||||
export { useScopesDashboardsState } from './useScopesDashboardsState';
|
||||
export {
|
||||
disableScopes,
|
||||
enableScopes,
|
||||
enterScopesReadOnly,
|
||||
exitScopesReadOnly,
|
||||
getClosestScopesFacade,
|
||||
getSelectedScopes,
|
||||
getSelectedScopesNames,
|
||||
} from './utils';
|
||||
@@ -1,17 +0,0 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { ScopesDashboardsScene } from './internal/ScopesDashboardsScene';
|
||||
import { ScopesSelectorScene } from './internal/ScopesSelectorScene';
|
||||
|
||||
export let scopesDashboardsScene: ScopesDashboardsScene | null = null;
|
||||
export let scopesSelectorScene: ScopesSelectorScene | null = null;
|
||||
|
||||
export function initializeScopes() {
|
||||
if (config.featureToggles.scopeFilters) {
|
||||
scopesSelectorScene = new ScopesSelectorScene();
|
||||
scopesDashboardsScene = new ScopesDashboardsScene();
|
||||
|
||||
scopesSelectorScene.setState({ dashboards: scopesDashboardsScene.getRef() });
|
||||
scopesDashboardsScene.setState({ selector: scopesSelectorScene.getRef() });
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { isEqual } from 'lodash';
|
||||
import { finalize, from, Subscription } from 'rxjs';
|
||||
|
||||
import { GrafanaTheme2, ScopeDashboardBinding } from '@grafana/data';
|
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState } from '@grafana/scenes';
|
||||
import { Button, LoadingPlaceholder, ScrollContainer, useStyles2 } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { ScopesDashboardsTree } from './ScopesDashboardsTree';
|
||||
import { ScopesDashboardsTreeSearch } from './ScopesDashboardsTreeSearch';
|
||||
import { ScopesSelectorScene } from './ScopesSelectorScene';
|
||||
import { fetchDashboards } from './api';
|
||||
import { SuggestedDashboardsFoldersMap } from './types';
|
||||
import { filterFolders, groupDashboards } from './utils';
|
||||
|
||||
export interface ScopesDashboardsSceneState extends SceneObjectState {
|
||||
selector: SceneObjectRef<ScopesSelectorScene> | null;
|
||||
// by keeping a track of the raw response, it's much easier to check if we got any dashboards for the currently selected scopes
|
||||
dashboards: ScopeDashboardBinding[];
|
||||
// this is a grouping in folders of the `dashboards` property. it is used for filtering the dashboards and folders when the search query changes
|
||||
folders: SuggestedDashboardsFoldersMap;
|
||||
// a filtered version of the `folders` property. this prevents a lot of unnecessary parsings in React renders
|
||||
filteredFolders: SuggestedDashboardsFoldersMap;
|
||||
forScopeNames: string[];
|
||||
isLoading: boolean;
|
||||
isPanelOpened: boolean;
|
||||
isEnabled: boolean;
|
||||
isReadOnly: boolean;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
export const getInitialDashboardsState: () => Omit<ScopesDashboardsSceneState, 'selector'> = () => ({
|
||||
dashboards: [],
|
||||
folders: {},
|
||||
filteredFolders: {},
|
||||
forScopeNames: [],
|
||||
isLoading: false,
|
||||
isPanelOpened: false,
|
||||
isEnabled: false,
|
||||
isReadOnly: false,
|
||||
searchQuery: '',
|
||||
});
|
||||
|
||||
export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsSceneState> {
|
||||
static Component = ScopesDashboardsSceneRenderer;
|
||||
|
||||
private dashboardsFetchingSub: Subscription | undefined;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
selector: null,
|
||||
...getInitialDashboardsState(),
|
||||
});
|
||||
|
||||
this.addActivationHandler(() => {
|
||||
return () => {
|
||||
this.dashboardsFetchingSub?.unsubscribe();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async fetchDashboards(scopeNames: string[]) {
|
||||
if (isEqual(this.state.forScopeNames, scopeNames)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dashboardsFetchingSub?.unsubscribe();
|
||||
|
||||
this.setState({ forScopeNames: scopeNames });
|
||||
|
||||
if (scopeNames.length === 0) {
|
||||
return this.setState({
|
||||
dashboards: [],
|
||||
folders: {},
|
||||
filteredFolders: {},
|
||||
forScopeNames: [],
|
||||
isLoading: false,
|
||||
isPanelOpened: false,
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
this.dashboardsFetchingSub = from(fetchDashboards(scopeNames))
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
this.setState({ isLoading: false });
|
||||
})
|
||||
)
|
||||
.subscribe((dashboards) => {
|
||||
const folders = groupDashboards(dashboards);
|
||||
const filteredFolders = filterFolders(folders, this.state.searchQuery);
|
||||
|
||||
this.setState({
|
||||
dashboards,
|
||||
folders,
|
||||
filteredFolders,
|
||||
isLoading: false,
|
||||
isPanelOpened: scopeNames.length > 0,
|
||||
});
|
||||
|
||||
this.dashboardsFetchingSub?.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
public changeSearchQuery(searchQuery: string) {
|
||||
searchQuery = searchQuery ?? '';
|
||||
|
||||
this.setState({
|
||||
filteredFolders: filterFolders(this.state.folders, searchQuery),
|
||||
searchQuery,
|
||||
});
|
||||
}
|
||||
|
||||
public updateFolder(path: string[], isExpanded: boolean) {
|
||||
let folders = { ...this.state.folders };
|
||||
let filteredFolders = { ...this.state.filteredFolders };
|
||||
let currentLevelFolders: SuggestedDashboardsFoldersMap = folders;
|
||||
let currentLevelFilteredFolders: SuggestedDashboardsFoldersMap = filteredFolders;
|
||||
|
||||
for (let idx = 0; idx < path.length - 1; idx++) {
|
||||
currentLevelFolders = currentLevelFolders[path[idx]].folders;
|
||||
currentLevelFilteredFolders = currentLevelFilteredFolders[path[idx]].folders;
|
||||
}
|
||||
|
||||
const name = path[path.length - 1];
|
||||
const currentFolder = currentLevelFolders[name];
|
||||
const currentFilteredFolder = currentLevelFilteredFolders[name];
|
||||
|
||||
currentFolder.isExpanded = isExpanded;
|
||||
currentFilteredFolder.isExpanded = isExpanded;
|
||||
|
||||
this.setState({ folders, filteredFolders });
|
||||
}
|
||||
|
||||
public togglePanel() {
|
||||
if (this.state.isPanelOpened) {
|
||||
this.closePanel();
|
||||
} else {
|
||||
this.openPanel();
|
||||
}
|
||||
}
|
||||
|
||||
public openPanel() {
|
||||
if (this.state.isPanelOpened) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isPanelOpened: true });
|
||||
}
|
||||
|
||||
public closePanel() {
|
||||
if (!this.state.isPanelOpened) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isPanelOpened: false });
|
||||
}
|
||||
|
||||
public enable() {
|
||||
this.setState({ isEnabled: true });
|
||||
}
|
||||
|
||||
public disable() {
|
||||
this.setState({ isEnabled: false });
|
||||
}
|
||||
|
||||
public enterReadOnly() {
|
||||
this.setState({ isReadOnly: true });
|
||||
}
|
||||
|
||||
public exitReadOnly() {
|
||||
this.setState({ isReadOnly: false });
|
||||
}
|
||||
}
|
||||
|
||||
export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<ScopesDashboardsScene>) {
|
||||
const { dashboards, filteredFolders, forScopeNames, isLoading, isPanelOpened, isEnabled, isReadOnly, searchQuery } =
|
||||
model.useState();
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (!isEnabled || !isPanelOpened || isReadOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isLoading) {
|
||||
if (forScopeNames.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.container, styles.noResultsContainer)}
|
||||
data-testid="scopes-dashboards-notFoundNoScopes"
|
||||
>
|
||||
<Trans i18nKey="scopes.dashboards.noResultsNoScopes">No scopes selected</Trans>
|
||||
</div>
|
||||
);
|
||||
} else if (dashboards.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.container, styles.noResultsContainer)}
|
||||
data-testid="scopes-dashboards-notFoundForScope"
|
||||
>
|
||||
<Trans i18nKey="scopes.dashboards.noResultsForScopes">No dashboards found for the selected scopes</Trans>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-testid="scopes-dashboards-container">
|
||||
<ScopesDashboardsTreeSearch
|
||||
disabled={isLoading}
|
||||
query={searchQuery}
|
||||
onChange={(value) => model.changeSearchQuery(value)}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingPlaceholder
|
||||
className={styles.loadingIndicator}
|
||||
text={t('scopes.dashboards.loading', 'Loading dashboards')}
|
||||
data-testid="scopes-dashboards-loading"
|
||||
/>
|
||||
) : filteredFolders[''] ? (
|
||||
<ScrollContainer>
|
||||
<ScopesDashboardsTree
|
||||
folders={filteredFolders}
|
||||
folderPath={['']}
|
||||
onFolderUpdate={(path, isExpanded) => model.updateFolder(path, isExpanded)}
|
||||
/>
|
||||
</ScrollContainer>
|
||||
) : (
|
||||
<p className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForFilter">
|
||||
<Trans i18nKey="scopes.dashboards.noResultsForFilter">No results found for your query</Trans>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => model.changeSearchQuery('')}
|
||||
data-testid="scopes-dashboards-notFoundForFilter-clear"
|
||||
>
|
||||
<Trans i18nKey="scopes.dashboards.noResultsForFilterClear">Clear search</Trans>
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
container: css({
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
borderRight: `1px solid ${theme.colors.border.weak}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(2),
|
||||
width: theme.spacing(37.5),
|
||||
}),
|
||||
noResultsContainer: css({
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
margin: 0,
|
||||
textAlign: 'center',
|
||||
}),
|
||||
loadingIndicator: css({
|
||||
alignSelf: 'center',
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,410 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { isEqual } from 'lodash';
|
||||
import { finalize, from, Subscription } from 'rxjs';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState } from '@grafana/scenes';
|
||||
import { Button, Drawer, IconButton, Spinner, useStyles2 } from '@grafana/ui';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { ScopesDashboardsScene } from './ScopesDashboardsScene';
|
||||
import { ScopesInput } from './ScopesInput';
|
||||
import { ScopesTree } from './ScopesTree';
|
||||
import { fetchNodes, fetchScope, fetchSelectedScopes } from './api';
|
||||
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types';
|
||||
import { getBasicScope, getScopesAndTreeScopesWithPaths, getTreeScopesFromSelectedScopes } from './utils';
|
||||
|
||||
export interface ScopesSelectorSceneState extends SceneObjectState {
|
||||
dashboards: SceneObjectRef<ScopesDashboardsScene> | null;
|
||||
nodes: NodesMap;
|
||||
loadingNodeName: string | undefined;
|
||||
scopes: SelectedScope[];
|
||||
treeScopes: TreeScope[];
|
||||
isReadOnly: boolean;
|
||||
isLoadingScopes: boolean;
|
||||
isPickerOpened: boolean;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export const initialSelectorState: Omit<ScopesSelectorSceneState, 'dashboards'> = {
|
||||
nodes: {
|
||||
'': {
|
||||
name: '',
|
||||
reason: NodeReason.Result,
|
||||
nodeType: 'container',
|
||||
title: '',
|
||||
isExpandable: true,
|
||||
isSelectable: false,
|
||||
isExpanded: true,
|
||||
query: '',
|
||||
nodes: {},
|
||||
},
|
||||
},
|
||||
loadingNodeName: undefined,
|
||||
scopes: [],
|
||||
treeScopes: [],
|
||||
isReadOnly: false,
|
||||
isLoadingScopes: false,
|
||||
isPickerOpened: false,
|
||||
isEnabled: false,
|
||||
};
|
||||
|
||||
export class ScopesSelectorScene extends SceneObjectBase<ScopesSelectorSceneState> {
|
||||
static Component = ScopesSelectorSceneRenderer;
|
||||
|
||||
private nodesFetchingSub: Subscription | undefined;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
dashboards: null,
|
||||
...initialSelectorState,
|
||||
});
|
||||
|
||||
this.addActivationHandler(() => {
|
||||
// Only fetch base nodes on activation when there are no nodes fetched
|
||||
// This prevents an issue where base nodes are overwritten upon re-activations
|
||||
if (Object.keys(this.state.nodes[''].nodes).length === 0) {
|
||||
this.fetchBaseNodes();
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.nodesFetchingSub?.unsubscribe();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public fetchBaseNodes() {
|
||||
return this.updateNode([''], true, '');
|
||||
}
|
||||
|
||||
public async updateNode(path: string[], isExpanded: boolean, query: string) {
|
||||
this.nodesFetchingSub?.unsubscribe();
|
||||
|
||||
let nodes = { ...this.state.nodes };
|
||||
let currentLevel: NodesMap = nodes;
|
||||
|
||||
for (let idx = 0; idx < path.length - 1; idx++) {
|
||||
currentLevel = currentLevel[path[idx]].nodes;
|
||||
}
|
||||
|
||||
const name = path[path.length - 1];
|
||||
const currentNode = currentLevel[name];
|
||||
|
||||
const isDifferentQuery = currentNode.query !== query;
|
||||
|
||||
currentNode.isExpanded = isExpanded;
|
||||
currentNode.query = query;
|
||||
|
||||
if (isExpanded || isDifferentQuery) {
|
||||
this.setState({ nodes, loadingNodeName: name });
|
||||
|
||||
this.nodesFetchingSub = from(fetchNodes(name, query))
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
this.setState({ loadingNodeName: undefined });
|
||||
})
|
||||
)
|
||||
.subscribe((childNodes) => {
|
||||
const [scopes, treeScopes] = getScopesAndTreeScopesWithPaths(
|
||||
this.state.scopes,
|
||||
this.state.treeScopes,
|
||||
path,
|
||||
childNodes
|
||||
);
|
||||
|
||||
const persistedNodes = treeScopes
|
||||
.map(({ path }) => path[path.length - 1])
|
||||
.filter((nodeName) => nodeName in currentNode.nodes && !(nodeName in childNodes))
|
||||
.reduce<NodesMap>((acc, nodeName) => {
|
||||
acc[nodeName] = {
|
||||
...currentNode.nodes[nodeName],
|
||||
reason: NodeReason.Persisted,
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
currentNode.nodes = { ...persistedNodes, ...childNodes };
|
||||
|
||||
this.setState({ nodes, scopes, treeScopes });
|
||||
|
||||
this.nodesFetchingSub?.unsubscribe();
|
||||
});
|
||||
} else {
|
||||
this.setState({ nodes, loadingNodeName: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
public toggleNodeSelect(path: string[]) {
|
||||
let treeScopes = [...this.state.treeScopes];
|
||||
|
||||
let parentNode = this.state.nodes[''];
|
||||
|
||||
for (let idx = 1; idx < path.length - 1; idx++) {
|
||||
parentNode = parentNode.nodes[path[idx]];
|
||||
}
|
||||
|
||||
const nodeName = path[path.length - 1];
|
||||
const { linkId } = parentNode.nodes[nodeName];
|
||||
|
||||
const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId);
|
||||
|
||||
if (selectedIdx === -1) {
|
||||
fetchScope(linkId!);
|
||||
|
||||
const selectedFromSameNode =
|
||||
treeScopes.length === 0 ||
|
||||
Object.values(parentNode.nodes).some(({ linkId }) => linkId === treeScopes[0].scopeName);
|
||||
|
||||
const treeScope = {
|
||||
scopeName: linkId!,
|
||||
path,
|
||||
};
|
||||
|
||||
this.setState({
|
||||
treeScopes: parentNode?.disableMultiSelect || !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope],
|
||||
});
|
||||
} else {
|
||||
treeScopes.splice(selectedIdx, 1);
|
||||
|
||||
this.setState({ treeScopes });
|
||||
}
|
||||
}
|
||||
|
||||
public openPicker() {
|
||||
if (!this.state.isReadOnly) {
|
||||
let nodes = { ...this.state.nodes };
|
||||
|
||||
// First close all nodes
|
||||
nodes = this.closeNodes(nodes);
|
||||
|
||||
// Extract the path of a scope
|
||||
let path = [...(this.state.scopes[0]?.path ?? ['', ''])];
|
||||
path.splice(path.length - 1, 1);
|
||||
|
||||
// Expand the nodes to the selected scope
|
||||
nodes = this.expandNodes(nodes, path);
|
||||
|
||||
this.setState({ isPickerOpened: true, nodes });
|
||||
}
|
||||
}
|
||||
|
||||
public closePicker() {
|
||||
this.setState({ isPickerOpened: false });
|
||||
}
|
||||
|
||||
public async updateScopes(treeScopes = this.state.treeScopes) {
|
||||
if (isEqual(treeScopes, getTreeScopesFromSelectedScopes(this.state.scopes))) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
// Update the scopes with the basic scopes otherwise they'd be lost between URL syncs
|
||||
scopes: treeScopes.map(({ scopeName, path }) => ({ scope: getBasicScope(scopeName), path })),
|
||||
treeScopes,
|
||||
isLoadingScopes: true,
|
||||
});
|
||||
|
||||
this.state.dashboards?.resolve().fetchDashboards(treeScopes.map(({ scopeName }) => scopeName));
|
||||
|
||||
const scopes = await fetchSelectedScopes(treeScopes);
|
||||
|
||||
this.setState({ scopes, isLoadingScopes: false });
|
||||
}
|
||||
|
||||
public resetDirtyScopeNames() {
|
||||
this.setState({ treeScopes: getTreeScopesFromSelectedScopes(this.state.scopes) });
|
||||
}
|
||||
|
||||
public async removeAllScopes() {
|
||||
return this.updateScopes([]);
|
||||
}
|
||||
|
||||
public enterReadOnly() {
|
||||
this.setState({ isReadOnly: true, isPickerOpened: false });
|
||||
}
|
||||
|
||||
public exitReadOnly() {
|
||||
this.setState({ isReadOnly: false });
|
||||
}
|
||||
|
||||
public enable() {
|
||||
this.setState({ isEnabled: true });
|
||||
}
|
||||
|
||||
public disable() {
|
||||
this.setState({ isEnabled: false });
|
||||
}
|
||||
|
||||
private closeNodes(nodes: NodesMap): NodesMap {
|
||||
return Object.entries(nodes).reduce<NodesMap>((acc, [id, node]) => {
|
||||
acc[id] = {
|
||||
...node,
|
||||
isExpanded: false,
|
||||
nodes: this.closeNodes(node.nodes),
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
private expandNodes(nodes: NodesMap, path: string[]): NodesMap {
|
||||
nodes = { ...nodes };
|
||||
let currentNodes = nodes;
|
||||
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const nodeId = path[i];
|
||||
|
||||
currentNodes[nodeId] = {
|
||||
...currentNodes[nodeId],
|
||||
isExpanded: true,
|
||||
};
|
||||
currentNodes = currentNodes[nodeId].nodes;
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
|
||||
export function ScopesSelectorSceneRenderer({ model }: SceneComponentProps<ScopesSelectorScene>) {
|
||||
const { chrome } = useGrafana();
|
||||
const state = chrome.useState();
|
||||
const menuDockedAndOpen = !state.chromeless && state.megaMenuDocked && state.megaMenuOpen;
|
||||
const styles = useStyles2(getStyles, menuDockedAndOpen);
|
||||
const {
|
||||
dashboards: dashboardsRef,
|
||||
nodes,
|
||||
loadingNodeName,
|
||||
scopes,
|
||||
treeScopes,
|
||||
isReadOnly,
|
||||
isLoadingScopes,
|
||||
isPickerOpened,
|
||||
isEnabled,
|
||||
} = model.useState();
|
||||
|
||||
const dashboards = dashboardsRef?.resolve();
|
||||
|
||||
const { isPanelOpened: isDashboardsPanelOpened } = dashboards?.useState() ?? {};
|
||||
|
||||
if (!isEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dashboardsIconLabel = isReadOnly
|
||||
? t('scopes.dashboards.toggle.disabled', 'Suggested dashboards list is disabled due to read only mode')
|
||||
: isDashboardsPanelOpened
|
||||
? t('scopes.dashboards.toggle.collapse', 'Collapse suggested dashboards list')
|
||||
: t('scopes.dashboards.toggle..expand', 'Expand suggested dashboards list');
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<IconButton
|
||||
name="web-section-alt"
|
||||
className={styles.dashboards}
|
||||
aria-label={dashboardsIconLabel}
|
||||
tooltip={dashboardsIconLabel}
|
||||
data-testid="scopes-dashboards-expand"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => dashboards?.togglePanel()}
|
||||
/>
|
||||
|
||||
<ScopesInput
|
||||
nodes={nodes}
|
||||
scopes={scopes}
|
||||
isDisabled={isReadOnly}
|
||||
isLoading={isLoadingScopes}
|
||||
onInputClick={() => model.openPicker()}
|
||||
onRemoveAllClick={() => model.removeAllScopes()}
|
||||
/>
|
||||
|
||||
{isPickerOpened && (
|
||||
<Drawer
|
||||
title={t('scopes.selector.title', 'Select scopes')}
|
||||
size="sm"
|
||||
onClose={() => {
|
||||
model.closePicker();
|
||||
model.resetDirtyScopeNames();
|
||||
}}
|
||||
>
|
||||
<div className={styles.drawerContainer}>
|
||||
<div className={styles.treeContainer}>
|
||||
{isLoadingScopes ? (
|
||||
<Spinner data-testid="scopes-selector-loading" />
|
||||
) : (
|
||||
<ScopesTree
|
||||
nodes={nodes}
|
||||
nodePath={['']}
|
||||
loadingNodeName={loadingNodeName}
|
||||
scopes={treeScopes}
|
||||
onNodeUpdate={(path, isExpanded, query) => model.updateNode(path, isExpanded, query)}
|
||||
onNodeSelectToggle={(path) => model.toggleNodeSelect(path)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonsContainer}>
|
||||
<Button
|
||||
variant="primary"
|
||||
data-testid="scopes-selector-apply"
|
||||
onClick={() => {
|
||||
model.closePicker();
|
||||
model.updateScopes();
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="scopes.selector.apply">Apply</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
data-testid="scopes-selector-cancel"
|
||||
onClick={() => {
|
||||
model.closePicker();
|
||||
model.resetDirtyScopeNames();
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="scopes.selector.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, menuDockedAndOpen: boolean) => {
|
||||
return {
|
||||
container: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
paddingLeft: menuDockedAndOpen ? theme.spacing(2) : 'unset',
|
||||
}),
|
||||
dashboards: css({
|
||||
color: theme.colors.text.secondary,
|
||||
marginRight: theme.spacing(2),
|
||||
|
||||
'&:hover': css({
|
||||
color: theme.colors.text.primary,
|
||||
}),
|
||||
}),
|
||||
drawerContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}),
|
||||
treeContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '100%',
|
||||
overflowY: 'hidden',
|
||||
// Fix for top level search outline overflow due to scrollbars
|
||||
paddingLeft: theme.spacing(0.5),
|
||||
}),
|
||||
buttonsContainer: css({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
marginTop: theme.spacing(8),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Scope, ScopeDashboardBinding, ScopeNode, ScopeSpec } from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { ScopedResourceClient } from 'app/features/apiserver/client';
|
||||
|
||||
import { getAPINamespace } from '../../../api/utils';
|
||||
|
||||
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types';
|
||||
import { getBasicScope, mergeScopes } from './utils';
|
||||
|
||||
const group = 'scope.grafana.app';
|
||||
const version = 'v0alpha1';
|
||||
const namespace = getAPINamespace();
|
||||
|
||||
const nodesEndpoint = `/apis/${group}/${version}/namespaces/${namespace}/find/scope_node_children`;
|
||||
const dashboardsEndpoint = `/apis/${group}/${version}/namespaces/${namespace}/find/scope_dashboard_bindings`;
|
||||
|
||||
const scopesClient = new ScopedResourceClient<ScopeSpec, unknown, 'Scope'>({
|
||||
group,
|
||||
version,
|
||||
resource: 'scopes',
|
||||
});
|
||||
|
||||
const scopesCache = new Map<string, Promise<Scope>>();
|
||||
|
||||
async function fetchScopeNodes(parent: string, query: string): Promise<ScopeNode[]> {
|
||||
try {
|
||||
return (await getBackendSrv().get<{ items: ScopeNode[] }>(nodesEndpoint, { parent, query }))?.items ?? [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchNodes(parent: string, query: string): Promise<NodesMap> {
|
||||
return (await fetchScopeNodes(parent, query)).reduce<NodesMap>((acc, { metadata: { name }, spec }) => {
|
||||
acc[name] = {
|
||||
name,
|
||||
...spec,
|
||||
isExpandable: spec.nodeType === 'container',
|
||||
isSelectable: spec.linkType === 'scope',
|
||||
isExpanded: false,
|
||||
query: '',
|
||||
reason: NodeReason.Result,
|
||||
nodes: {},
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export async function fetchScope(name: string): Promise<Scope> {
|
||||
if (scopesCache.has(name)) {
|
||||
return scopesCache.get(name)!;
|
||||
}
|
||||
|
||||
const response = new Promise<Scope>(async (resolve) => {
|
||||
const basicScope = getBasicScope(name);
|
||||
|
||||
try {
|
||||
const serverScope = await scopesClient.get(name);
|
||||
|
||||
const scope = mergeScopes(basicScope, serverScope);
|
||||
|
||||
resolve(scope);
|
||||
} catch (err) {
|
||||
scopesCache.delete(name);
|
||||
|
||||
resolve(basicScope);
|
||||
}
|
||||
});
|
||||
|
||||
scopesCache.set(name, response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function fetchScopes(names: string[]): Promise<Scope[]> {
|
||||
return await Promise.all(names.map(fetchScope));
|
||||
}
|
||||
|
||||
export async function fetchSelectedScopes(treeScopes: TreeScope[]): Promise<SelectedScope[]> {
|
||||
const scopes = await fetchScopes(treeScopes.map(({ scopeName }) => scopeName));
|
||||
|
||||
return scopes.reduce<SelectedScope[]>((acc, scope, idx) => {
|
||||
acc.push({
|
||||
scope,
|
||||
path: treeScopes[idx].path,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export async function fetchDashboards(scopeNames: string[]): Promise<ScopeDashboardBinding[]> {
|
||||
try {
|
||||
const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(dashboardsEndpoint, {
|
||||
scope: scopeNames,
|
||||
});
|
||||
|
||||
return response?.items ?? [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Scope, ScopeDashboardBinding, ScopeNodeSpec } from '@grafana/data';
|
||||
|
||||
export enum NodeReason {
|
||||
Persisted,
|
||||
Result,
|
||||
}
|
||||
|
||||
export interface Node extends ScopeNodeSpec {
|
||||
name: string;
|
||||
reason: NodeReason;
|
||||
isExpandable: boolean;
|
||||
isSelectable: boolean;
|
||||
isExpanded: boolean;
|
||||
query: string;
|
||||
nodes: NodesMap;
|
||||
}
|
||||
|
||||
export type NodesMap = Record<string, Node>;
|
||||
|
||||
export interface SelectedScope {
|
||||
scope: Scope;
|
||||
path: string[];
|
||||
}
|
||||
|
||||
export interface TreeScope {
|
||||
scopeName: string;
|
||||
path: string[];
|
||||
}
|
||||
|
||||
export interface SuggestedDashboard {
|
||||
dashboard: string;
|
||||
dashboardTitle: string;
|
||||
items: ScopeDashboardBinding[];
|
||||
}
|
||||
|
||||
export interface SuggestedDashboardsFolder {
|
||||
title: string;
|
||||
isExpanded: boolean;
|
||||
folders: SuggestedDashboardsFoldersMap;
|
||||
dashboards: SuggestedDashboardsMap;
|
||||
}
|
||||
|
||||
export type SuggestedDashboardsMap = Record<string, SuggestedDashboard>;
|
||||
export type SuggestedDashboardsFoldersMap = Record<string, SuggestedDashboardsFolder>;
|
||||
|
||||
export type OnNodeUpdate = (path: string[], isExpanded: boolean, query: string) => void;
|
||||
export type OnNodeSelectToggle = (path: string[]) => void;
|
||||
export type OnFolderUpdate = (path: string[], isExpanded: boolean) => void;
|
||||
@@ -1,177 +0,0 @@
|
||||
import { Scope, ScopeDashboardBinding } from '@grafana/data';
|
||||
|
||||
import { NodesMap, SelectedScope, SuggestedDashboardsFoldersMap, TreeScope } from './types';
|
||||
|
||||
export function getBasicScope(name: string): Scope {
|
||||
return {
|
||||
metadata: { name },
|
||||
spec: {
|
||||
filters: [],
|
||||
title: name,
|
||||
type: '',
|
||||
category: '',
|
||||
description: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeScopes(scope1: Scope, scope2: Scope): Scope {
|
||||
return {
|
||||
...scope1,
|
||||
metadata: {
|
||||
...scope1.metadata,
|
||||
...scope2.metadata,
|
||||
},
|
||||
spec: {
|
||||
...scope1.spec,
|
||||
...scope2.spec,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getTreeScopesFromSelectedScopes(scopes: SelectedScope[]): TreeScope[] {
|
||||
return scopes.map(({ scope, path }) => ({
|
||||
scopeName: scope.metadata.name,
|
||||
path,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getScopesFromSelectedScopes(scopes: SelectedScope[]): Scope[] {
|
||||
return scopes.map(({ scope }) => scope);
|
||||
}
|
||||
|
||||
export function getScopeNamesFromSelectedScopes(scopes: SelectedScope[]): string[] {
|
||||
return scopes.map(({ scope }) => scope.metadata.name);
|
||||
}
|
||||
|
||||
// helper func to get the selected/tree scopes together with their paths
|
||||
// needed to maintain selected scopes in tree for example when navigating
|
||||
// between categories or when loading scopes from URL to find the scope's path
|
||||
export function getScopesAndTreeScopesWithPaths(
|
||||
selectedScopes: SelectedScope[],
|
||||
treeScopes: TreeScope[],
|
||||
path: string[],
|
||||
childNodes: NodesMap
|
||||
): [SelectedScope[], TreeScope[]] {
|
||||
const childNodesArr = Object.values(childNodes);
|
||||
|
||||
// Get all scopes without paths
|
||||
// We use tree scopes as the list is always up to date as opposed to selected scopes which can be outdated
|
||||
const scopeNamesWithoutPaths = treeScopes.filter(({ path }) => path.length === 0).map(({ scopeName }) => scopeName);
|
||||
|
||||
// We search for the path of each scope name without a path
|
||||
const scopeNamesWithPaths = scopeNamesWithoutPaths.reduce<Record<string, string[]>>((acc, scopeName) => {
|
||||
const possibleParent = childNodesArr.find((childNode) => childNode.isSelectable && childNode.linkId === scopeName);
|
||||
|
||||
if (possibleParent) {
|
||||
acc[scopeName] = [...path, possibleParent.name];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Update the paths of the selected scopes based on what we found
|
||||
const newSelectedScopes = selectedScopes.map((selectedScope) => {
|
||||
if (selectedScope.path.length > 0) {
|
||||
return selectedScope;
|
||||
}
|
||||
|
||||
return {
|
||||
...selectedScope,
|
||||
path: scopeNamesWithPaths[selectedScope.scope.metadata.name] ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
// Update the paths of the tree scopes based on what we found
|
||||
const newTreeScopes = treeScopes.map((treeScope) => {
|
||||
if (treeScope.path.length > 0) {
|
||||
return treeScope;
|
||||
}
|
||||
|
||||
return {
|
||||
...treeScope,
|
||||
path: scopeNamesWithPaths[treeScope.scopeName] ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
return [newSelectedScopes, newTreeScopes];
|
||||
}
|
||||
|
||||
export function groupDashboards(dashboards: ScopeDashboardBinding[]): SuggestedDashboardsFoldersMap {
|
||||
return dashboards.reduce<SuggestedDashboardsFoldersMap>(
|
||||
(acc, dashboard) => {
|
||||
const rootNode = acc[''];
|
||||
const groups = dashboard.status.groups ?? [];
|
||||
|
||||
groups.forEach((group) => {
|
||||
if (group && !rootNode.folders[group]) {
|
||||
rootNode.folders[group] = {
|
||||
title: group,
|
||||
isExpanded: false,
|
||||
folders: {},
|
||||
dashboards: {},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const targets =
|
||||
groups.length > 0
|
||||
? groups.map((group) => (group === '' ? rootNode.dashboards : rootNode.folders[group].dashboards))
|
||||
: [rootNode.dashboards];
|
||||
|
||||
targets.forEach((target) => {
|
||||
if (!target[dashboard.spec.dashboard]) {
|
||||
target[dashboard.spec.dashboard] = {
|
||||
dashboard: dashboard.spec.dashboard,
|
||||
dashboardTitle: dashboard.status.dashboardTitle,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
target[dashboard.spec.dashboard].items.push(dashboard);
|
||||
});
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
'': {
|
||||
title: '',
|
||||
isExpanded: true,
|
||||
folders: {},
|
||||
dashboards: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function filterFolders(folders: SuggestedDashboardsFoldersMap, query: string): SuggestedDashboardsFoldersMap {
|
||||
query = (query ?? '').toLowerCase();
|
||||
|
||||
return Object.entries(folders).reduce<SuggestedDashboardsFoldersMap>((acc, [folderId, folder]) => {
|
||||
// If folder matches the query, we show everything inside
|
||||
if (folder.title.toLowerCase().includes(query)) {
|
||||
acc[folderId] = {
|
||||
...folder,
|
||||
isExpanded: true,
|
||||
};
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
const filteredFolders = filterFolders(folder.folders, query);
|
||||
const filteredDashboards = Object.entries(folder.dashboards).filter(([_, dashboard]) =>
|
||||
dashboard.dashboardTitle.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
if (Object.keys(filteredFolders).length > 0 || filteredDashboards.length > 0) {
|
||||
acc[folderId] = {
|
||||
...folder,
|
||||
isExpanded: true,
|
||||
folders: filteredFolders,
|
||||
dashboards: Object.fromEntries(filteredDashboards),
|
||||
};
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
+13
-20
@@ -10,26 +10,19 @@ import { NodesMap, SelectedScope } from './types';
|
||||
export interface ScopesInputProps {
|
||||
nodes: NodesMap;
|
||||
scopes: SelectedScope[];
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
onInputClick: () => void;
|
||||
onRemoveAllClick: () => void;
|
||||
}
|
||||
|
||||
export function ScopesInput({
|
||||
nodes,
|
||||
scopes,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
onInputClick,
|
||||
onRemoveAllClick,
|
||||
}: ScopesInputProps) {
|
||||
export function ScopesInput({ nodes, scopes, disabled, loading, onInputClick, onRemoveAllClick }: ScopesInputProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsTooltipVisible(false);
|
||||
setTooltipVisible(false);
|
||||
}, [scopes]);
|
||||
|
||||
const scopesPaths = useMemo(() => {
|
||||
@@ -82,13 +75,13 @@ export function ScopesInput({
|
||||
<Input
|
||||
readOnly
|
||||
placeholder={t('scopes.selector.input.placeholder', 'Select scopes...')}
|
||||
disabled={isDisabled}
|
||||
loading={isLoading}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
value={scopesTitles}
|
||||
aria-label={t('scopes.selector.input.placeholder', 'Select scopes...')}
|
||||
data-testid="scopes-selector-input"
|
||||
suffix={
|
||||
scopes.length > 0 && !isDisabled ? (
|
||||
scopes.length > 0 && !disabled ? (
|
||||
<IconButton
|
||||
aria-label={t('scopes.selector.input.removeAll', 'Remove all scopes')}
|
||||
name="times"
|
||||
@@ -96,20 +89,20 @@ export function ScopesInput({
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onMouseOver={() => setIsTooltipVisible(true)}
|
||||
onMouseOut={() => setIsTooltipVisible(false)}
|
||||
onMouseOver={() => setTooltipVisible(true)}
|
||||
onMouseOut={() => setTooltipVisible(false)}
|
||||
onClick={() => {
|
||||
if (!isDisabled) {
|
||||
if (!disabled) {
|
||||
onInputClick();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[isDisabled, isLoading, onInputClick, onRemoveAllClick, scopes, scopesTitles]
|
||||
[disabled, loading, onInputClick, onRemoveAllClick, scopes, scopesTitles]
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content={scopesPaths} show={scopes.length === 0 ? false : isTooltipVisible}>
|
||||
<Tooltip content={scopesPaths} show={scopes.length === 0 ? false : tooltipVisible}>
|
||||
{input}
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -0,0 +1,129 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useObservable } from 'react-use';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useScopes } from '@grafana/runtime';
|
||||
import { Button, Drawer, IconButton, Spinner, useStyles2 } from '@grafana/ui';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { ScopesInput } from './ScopesInput';
|
||||
import { ScopesSelectorService } from './ScopesSelectorService';
|
||||
import { ScopesTree } from './ScopesTree';
|
||||
|
||||
export const ScopesSelector = () => {
|
||||
const { chrome } = useGrafana();
|
||||
const chromeState = chrome.useState();
|
||||
const menuDockedAndOpen = !chromeState.chromeless && chromeState.megaMenuDocked && chromeState.megaMenuOpen;
|
||||
const styles = useStyles2(getStyles, menuDockedAndOpen);
|
||||
const scopes = useScopes();
|
||||
|
||||
const scopesSelectorService = ScopesSelectorService.instance;
|
||||
|
||||
useObservable(scopesSelectorService?.stateObservable ?? new Observable(), scopesSelectorService?.state);
|
||||
|
||||
if (!scopes || !scopesSelectorService || !scopes.state.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { readOnly, drawerOpened, loading } = scopes.state;
|
||||
const { nodes, selectedScopes, opened, loadingNodeName, treeScopes } = scopesSelectorService.state;
|
||||
const { toggleDrawer, open, removeAllScopes, closeAndApply, closeAndReset, updateNode, toggleNodeSelect } =
|
||||
scopesSelectorService;
|
||||
|
||||
const dashboardsIconLabel = readOnly
|
||||
? t('scopes.dashboards.toggle.disabled', 'Suggested dashboards list is disabled due to read only mode')
|
||||
: drawerOpened
|
||||
? t('scopes.dashboards.toggle.collapse', 'Collapse suggested dashboards list')
|
||||
: t('scopes.dashboards.toggle..expand', 'Expand suggested dashboards list');
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<IconButton
|
||||
name="web-section-alt"
|
||||
className={styles.dashboards}
|
||||
aria-label={dashboardsIconLabel}
|
||||
tooltip={dashboardsIconLabel}
|
||||
data-testid="scopes-dashboards-expand"
|
||||
disabled={readOnly}
|
||||
onClick={toggleDrawer}
|
||||
/>
|
||||
|
||||
<ScopesInput
|
||||
nodes={nodes}
|
||||
scopes={selectedScopes}
|
||||
disabled={readOnly}
|
||||
loading={loading}
|
||||
onInputClick={open}
|
||||
onRemoveAllClick={removeAllScopes}
|
||||
/>
|
||||
|
||||
{opened && (
|
||||
<Drawer title={t('scopes.selector.title', 'Select scopes')} size="sm" onClose={closeAndReset}>
|
||||
<div className={styles.drawerContainer}>
|
||||
<div className={styles.treeContainer}>
|
||||
{loading ? (
|
||||
<Spinner data-testid="scopes-selector-loading" />
|
||||
) : (
|
||||
<ScopesTree
|
||||
nodes={nodes}
|
||||
nodePath={['']}
|
||||
loadingNodeName={loadingNodeName}
|
||||
scopes={treeScopes}
|
||||
onNodeUpdate={updateNode}
|
||||
onNodeSelectToggle={toggleNodeSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonsContainer}>
|
||||
<Button variant="primary" data-testid="scopes-selector-apply" onClick={closeAndApply}>
|
||||
<Trans i18nKey="scopes.selector.apply">Apply</Trans>
|
||||
</Button>
|
||||
<Button variant="secondary" data-testid="scopes-selector-cancel" onClick={closeAndReset}>
|
||||
<Trans i18nKey="scopes.selector.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, menuDockedAndOpen: boolean) => {
|
||||
return {
|
||||
container: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
paddingLeft: menuDockedAndOpen ? theme.spacing(2) : 'unset',
|
||||
}),
|
||||
dashboards: css({
|
||||
color: theme.colors.text.secondary,
|
||||
marginRight: theme.spacing(2),
|
||||
|
||||
'&:hover': css({
|
||||
color: theme.colors.text.primary,
|
||||
}),
|
||||
}),
|
||||
drawerContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}),
|
||||
treeContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '100%',
|
||||
overflowY: 'hidden',
|
||||
// Fix for top level search outline overflow due to scrollbars
|
||||
paddingLeft: theme.spacing(0.5),
|
||||
}),
|
||||
buttonsContainer: css({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
marginTop: theme.spacing(8),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,392 @@
|
||||
import { isEqual } from 'lodash';
|
||||
import { finalize, from } from 'rxjs';
|
||||
|
||||
import { Scope, ScopeNode } from '@grafana/data';
|
||||
import { config, getBackendSrv } from '@grafana/runtime';
|
||||
|
||||
import { ScopesService } from '../ScopesService';
|
||||
import { ScopesServiceBase } from '../ScopesServiceBase';
|
||||
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
|
||||
|
||||
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types';
|
||||
|
||||
interface ScopesSelectorServiceState {
|
||||
opened: boolean;
|
||||
loadingNodeName: string | undefined;
|
||||
nodes: NodesMap;
|
||||
selectedScopes: SelectedScope[];
|
||||
treeScopes: TreeScope[];
|
||||
}
|
||||
|
||||
export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServiceState> {
|
||||
static #instance: ScopesSelectorService | undefined = undefined;
|
||||
|
||||
private _scopesCache = new Map<string, Promise<Scope>>();
|
||||
|
||||
private constructor() {
|
||||
super({
|
||||
opened: false,
|
||||
loadingNodeName: undefined,
|
||||
nodes: {
|
||||
'': {
|
||||
name: '',
|
||||
reason: NodeReason.Result,
|
||||
nodeType: 'container',
|
||||
title: '',
|
||||
expandable: true,
|
||||
selectable: false,
|
||||
expanded: true,
|
||||
query: '',
|
||||
nodes: {},
|
||||
},
|
||||
},
|
||||
selectedScopes: [],
|
||||
treeScopes: [],
|
||||
});
|
||||
}
|
||||
|
||||
public static get instance(): ScopesSelectorService | undefined {
|
||||
if (!ScopesSelectorService.#instance && config.featureToggles.scopeFilters) {
|
||||
ScopesSelectorService.#instance = new ScopesSelectorService();
|
||||
}
|
||||
|
||||
return ScopesSelectorService.#instance;
|
||||
}
|
||||
|
||||
public updateNode = async (path: string[], expanded: boolean, query: string) => {
|
||||
this._fetchSub?.unsubscribe();
|
||||
|
||||
let nodes = { ...this.state.nodes };
|
||||
let currentLevel: NodesMap = nodes;
|
||||
|
||||
for (let idx = 0; idx < path.length - 1; idx++) {
|
||||
currentLevel = currentLevel[path[idx]].nodes;
|
||||
}
|
||||
|
||||
const loadingNodeName = path[path.length - 1];
|
||||
const currentNode = currentLevel[loadingNodeName];
|
||||
|
||||
const differentQuery = currentNode.query !== query;
|
||||
|
||||
currentNode.expanded = expanded;
|
||||
currentNode.query = query;
|
||||
|
||||
if (expanded || differentQuery) {
|
||||
this.updateState({ nodes, loadingNodeName });
|
||||
|
||||
this._fetchSub = from(this.fetchNodeApi(loadingNodeName, query))
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
this.updateState({ loadingNodeName: undefined });
|
||||
})
|
||||
)
|
||||
.subscribe((childNodes) => {
|
||||
const [selectedScopes, treeScopes] = this.getScopesAndTreeScopesWithPaths(
|
||||
this.state.selectedScopes,
|
||||
this.state.treeScopes,
|
||||
path,
|
||||
childNodes
|
||||
);
|
||||
|
||||
const persistedNodes = treeScopes
|
||||
.map(({ path }) => path[path.length - 1])
|
||||
.filter((nodeName) => nodeName in currentNode.nodes && !(nodeName in childNodes))
|
||||
.reduce<NodesMap>((acc, nodeName) => {
|
||||
acc[nodeName] = {
|
||||
...currentNode.nodes[nodeName],
|
||||
reason: NodeReason.Persisted,
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
currentNode.nodes = { ...persistedNodes, ...childNodes };
|
||||
|
||||
this.updateState({ nodes, selectedScopes, treeScopes });
|
||||
|
||||
this._fetchSub?.unsubscribe();
|
||||
});
|
||||
} else {
|
||||
this.updateState({ nodes, loadingNodeName: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
public toggleNodeSelect = (path: string[]) => {
|
||||
let treeScopes = [...this.state.treeScopes];
|
||||
|
||||
let parentNode = this.state.nodes[''];
|
||||
|
||||
for (let idx = 1; idx < path.length - 1; idx++) {
|
||||
parentNode = parentNode.nodes[path[idx]];
|
||||
}
|
||||
|
||||
const nodeName = path[path.length - 1];
|
||||
const { linkId } = parentNode.nodes[nodeName];
|
||||
|
||||
const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId);
|
||||
|
||||
if (selectedIdx === -1) {
|
||||
this.fetchScopeApi(linkId!);
|
||||
|
||||
const selectedFromSameNode =
|
||||
treeScopes.length === 0 ||
|
||||
Object.values(parentNode.nodes).some(({ linkId }) => linkId === treeScopes[0].scopeName);
|
||||
|
||||
const treeScope = {
|
||||
scopeName: linkId!,
|
||||
path,
|
||||
};
|
||||
|
||||
this.updateState({
|
||||
treeScopes: parentNode?.disableMultiSelect || !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope],
|
||||
});
|
||||
} else {
|
||||
treeScopes.splice(selectedIdx, 1);
|
||||
|
||||
this.updateState({ treeScopes });
|
||||
}
|
||||
};
|
||||
|
||||
public changeScopes = (scopeNames: string[]) =>
|
||||
this.setNewScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] })));
|
||||
|
||||
public setNewScopes = async (treeScopes = this.state.treeScopes) => {
|
||||
if (isEqual(treeScopes, this.getTreeScopesFromSelectedScopes(this.state.selectedScopes))) {
|
||||
return;
|
||||
}
|
||||
|
||||
let selectedScopes = treeScopes.map(({ scopeName, path }) => ({
|
||||
scope: this.getBasicScope(scopeName),
|
||||
path,
|
||||
}));
|
||||
this.updateState({ selectedScopes, treeScopes });
|
||||
ScopesService.instance?.setLoading(true);
|
||||
ScopesDashboardsService.instance?.fetchDashboards(selectedScopes.map(({ scope }) => scope.metadata.name));
|
||||
|
||||
selectedScopes = await this.fetchScopesApi(treeScopes);
|
||||
this.updateState({ selectedScopes });
|
||||
ScopesService.instance?.setScopes(selectedScopes.map(({ scope }) => scope));
|
||||
ScopesService.instance?.setLoading(false);
|
||||
};
|
||||
|
||||
public removeAllScopes = () => this.setNewScopes([]);
|
||||
|
||||
public open = async () => {
|
||||
if (!ScopesService.instance?.state.readOnly) {
|
||||
if (Object.keys(this.state.nodes[''].nodes).length === 0) {
|
||||
await this.updateNode([''], true, '');
|
||||
}
|
||||
|
||||
let nodes = { ...this.state.nodes };
|
||||
|
||||
// First close all nodes
|
||||
nodes = this.closeNodes(nodes);
|
||||
|
||||
// Extract the path of a scope
|
||||
let path = [...(this.state.selectedScopes[0]?.path ?? ['', ''])];
|
||||
path.splice(path.length - 1, 1);
|
||||
|
||||
// Expand the nodes to the selected scope
|
||||
nodes = this.expandNodes(nodes, path);
|
||||
|
||||
this.updateState({ nodes, opened: true });
|
||||
}
|
||||
};
|
||||
|
||||
public closeAndReset = () => {
|
||||
this.updateState({ opened: false, treeScopes: this.getTreeScopesFromSelectedScopes(this.state.selectedScopes) });
|
||||
};
|
||||
|
||||
public closeAndApply = () => {
|
||||
this.updateState({ opened: false });
|
||||
this.setNewScopes();
|
||||
};
|
||||
|
||||
public toggleDrawer = () => ScopesService.instance?.setDrawerOpened(!ScopesService.instance?.state.drawerOpened);
|
||||
|
||||
private closeNodes = (nodes: NodesMap): NodesMap => {
|
||||
return Object.entries(nodes).reduce<NodesMap>((acc, [id, node]) => {
|
||||
acc[id] = {
|
||||
...node,
|
||||
expanded: false,
|
||||
nodes: this.closeNodes(node.nodes),
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
private expandNodes = (nodes: NodesMap, path: string[]): NodesMap => {
|
||||
nodes = { ...nodes };
|
||||
let currentNodes = nodes;
|
||||
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const nodeId = path[i];
|
||||
|
||||
currentNodes[nodeId] = {
|
||||
...currentNodes[nodeId],
|
||||
expanded: true,
|
||||
};
|
||||
currentNodes = currentNodes[nodeId].nodes;
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
|
||||
private getBasicScope = (name: string): Scope => {
|
||||
return {
|
||||
metadata: { name },
|
||||
spec: {
|
||||
filters: [],
|
||||
title: name,
|
||||
type: '',
|
||||
category: '',
|
||||
description: '',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
private getTreeScopesFromSelectedScopes = (scopes: SelectedScope[]): TreeScope[] => {
|
||||
return scopes.map(({ scope, path }) => ({
|
||||
scopeName: scope.metadata.name,
|
||||
path,
|
||||
}));
|
||||
};
|
||||
|
||||
// helper func to get the selected/tree scopes together with their paths
|
||||
// needed to maintain selected scopes in tree for example when navigating
|
||||
// between categories or when loading scopes from URL to find the scope's path
|
||||
private getScopesAndTreeScopesWithPaths = (
|
||||
selectedScopes: SelectedScope[],
|
||||
treeScopes: TreeScope[],
|
||||
path: string[],
|
||||
childNodes: NodesMap
|
||||
): [SelectedScope[], TreeScope[]] => {
|
||||
const childNodesArr = Object.values(childNodes);
|
||||
|
||||
// Get all scopes without paths
|
||||
// We use tree scopes as the list is always up to date as opposed to selected scopes which can be outdated
|
||||
const scopeNamesWithoutPaths = treeScopes.filter(({ path }) => path.length === 0).map(({ scopeName }) => scopeName);
|
||||
|
||||
// We search for the path of each scope name without a path
|
||||
const scopeNamesWithPaths = scopeNamesWithoutPaths.reduce<Record<string, string[]>>((acc, scopeName) => {
|
||||
const possibleParent = childNodesArr.find((childNode) => childNode.selectable && childNode.linkId === scopeName);
|
||||
|
||||
if (possibleParent) {
|
||||
acc[scopeName] = [...path, possibleParent.name];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Update the paths of the selected scopes based on what we found
|
||||
const newSelectedScopes = selectedScopes.map((selectedScope) => {
|
||||
if (selectedScope.path.length > 0) {
|
||||
return selectedScope;
|
||||
}
|
||||
|
||||
return {
|
||||
...selectedScope,
|
||||
path: scopeNamesWithPaths[selectedScope.scope.metadata.name] ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
// Update the paths of the tree scopes based on what we found
|
||||
const newTreeScopes = treeScopes.map((treeScope) => {
|
||||
if (treeScope.path.length > 0) {
|
||||
return treeScope;
|
||||
}
|
||||
|
||||
return {
|
||||
...treeScope,
|
||||
path: scopeNamesWithPaths[treeScope.scopeName] ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
return [newSelectedScopes, newTreeScopes];
|
||||
};
|
||||
|
||||
public fetchNodeApi = async (parent: string, query: string): Promise<NodesMap> => {
|
||||
try {
|
||||
const nodes =
|
||||
(
|
||||
await getBackendSrv().get<{ items: ScopeNode[] }>(
|
||||
`/apis/${this._apiGroup}/${this._apiVersion}/namespaces/${this._apiNamespace}/find/scope_node_children`,
|
||||
{ parent, query }
|
||||
)
|
||||
)?.items ?? [];
|
||||
|
||||
return nodes.reduce<NodesMap>((acc, { metadata: { name }, spec }) => {
|
||||
acc[name] = {
|
||||
name,
|
||||
...spec,
|
||||
expandable: spec.nodeType === 'container',
|
||||
selectable: spec.linkType === 'scope',
|
||||
expanded: false,
|
||||
query: '',
|
||||
reason: NodeReason.Result,
|
||||
nodes: {},
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
} catch (err) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
public fetchScopeApi = async (name: string): Promise<Scope> => {
|
||||
if (this._scopesCache.has(name)) {
|
||||
return this._scopesCache.get(name)!;
|
||||
}
|
||||
|
||||
const response = new Promise<Scope>(async (resolve) => {
|
||||
const basicScope = this.getBasicScope(name);
|
||||
|
||||
try {
|
||||
const serverScope = await getBackendSrv().get<Scope>(
|
||||
`/apis/${this._apiGroup}/${this._apiVersion}/namespaces/${this._apiNamespace}/scopes/${name}`
|
||||
);
|
||||
|
||||
const scope = {
|
||||
...basicScope,
|
||||
...serverScope,
|
||||
metadata: {
|
||||
...basicScope.metadata,
|
||||
...serverScope.metadata,
|
||||
},
|
||||
spec: {
|
||||
...basicScope.spec,
|
||||
...serverScope.spec,
|
||||
},
|
||||
};
|
||||
|
||||
resolve(scope);
|
||||
} catch (err) {
|
||||
this._scopesCache.delete(name);
|
||||
|
||||
resolve(basicScope);
|
||||
}
|
||||
});
|
||||
|
||||
this._scopesCache.set(name, response);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
public fetchScopesApi = async (treeScopes: TreeScope[]): Promise<SelectedScope[]> => {
|
||||
const scopes = await Promise.all(treeScopes.map(({ scopeName }) => this.fetchScopeApi(scopeName)));
|
||||
|
||||
return scopes.reduce<SelectedScope[]>((acc, scope, idx) => {
|
||||
acc.push({
|
||||
scope,
|
||||
path: treeScopes[idx].path,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
public reset = () => {
|
||||
ScopesSelectorService.#instance = undefined;
|
||||
};
|
||||
}
|
||||
+6
-6
@@ -27,11 +27,11 @@ export function ScopesTree({
|
||||
const nodeId = nodePath[nodePath.length - 1];
|
||||
const node = nodes[nodeId];
|
||||
const childNodes = Object.values(node.nodes);
|
||||
const isNodeLoading = loadingNodeName === nodeId;
|
||||
const nodeLoading = loadingNodeName === nodeId;
|
||||
const scopeNames = scopes.map(({ scopeName }) => scopeName);
|
||||
const anyChildExpanded = childNodes.some(({ isExpanded }) => isExpanded);
|
||||
const anyChildExpanded = childNodes.some(({ expanded }) => expanded);
|
||||
const groupedNodes: Dictionary<Node[]> = useMemo(() => groupBy(childNodes, 'reason'), [childNodes]);
|
||||
const isLastExpandedNode = !anyChildExpanded && node.isExpanded;
|
||||
const lastExpandedNode = !anyChildExpanded && node.expanded;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -42,11 +42,11 @@ export function ScopesTree({
|
||||
onNodeUpdate={onNodeUpdate}
|
||||
/>
|
||||
|
||||
<ScopesTreeLoading isNodeLoading={isNodeLoading}>
|
||||
<ScopesTreeLoading nodeLoading={nodeLoading}>
|
||||
<ScopesTreeItem
|
||||
anyChildExpanded={anyChildExpanded}
|
||||
groupedNodes={groupedNodes}
|
||||
isLastExpandedNode={isLastExpandedNode}
|
||||
lastExpandedNode={lastExpandedNode}
|
||||
loadingNodeName={loadingNodeName}
|
||||
node={node}
|
||||
nodePath={nodePath}
|
||||
@@ -67,7 +67,7 @@ export function ScopesTree({
|
||||
<ScopesTreeItem
|
||||
anyChildExpanded={anyChildExpanded}
|
||||
groupedNodes={groupedNodes}
|
||||
isLastExpandedNode={isLastExpandedNode}
|
||||
lastExpandedNode={lastExpandedNode}
|
||||
loadingNodeName={loadingNodeName}
|
||||
node={node}
|
||||
nodePath={nodePath}
|
||||
+15
-15
@@ -11,7 +11,7 @@ import { Node, NodeReason, OnNodeSelectToggle, OnNodeUpdate, TreeScope } from '.
|
||||
export interface ScopesTreeItemProps {
|
||||
anyChildExpanded: boolean;
|
||||
groupedNodes: Dictionary<Node[]>;
|
||||
isLastExpandedNode: boolean;
|
||||
lastExpandedNode: boolean;
|
||||
loadingNodeName: string | undefined;
|
||||
node: Node;
|
||||
nodePath: string[];
|
||||
@@ -26,7 +26,7 @@ export interface ScopesTreeItemProps {
|
||||
export function ScopesTreeItem({
|
||||
anyChildExpanded,
|
||||
groupedNodes,
|
||||
isLastExpandedNode,
|
||||
lastExpandedNode,
|
||||
loadingNodeName,
|
||||
node,
|
||||
nodePath,
|
||||
@@ -48,9 +48,9 @@ export function ScopesTreeItem({
|
||||
const children = (
|
||||
<div role="tree" className={anyChildExpanded ? styles.expandedContainer : undefined}>
|
||||
{nodes.map((childNode) => {
|
||||
const isSelected = childNode.isSelectable && scopeNames.includes(childNode.linkId!);
|
||||
const selected = childNode.selectable && scopeNames.includes(childNode.linkId!);
|
||||
|
||||
if (anyChildExpanded && !childNode.isExpanded) {
|
||||
if (anyChildExpanded && !childNode.expanded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -62,16 +62,16 @@ export function ScopesTreeItem({
|
||||
<div
|
||||
key={childNode.name}
|
||||
role="treeitem"
|
||||
aria-selected={childNode.isExpanded}
|
||||
aria-selected={childNode.expanded}
|
||||
className={anyChildExpanded ? styles.expandedContainer : undefined}
|
||||
>
|
||||
<div className={cx(styles.title, childNode.isSelectable && !childNode.isExpanded && styles.titlePadding)}>
|
||||
{childNode.isSelectable && !childNode.isExpanded ? (
|
||||
<div className={cx(styles.title, childNode.selectable && !childNode.expanded && styles.titlePadding)}>
|
||||
{childNode.selectable && !childNode.expanded ? (
|
||||
node.disableMultiSelect ? (
|
||||
<RadioButtonDot
|
||||
id={radioName}
|
||||
name={radioName}
|
||||
checked={isSelected}
|
||||
checked={selected}
|
||||
label=""
|
||||
data-testid={`scopes-tree-${type}-${childNode.name}-radio`}
|
||||
onClick={() => {
|
||||
@@ -80,7 +80,7 @@ export function ScopesTreeItem({
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
checked={selected}
|
||||
data-testid={`scopes-tree-${type}-${childNode.name}-checkbox`}
|
||||
onChange={() => {
|
||||
onNodeSelectToggle(childNodePath);
|
||||
@@ -89,18 +89,18 @@ export function ScopesTreeItem({
|
||||
)
|
||||
) : null}
|
||||
|
||||
{childNode.isExpandable ? (
|
||||
{childNode.expandable ? (
|
||||
<button
|
||||
className={styles.expand}
|
||||
data-testid={`scopes-tree-${type}-${childNode.name}-expand`}
|
||||
aria-label={
|
||||
childNode.isExpanded ? t('scopes.tree.collapse', 'Collapse') : t('scopes.tree.expand', 'Expand')
|
||||
childNode.expanded ? t('scopes.tree.collapse', 'Collapse') : t('scopes.tree.expand', 'Expand')
|
||||
}
|
||||
onClick={() => {
|
||||
onNodeUpdate(childNodePath, !childNode.isExpanded, childNode.query);
|
||||
onNodeUpdate(childNodePath, !childNode.expanded, childNode.query);
|
||||
}}
|
||||
>
|
||||
<Icon name={!childNode.isExpanded ? 'angle-right' : 'angle-down'} />
|
||||
<Icon name={!childNode.expanded ? 'angle-right' : 'angle-down'} />
|
||||
|
||||
{childNode.title}
|
||||
</button>
|
||||
@@ -110,7 +110,7 @@ export function ScopesTreeItem({
|
||||
</div>
|
||||
|
||||
<div className={styles.children}>
|
||||
{childNode.isExpanded && (
|
||||
{childNode.expanded && (
|
||||
<ScopesTree
|
||||
nodes={node.nodes}
|
||||
nodePath={childNodePath}
|
||||
@@ -127,7 +127,7 @@ export function ScopesTreeItem({
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLastExpandedNode) {
|
||||
if (lastExpandedNode) {
|
||||
return (
|
||||
<ScrollContainer
|
||||
minHeight={`${Math.min(5, nodes.length) * 30}px`}
|
||||
+3
-3
@@ -7,13 +7,13 @@ import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
export interface ScopesTreeLoadingProps {
|
||||
children: ReactNode;
|
||||
isNodeLoading: boolean;
|
||||
nodeLoading: boolean;
|
||||
}
|
||||
|
||||
export function ScopesTreeLoading({ children, isNodeLoading }: ScopesTreeLoadingProps) {
|
||||
export function ScopesTreeLoading({ children, nodeLoading }: ScopesTreeLoadingProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (isNodeLoading) {
|
||||
if (nodeLoading) {
|
||||
return <Skeleton count={5} className={styles.loader} />;
|
||||
}
|
||||
|
||||
+6
-6
@@ -18,22 +18,22 @@ export interface ScopesTreeSearchProps {
|
||||
export function ScopesTreeSearch({ anyChildExpanded, nodePath, query, onNodeUpdate }: ScopesTreeSearchProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [inputState, setInputState] = useState<{ value: string; isDirty: boolean }>({ value: query, isDirty: false });
|
||||
const [inputState, setInputState] = useState<{ value: string; dirty: boolean }>({ value: query, dirty: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputState.isDirty && inputState.value !== query) {
|
||||
setInputState({ value: query, isDirty: false });
|
||||
if (!inputState.dirty && inputState.value !== query) {
|
||||
setInputState({ value: query, dirty: false });
|
||||
}
|
||||
}, [inputState, query]);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (inputState.isDirty) {
|
||||
if (inputState.dirty) {
|
||||
onNodeUpdate(nodePath, true, inputState.value);
|
||||
}
|
||||
},
|
||||
500,
|
||||
[inputState.isDirty, inputState.value]
|
||||
[inputState.dirty, inputState.value]
|
||||
);
|
||||
|
||||
if (anyChildExpanded) {
|
||||
@@ -48,7 +48,7 @@ export function ScopesTreeSearch({ anyChildExpanded, nodePath, query, onNodeUpda
|
||||
data-testid="scopes-tree-search"
|
||||
escapeRegex={false}
|
||||
onChange={(value) => {
|
||||
setInputState({ value, isDirty: true });
|
||||
setInputState({ value, dirty: true });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Scope, ScopeNodeSpec } from '@grafana/data';
|
||||
|
||||
export enum NodeReason {
|
||||
Persisted,
|
||||
Result,
|
||||
}
|
||||
|
||||
export interface Node extends ScopeNodeSpec {
|
||||
name: string;
|
||||
reason: NodeReason;
|
||||
expandable: boolean;
|
||||
selectable: boolean;
|
||||
expanded: boolean;
|
||||
query: string;
|
||||
nodes: NodesMap;
|
||||
}
|
||||
|
||||
export type NodesMap = Record<string, Node>;
|
||||
|
||||
export interface SelectedScope {
|
||||
scope: Scope;
|
||||
path: string[];
|
||||
}
|
||||
|
||||
export interface TreeScope {
|
||||
scopeName: string;
|
||||
path: string[];
|
||||
}
|
||||
|
||||
export type OnNodeUpdate = (path: string[], expanded: boolean, query: string) => void;
|
||||
export type OnNodeSelectToggle = (path: string[]) => void;
|
||||
@@ -2,8 +2,7 @@ import { config } from '@grafana/runtime';
|
||||
import { setDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
||||
import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
|
||||
|
||||
import { clearMocks, enterEditMode, updateMyVar, updateScopes, updateTimeRange } from './utils/actions';
|
||||
import { expectDashboardReload, expectNotDashboardReload } from './utils/assertions';
|
||||
import { enterEditMode, updateMyVar, updateScopes, updateTimeRange } from './utils/actions';
|
||||
import { getDatasource, getInstanceSettings, getMock } from './utils/mocks';
|
||||
import { renderDashboard, resetScenes } from './utils/render';
|
||||
|
||||
@@ -17,6 +16,8 @@ jest.mock('@grafana/runtime', () => ({
|
||||
}));
|
||||
|
||||
describe('Dashboard reload', () => {
|
||||
let dashboardReloadSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(() => {
|
||||
config.featureToggles.scopeFilters = true;
|
||||
config.featureToggles.groupByVariable = true;
|
||||
@@ -39,43 +40,45 @@ describe('Dashboard reload', () => {
|
||||
config.featureToggles.reloadDashboardsOnParamsChange = reloadDashboardsOnParamsChange;
|
||||
setDashboardAPI(undefined);
|
||||
|
||||
const dashboardScene = renderDashboard({ uid: withUid ? 'dash-1' : undefined }, { reloadOnParamsChange });
|
||||
const dashboardScene = await renderDashboard({ uid: withUid ? 'dash-1' : undefined }, { reloadOnParamsChange });
|
||||
|
||||
dashboardReloadSpy = jest.spyOn(getDashboardScenePageStateManager(), 'reloadDashboard');
|
||||
|
||||
if (editMode) {
|
||||
await enterEditMode(dashboardScene);
|
||||
}
|
||||
|
||||
const shouldReload = reloadDashboardsOnParamsChange && reloadOnParamsChange && withUid && !editMode;
|
||||
dashboardReloadSpy.mockClear();
|
||||
|
||||
await updateTimeRange(dashboardScene);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
if (!shouldReload) {
|
||||
expectNotDashboardReload();
|
||||
expect(dashboardReloadSpy).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expectDashboardReload();
|
||||
expect(dashboardReloadSpy).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
await updateMyVar(dashboardScene, '2');
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
if (!shouldReload) {
|
||||
expectNotDashboardReload();
|
||||
expect(dashboardReloadSpy).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expectDashboardReload();
|
||||
expect(dashboardReloadSpy).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
await updateScopes(['grafana']);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
if (!shouldReload) {
|
||||
expectNotDashboardReload();
|
||||
expect(dashboardReloadSpy).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expectDashboardReload();
|
||||
expect(dashboardReloadSpy).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
getDashboardScenePageStateManager().clearDashboardCache();
|
||||
getDashboardScenePageStateManager().clearSceneCache();
|
||||
setDashboardAPI(undefined);
|
||||
await resetScenes();
|
||||
clearMocks();
|
||||
await resetScenes([dashboardReloadSpy]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
|
||||
|
||||
import {
|
||||
clearNotFound,
|
||||
expandDashboardFolder,
|
||||
@@ -21,7 +23,18 @@ import {
|
||||
expectNoDashboardsNoScopes,
|
||||
expectNoDashboardsSearch,
|
||||
} from './utils/assertions';
|
||||
import { fetchDashboardsSpy, getDatasource, getInstanceSettings, getMock } from './utils/mocks';
|
||||
import {
|
||||
alternativeDashboardWithRootFolder,
|
||||
alternativeDashboardWithTwoFolders,
|
||||
dashboardWithOneFolder,
|
||||
dashboardWithoutFolder,
|
||||
dashboardWithRootFolder,
|
||||
dashboardWithRootFolderAndOtherFolder,
|
||||
dashboardWithTwoFolders,
|
||||
getDatasource,
|
||||
getInstanceSettings,
|
||||
getMock,
|
||||
} from './utils/mocks';
|
||||
import { renderDashboard, resetScenes } from './utils/render';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
@@ -34,17 +47,20 @@ jest.mock('@grafana/runtime', () => ({
|
||||
}));
|
||||
|
||||
describe('Dashboards list', () => {
|
||||
let fetchDashboardsSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(() => {
|
||||
config.featureToggles.scopeFilters = true;
|
||||
config.featureToggles.groupByVariable = true;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
renderDashboard();
|
||||
beforeEach(async () => {
|
||||
await renderDashboard();
|
||||
fetchDashboardsSpy = jest.spyOn(ScopesDashboardsService.instance!, 'fetchDashboardsApi');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await resetScenes();
|
||||
await resetScenes([fetchDashboardsSpy]);
|
||||
});
|
||||
|
||||
it('Opens container and fetches dashboards list when a scope is selected', async () => {
|
||||
@@ -244,14 +260,13 @@ describe('Dashboards list', () => {
|
||||
});
|
||||
|
||||
it('Does not show the input when there are no dashboards found for scope', async () => {
|
||||
await toggleDashboards();
|
||||
await updateScopes(['cloud']);
|
||||
await toggleDashboards();
|
||||
expectNoDashboardsForScope();
|
||||
expectNoDashboardsSearch();
|
||||
});
|
||||
|
||||
it('Shows the input and a message when there are no dashboards found for filter', async () => {
|
||||
await toggleDashboards();
|
||||
await updateScopes(['mimir']);
|
||||
await searchDashboards('unknown');
|
||||
expectDashboardsSearch();
|
||||
@@ -260,4 +275,359 @@ describe('Dashboards list', () => {
|
||||
await clearNotFound();
|
||||
expectDashboardSearchValue('');
|
||||
});
|
||||
|
||||
describe('groupDashboards', () => {
|
||||
it('Assigns dashboards without groups to root folder', () => {
|
||||
expect(ScopesDashboardsService.instance?.groupDashboards([dashboardWithoutFolder])).toEqual({
|
||||
'': {
|
||||
title: '',
|
||||
expanded: true,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
[dashboardWithoutFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithoutFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithoutFolder.status.dashboardTitle,
|
||||
items: [dashboardWithoutFolder],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Assigns dashboards with root group to root folder', () => {
|
||||
expect(ScopesDashboardsService.instance?.groupDashboards([dashboardWithRootFolder])).toEqual({
|
||||
'': {
|
||||
title: '',
|
||||
expanded: true,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
[dashboardWithRootFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithRootFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithRootFolder.status.dashboardTitle,
|
||||
items: [dashboardWithRootFolder],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Merges folders from multiple dashboards', () => {
|
||||
expect(
|
||||
ScopesDashboardsService.instance?.groupDashboards([dashboardWithOneFolder, dashboardWithTwoFolders])
|
||||
).toEqual({
|
||||
'': {
|
||||
title: '',
|
||||
expanded: true,
|
||||
folders: {
|
||||
'Folder 1': {
|
||||
title: 'Folder 1',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
[dashboardWithOneFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithOneFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithOneFolder.status.dashboardTitle,
|
||||
items: [dashboardWithOneFolder],
|
||||
},
|
||||
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
|
||||
items: [dashboardWithTwoFolders],
|
||||
},
|
||||
},
|
||||
},
|
||||
'Folder 2': {
|
||||
title: 'Folder 2',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
|
||||
items: [dashboardWithTwoFolders],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboards: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Merges scopes from multiple dashboards', () => {
|
||||
expect(
|
||||
ScopesDashboardsService.instance?.groupDashboards([dashboardWithTwoFolders, alternativeDashboardWithTwoFolders])
|
||||
).toEqual({
|
||||
'': {
|
||||
title: '',
|
||||
expanded: true,
|
||||
folders: {
|
||||
'Folder 1': {
|
||||
title: 'Folder 1',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
|
||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
|
||||
},
|
||||
},
|
||||
},
|
||||
'Folder 2': {
|
||||
title: 'Folder 2',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
|
||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboards: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Matches snapshot', () => {
|
||||
expect(
|
||||
ScopesDashboardsService.instance?.groupDashboards([
|
||||
dashboardWithoutFolder,
|
||||
dashboardWithOneFolder,
|
||||
dashboardWithTwoFolders,
|
||||
alternativeDashboardWithTwoFolders,
|
||||
dashboardWithRootFolder,
|
||||
alternativeDashboardWithRootFolder,
|
||||
dashboardWithRootFolderAndOtherFolder,
|
||||
])
|
||||
).toEqual({
|
||||
'': {
|
||||
dashboards: {
|
||||
[dashboardWithRootFolderAndOtherFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithRootFolderAndOtherFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithRootFolderAndOtherFolder.status.dashboardTitle,
|
||||
items: [dashboardWithRootFolderAndOtherFolder],
|
||||
},
|
||||
[dashboardWithRootFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithRootFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithRootFolder.status.dashboardTitle,
|
||||
items: [dashboardWithRootFolder, alternativeDashboardWithRootFolder],
|
||||
},
|
||||
[dashboardWithoutFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithoutFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithoutFolder.status.dashboardTitle,
|
||||
items: [dashboardWithoutFolder],
|
||||
},
|
||||
},
|
||||
folders: {
|
||||
'Folder 1': {
|
||||
dashboards: {
|
||||
[dashboardWithOneFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithOneFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithOneFolder.status.dashboardTitle,
|
||||
items: [dashboardWithOneFolder],
|
||||
},
|
||||
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
|
||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
|
||||
},
|
||||
},
|
||||
folders: {},
|
||||
expanded: false,
|
||||
title: 'Folder 1',
|
||||
},
|
||||
'Folder 2': {
|
||||
dashboards: {
|
||||
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
|
||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
|
||||
},
|
||||
},
|
||||
folders: {},
|
||||
expanded: false,
|
||||
title: 'Folder 2',
|
||||
},
|
||||
'Folder 3': {
|
||||
dashboards: {
|
||||
[dashboardWithRootFolderAndOtherFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithRootFolderAndOtherFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithRootFolderAndOtherFolder.status.dashboardTitle,
|
||||
items: [dashboardWithRootFolderAndOtherFolder],
|
||||
},
|
||||
},
|
||||
folders: {},
|
||||
expanded: false,
|
||||
title: 'Folder 3',
|
||||
},
|
||||
},
|
||||
expanded: true,
|
||||
title: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterFolders', () => {
|
||||
it('Shows folders matching criteria', () => {
|
||||
expect(
|
||||
ScopesDashboardsService.instance?.filterFolders(
|
||||
{
|
||||
'': {
|
||||
title: '',
|
||||
expanded: true,
|
||||
folders: {
|
||||
'Folder 1': {
|
||||
title: 'Folder 1',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
'Folder 2': {
|
||||
title: 'Folder 2',
|
||||
expanded: true,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Folder'
|
||||
)
|
||||
).toEqual({
|
||||
'': {
|
||||
title: '',
|
||||
expanded: true,
|
||||
folders: {
|
||||
'Folder 1': {
|
||||
title: 'Folder 1',
|
||||
expanded: true,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
'Folder 2': {
|
||||
title: 'Folder 2',
|
||||
expanded: true,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboards: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Shows dashboards matching criteria', () => {
|
||||
expect(
|
||||
ScopesDashboardsService.instance?.filterFolders(
|
||||
{
|
||||
'': {
|
||||
title: '',
|
||||
expanded: true,
|
||||
folders: {
|
||||
'Folder 1': {
|
||||
title: 'Folder 1',
|
||||
expanded: false,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
'Folder 2': {
|
||||
title: 'Folder 2',
|
||||
expanded: true,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
'Random ID': {
|
||||
dashboard: 'Random ID',
|
||||
dashboardTitle: 'Random Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
'Random ID': {
|
||||
dashboard: 'Random ID',
|
||||
dashboardTitle: 'Random Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'dash'
|
||||
)
|
||||
).toEqual({
|
||||
'': {
|
||||
title: '',
|
||||
expanded: true,
|
||||
folders: {
|
||||
'Folder 1': {
|
||||
title: 'Folder 1',
|
||||
expanded: true,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { scopesSelectorScene } from '../instance';
|
||||
|
||||
import { getDatasource, getInstanceSettings, getMock } from './utils/mocks';
|
||||
import { renderDashboard } from './utils/render';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
useChromeHeaderHeight: jest.fn(),
|
||||
getBackendSrv: () => ({ get: getMock }),
|
||||
getDataSourceSrv: () => ({ get: getDatasource, getInstanceSettings }),
|
||||
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
|
||||
}));
|
||||
|
||||
describe('Feature flag off', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.scopeFilters = false;
|
||||
config.featureToggles.groupByVariable = true;
|
||||
});
|
||||
|
||||
it('Does not initialize', () => {
|
||||
renderDashboard();
|
||||
expect(scopesSelectorScene).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { sceneGraph } from '@grafana/scenes';
|
||||
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
||||
|
||||
import { getClosestScopesFacade } from '../utils';
|
||||
import { getDashboardScenePageStateManager } from '../../dashboard-scene/pages/DashboardScenePageStateManager';
|
||||
import { ScopesSelectorService } from '../selector/ScopesSelectorService';
|
||||
|
||||
import { applyScopes, cancelScopes, openSelector, selectResultCloud, updateScopes } from './utils/actions';
|
||||
import { expectNotDashboardReload, expectScopesSelectorValue } from './utils/assertions';
|
||||
import { fetchSelectedScopesSpy, getDatasource, getInstanceSettings, getMock, mocksScopes } from './utils/mocks';
|
||||
import { expectScopesSelectorValue } from './utils/assertions';
|
||||
import { getDatasource, getInstanceSettings, getMock, mocksScopes } from './utils/mocks';
|
||||
import { renderDashboard, resetScenes } from './utils/render';
|
||||
import { getListOfScopes } from './utils/selectors';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
__esModule: true,
|
||||
@@ -19,19 +19,22 @@ jest.mock('@grafana/runtime', () => ({
|
||||
}));
|
||||
|
||||
describe('Selector', () => {
|
||||
let dashboardScene: DashboardScene;
|
||||
let fetchSelectedScopesSpy: jest.SpyInstance;
|
||||
let dashboardReloadSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(() => {
|
||||
config.featureToggles.scopeFilters = true;
|
||||
config.featureToggles.groupByVariable = true;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
dashboardScene = renderDashboard();
|
||||
beforeEach(async () => {
|
||||
await renderDashboard();
|
||||
fetchSelectedScopesSpy = jest.spyOn(ScopesSelectorService.instance!, 'fetchScopesApi');
|
||||
dashboardReloadSpy = jest.spyOn(getDashboardScenePageStateManager(), 'reloadDashboard');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await resetScenes();
|
||||
await resetScenes([fetchSelectedScopesSpy, dashboardReloadSpy]);
|
||||
});
|
||||
|
||||
it('Fetches scope details on save', async () => {
|
||||
@@ -39,9 +42,7 @@ describe('Selector', () => {
|
||||
await selectResultCloud();
|
||||
await applyScopes();
|
||||
expect(fetchSelectedScopesSpy).toHaveBeenCalled();
|
||||
expect(getClosestScopesFacade(dashboardScene)?.value).toEqual(
|
||||
mocksScopes.filter(({ metadata: { name } }) => name === 'cloud')
|
||||
);
|
||||
expect(getListOfScopes()).toEqual(mocksScopes.filter(({ metadata: { name } }) => name === 'cloud'));
|
||||
});
|
||||
|
||||
it('Does not save the scopes on close', async () => {
|
||||
@@ -49,7 +50,7 @@ describe('Selector', () => {
|
||||
await selectResultCloud();
|
||||
await cancelScopes();
|
||||
expect(fetchSelectedScopesSpy).not.toHaveBeenCalled();
|
||||
expect(getClosestScopesFacade(dashboardScene)?.value).toEqual([]);
|
||||
expect(getListOfScopes()).toEqual([]);
|
||||
});
|
||||
|
||||
it('Shows selected scopes', async () => {
|
||||
@@ -59,25 +60,6 @@ describe('Selector', () => {
|
||||
|
||||
it('Does not reload the dashboard on scope change', async () => {
|
||||
await updateScopes(['grafana']);
|
||||
expectNotDashboardReload();
|
||||
});
|
||||
|
||||
it('Adds scopes to enrichers', async () => {
|
||||
const queryRunner = sceneGraph.getQueryController(dashboardScene)!;
|
||||
|
||||
await updateScopes(['grafana']);
|
||||
let scopes = mocksScopes.filter(({ metadata: { name } }) => name === 'grafana');
|
||||
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(scopes);
|
||||
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(scopes);
|
||||
|
||||
await updateScopes(['grafana', 'mimir']);
|
||||
scopes = mocksScopes.filter(({ metadata: { name } }) => name === 'grafana' || name === 'mimir');
|
||||
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(scopes);
|
||||
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(scopes);
|
||||
|
||||
await updateScopes(['mimir']);
|
||||
scopes = mocksScopes.filter(({ metadata: { name } }) => name === 'mimir');
|
||||
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(scopes);
|
||||
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(scopes);
|
||||
expect(dashboardReloadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { ScopesSelectorService } from '../selector/ScopesSelectorService';
|
||||
|
||||
import {
|
||||
applyScopes,
|
||||
clearScopesSearch,
|
||||
@@ -39,7 +41,7 @@ import {
|
||||
expectSelectedScopePath,
|
||||
expectTreeScopePath,
|
||||
} from './utils/assertions';
|
||||
import { fetchNodesSpy, fetchScopeSpy, getDatasource, getInstanceSettings, getMock } from './utils/mocks';
|
||||
import { getDatasource, getInstanceSettings, getMock } from './utils/mocks';
|
||||
import { renderDashboard, resetScenes } from './utils/render';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
@@ -52,17 +54,22 @@ jest.mock('@grafana/runtime', () => ({
|
||||
}));
|
||||
|
||||
describe('Tree', () => {
|
||||
let fetchNodesSpy: jest.SpyInstance;
|
||||
let fetchScopeSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(() => {
|
||||
config.featureToggles.scopeFilters = true;
|
||||
config.featureToggles.groupByVariable = true;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
renderDashboard();
|
||||
beforeEach(async () => {
|
||||
await renderDashboard();
|
||||
fetchNodesSpy = jest.spyOn(ScopesSelectorService.instance!, 'fetchNodeApi');
|
||||
fetchScopeSpy = jest.spyOn(ScopesSelectorService.instance!, 'fetchScopeApi');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await resetScenes();
|
||||
await resetScenes([fetchNodesSpy, fetchScopeSpy]);
|
||||
});
|
||||
|
||||
it('Fetches scope details on select', async () => {
|
||||
@@ -126,16 +133,16 @@ describe('Tree', () => {
|
||||
await openSelector();
|
||||
await expandResultApplications();
|
||||
await searchScopes('Cloud');
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
|
||||
expectResultApplicationsGrafanaNotPresent();
|
||||
expectResultApplicationsMimirNotPresent();
|
||||
expectResultApplicationsCloudPresent();
|
||||
|
||||
await clearScopesSearch();
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(4);
|
||||
|
||||
await searchScopes('Grafana');
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(4);
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(5);
|
||||
expectResultApplicationsGrafanaPresent();
|
||||
expectResultApplicationsCloudNotPresent();
|
||||
});
|
||||
@@ -156,7 +163,7 @@ describe('Tree', () => {
|
||||
await expandResultApplications();
|
||||
await selectResultApplicationsMimir();
|
||||
await searchScopes('grafana');
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
|
||||
expectPersistedApplicationsMimirPresent();
|
||||
expectPersistedApplicationsGrafanaNotPresent();
|
||||
expectResultApplicationsMimirNotPresent();
|
||||
@@ -168,7 +175,7 @@ describe('Tree', () => {
|
||||
await expandResultApplications();
|
||||
await selectResultApplicationsMimir();
|
||||
await searchScopes('mimir');
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
|
||||
expectPersistedApplicationsMimirNotPresent();
|
||||
expectResultApplicationsMimirPresent();
|
||||
});
|
||||
@@ -178,10 +185,10 @@ describe('Tree', () => {
|
||||
await expandResultApplications();
|
||||
await selectResultApplicationsMimir();
|
||||
await searchScopes('grafana');
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
await clearScopesSearch();
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(4);
|
||||
expectPersistedApplicationsMimirNotPresent();
|
||||
expectPersistedApplicationsGrafanaNotPresent();
|
||||
expectResultApplicationsMimirPresent();
|
||||
@@ -192,15 +199,15 @@ describe('Tree', () => {
|
||||
await openSelector();
|
||||
await expandResultApplications();
|
||||
await searchScopes('mimir');
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
await selectResultApplicationsMimir();
|
||||
await searchScopes('unknown');
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(4);
|
||||
expectPersistedApplicationsMimirPresent();
|
||||
|
||||
await clearScopesSearch();
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(4);
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(5);
|
||||
expectResultApplicationsMimirPresent();
|
||||
expectResultApplicationsGrafanaPresent();
|
||||
});
|
||||
@@ -210,7 +217,7 @@ describe('Tree', () => {
|
||||
await expandResultApplications();
|
||||
await selectResultApplicationsMimir();
|
||||
await searchScopes('grafana');
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
await selectResultApplicationsGrafana();
|
||||
await applyScopes();
|
||||
@@ -222,7 +229,7 @@ describe('Tree', () => {
|
||||
await expandResultApplications();
|
||||
await selectResultApplicationsMimir();
|
||||
await searchScopes('grafana');
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
await selectResultApplicationsGrafana();
|
||||
await applyScopes();
|
||||
@@ -239,11 +246,11 @@ describe('Tree', () => {
|
||||
expectScopesHeadline('Recommended');
|
||||
|
||||
await searchScopes('Applications');
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
|
||||
expectScopesHeadline('Results');
|
||||
|
||||
await searchScopes('unknown');
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
|
||||
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
|
||||
expectScopesHeadline('No results found for your query');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
import { filterFolders, groupDashboards } from '../internal/utils';
|
||||
|
||||
import {
|
||||
alternativeDashboardWithRootFolder,
|
||||
alternativeDashboardWithTwoFolders,
|
||||
dashboardWithOneFolder,
|
||||
dashboardWithoutFolder,
|
||||
dashboardWithRootFolder,
|
||||
dashboardWithRootFolderAndOtherFolder,
|
||||
dashboardWithTwoFolders,
|
||||
} from './utils/mocks';
|
||||
|
||||
describe('Utils', () => {
|
||||
describe('groupDashboards', () => {
|
||||
it('Assigns dashboards without groups to root folder', () => {
|
||||
expect(groupDashboards([dashboardWithoutFolder])).toEqual({
|
||||
'': {
|
||||
title: '',
|
||||
isExpanded: true,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
[dashboardWithoutFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithoutFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithoutFolder.status.dashboardTitle,
|
||||
items: [dashboardWithoutFolder],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Assigns dashboards with root group to root folder', () => {
|
||||
expect(groupDashboards([dashboardWithRootFolder])).toEqual({
|
||||
'': {
|
||||
title: '',
|
||||
isExpanded: true,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
[dashboardWithRootFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithRootFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithRootFolder.status.dashboardTitle,
|
||||
items: [dashboardWithRootFolder],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Merges folders from multiple dashboards', () => {
|
||||
expect(groupDashboards([dashboardWithOneFolder, dashboardWithTwoFolders])).toEqual({
|
||||
'': {
|
||||
title: '',
|
||||
isExpanded: true,
|
||||
folders: {
|
||||
'Folder 1': {
|
||||
title: 'Folder 1',
|
||||
isExpanded: false,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
[dashboardWithOneFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithOneFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithOneFolder.status.dashboardTitle,
|
||||
items: [dashboardWithOneFolder],
|
||||
},
|
||||
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
|
||||
items: [dashboardWithTwoFolders],
|
||||
},
|
||||
},
|
||||
},
|
||||
'Folder 2': {
|
||||
title: 'Folder 2',
|
||||
isExpanded: false,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
|
||||
items: [dashboardWithTwoFolders],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboards: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Merges scopes from multiple dashboards', () => {
|
||||
expect(groupDashboards([dashboardWithTwoFolders, alternativeDashboardWithTwoFolders])).toEqual({
|
||||
'': {
|
||||
title: '',
|
||||
isExpanded: true,
|
||||
folders: {
|
||||
'Folder 1': {
|
||||
title: 'Folder 1',
|
||||
isExpanded: false,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
|
||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
|
||||
},
|
||||
},
|
||||
},
|
||||
'Folder 2': {
|
||||
title: 'Folder 2',
|
||||
isExpanded: false,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
|
||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboards: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Matches snapshot', () => {
|
||||
expect(
|
||||
groupDashboards([
|
||||
dashboardWithoutFolder,
|
||||
dashboardWithOneFolder,
|
||||
dashboardWithTwoFolders,
|
||||
alternativeDashboardWithTwoFolders,
|
||||
dashboardWithRootFolder,
|
||||
alternativeDashboardWithRootFolder,
|
||||
dashboardWithRootFolderAndOtherFolder,
|
||||
])
|
||||
).toEqual({
|
||||
'': {
|
||||
dashboards: {
|
||||
[dashboardWithRootFolderAndOtherFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithRootFolderAndOtherFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithRootFolderAndOtherFolder.status.dashboardTitle,
|
||||
items: [dashboardWithRootFolderAndOtherFolder],
|
||||
},
|
||||
[dashboardWithRootFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithRootFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithRootFolder.status.dashboardTitle,
|
||||
items: [dashboardWithRootFolder, alternativeDashboardWithRootFolder],
|
||||
},
|
||||
[dashboardWithoutFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithoutFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithoutFolder.status.dashboardTitle,
|
||||
items: [dashboardWithoutFolder],
|
||||
},
|
||||
},
|
||||
folders: {
|
||||
'Folder 1': {
|
||||
dashboards: {
|
||||
[dashboardWithOneFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithOneFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithOneFolder.status.dashboardTitle,
|
||||
items: [dashboardWithOneFolder],
|
||||
},
|
||||
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
|
||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
|
||||
},
|
||||
},
|
||||
folders: {},
|
||||
isExpanded: false,
|
||||
title: 'Folder 1',
|
||||
},
|
||||
'Folder 2': {
|
||||
dashboards: {
|
||||
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle,
|
||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
|
||||
},
|
||||
},
|
||||
folders: {},
|
||||
isExpanded: false,
|
||||
title: 'Folder 2',
|
||||
},
|
||||
'Folder 3': {
|
||||
dashboards: {
|
||||
[dashboardWithRootFolderAndOtherFolder.spec.dashboard]: {
|
||||
dashboard: dashboardWithRootFolderAndOtherFolder.spec.dashboard,
|
||||
dashboardTitle: dashboardWithRootFolderAndOtherFolder.status.dashboardTitle,
|
||||
items: [dashboardWithRootFolderAndOtherFolder],
|
||||
},
|
||||
},
|
||||
folders: {},
|
||||
isExpanded: false,
|
||||
title: 'Folder 3',
|
||||
},
|
||||
},
|
||||
isExpanded: true,
|
||||
title: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterFolders', () => {
|
||||
it('Shows folders matching criteria', () => {
|
||||
expect(
|
||||
filterFolders(
|
||||
{
|
||||
'': {
|
||||
title: '',
|
||||
isExpanded: true,
|
||||
folders: {
|
||||
'Folder 1': {
|
||||
title: 'Folder 1',
|
||||
isExpanded: false,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
'Folder 2': {
|
||||
title: 'Folder 2',
|
||||
isExpanded: true,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Folder'
|
||||
)
|
||||
).toEqual({
|
||||
'': {
|
||||
title: '',
|
||||
isExpanded: true,
|
||||
folders: {
|
||||
'Folder 1': {
|
||||
title: 'Folder 1',
|
||||
isExpanded: true,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
'Folder 2': {
|
||||
title: 'Folder 2',
|
||||
isExpanded: true,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboards: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Shows dashboards matching criteria', () => {
|
||||
expect(
|
||||
filterFolders(
|
||||
{
|
||||
'': {
|
||||
title: '',
|
||||
isExpanded: true,
|
||||
folders: {
|
||||
'Folder 1': {
|
||||
title: 'Folder 1',
|
||||
isExpanded: false,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
'Folder 2': {
|
||||
title: 'Folder 2',
|
||||
isExpanded: true,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
'Random ID': {
|
||||
dashboard: 'Random ID',
|
||||
dashboardTitle: 'Random Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
'Random ID': {
|
||||
dashboard: 'Random ID',
|
||||
dashboardTitle: 'Random Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'dash'
|
||||
)
|
||||
).toEqual({
|
||||
'': {
|
||||
title: '',
|
||||
isExpanded: true,
|
||||
folders: {
|
||||
'Folder 1': {
|
||||
title: 'Folder 1',
|
||||
isExpanded: true,
|
||||
folders: {},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboards: {
|
||||
'Dashboard ID': {
|
||||
dashboard: 'Dashboard ID',
|
||||
dashboardTitle: 'Dashboard Title',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,16 +5,8 @@ import { MultiValueVariable, sceneGraph, VariableValue } from '@grafana/scenes';
|
||||
import { defaultTimeZone, TimeZone } from '@grafana/schema';
|
||||
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
||||
|
||||
import { scopesSelectorScene } from '../../instance';
|
||||
import { ScopesService } from '../../ScopesService';
|
||||
|
||||
import {
|
||||
dashboardReloadSpy,
|
||||
fetchDashboardsSpy,
|
||||
fetchNodesSpy,
|
||||
fetchScopeSpy,
|
||||
fetchSelectedScopesSpy,
|
||||
getMock,
|
||||
} from './mocks';
|
||||
import {
|
||||
getDashboardFolderExpand,
|
||||
getDashboardsExpand,
|
||||
@@ -37,30 +29,13 @@ import {
|
||||
getTreeSearch,
|
||||
} from './selectors';
|
||||
|
||||
export const clearMocks = () => {
|
||||
fetchNodesSpy.mockClear();
|
||||
fetchScopeSpy.mockClear();
|
||||
fetchSelectedScopesSpy.mockClear();
|
||||
fetchDashboardsSpy.mockClear();
|
||||
dashboardReloadSpy.mockClear();
|
||||
getMock.mockClear();
|
||||
};
|
||||
|
||||
const click = async (selector: () => HTMLElement) => act(() => fireEvent.click(selector()));
|
||||
const type = async (selector: () => HTMLInputElement, value: string) => {
|
||||
await act(() => fireEvent.input(selector(), { target: { value } }));
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
};
|
||||
|
||||
export const updateScopes = async (scopes: string[]) =>
|
||||
act(async () =>
|
||||
scopesSelectorScene?.updateScopes(
|
||||
scopes.map((scopeName) => ({
|
||||
scopeName,
|
||||
path: [],
|
||||
}))
|
||||
)
|
||||
);
|
||||
export const updateScopes = async (scopes: string[]) => act(async () => ScopesService.instance?.changeScopes(scopes));
|
||||
export const openSelector = async () => click(getSelectorInput);
|
||||
export const applyScopes = async () => {
|
||||
await click(getSelectorApply);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { dashboardReloadSpy } from './mocks';
|
||||
import {
|
||||
getDashboard,
|
||||
getDashboardsContainer,
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
queryDashboard,
|
||||
queryDashboardFolderExpand,
|
||||
queryDashboardsContainer,
|
||||
queryDashboardsExpand,
|
||||
queryDashboardsSearch,
|
||||
queryPersistedApplicationsGrafanaSelect,
|
||||
queryPersistedApplicationsMimirSelect,
|
||||
@@ -29,7 +27,6 @@ import {
|
||||
queryResultApplicationsGrafanaSelect,
|
||||
queryResultApplicationsMimirSelect,
|
||||
querySelectorApply,
|
||||
querySelectorInput,
|
||||
} from './selectors';
|
||||
|
||||
const expectInDocument = (selector: () => HTMLElement) => expect(selector()).toBeInTheDocument();
|
||||
@@ -42,7 +39,7 @@ const expectTextContent = (selector: () => HTMLElement, text: string) => expect(
|
||||
const expectDisabled = (selector: () => HTMLElement) => expect(selector()).toBeDisabled();
|
||||
|
||||
export const expectScopesSelectorClosed = () => expectNotInDocument(querySelectorApply);
|
||||
export const expectScopesSelectorNotInDocument = () => expectNotInDocument(querySelectorInput);
|
||||
export const expectScopesSelectorDisabled = () => expectDisabled(getSelectorInput);
|
||||
export const expectScopesSelectorValue = (value: string) => expectValue(getSelectorInput, value);
|
||||
export const expectScopesHeadline = (value: string) => expectTextContent(getTreeHeadline, value);
|
||||
export const expectPersistedApplicationsGrafanaNotPresent = () =>
|
||||
@@ -65,7 +62,6 @@ export const expectResultCloudOpsSelected = () => expectRadioChecked(getResultCl
|
||||
export const expectResultCloudOpsNotSelected = () => expectRadioNotChecked(getResultCloudOpsRadio);
|
||||
|
||||
export const expectDashboardsDisabled = () => expectDisabled(getDashboardsExpand);
|
||||
export const expectDashboardsNotInDocument = () => expectNotInDocument(queryDashboardsExpand);
|
||||
export const expectDashboardsClosed = () => expectNotInDocument(queryDashboardsContainer);
|
||||
export const expectDashboardsOpen = () => expectInDocument(getDashboardsContainer);
|
||||
export const expectNoDashboardsSearch = () => expectNotInDocument(queryDashboardsSearch);
|
||||
@@ -81,9 +77,6 @@ export const expectDashboardNotInDocument = (uid: string) => expectNotInDocument
|
||||
export const expectDashboardLength = (uid: string, length: number) =>
|
||||
expect(queryAllDashboard(uid)).toHaveLength(length);
|
||||
|
||||
export const expectNotDashboardReload = () => expect(dashboardReloadSpy).not.toHaveBeenCalled();
|
||||
export const expectDashboardReload = () => expect(dashboardReloadSpy).toHaveBeenCalled();
|
||||
|
||||
export const expectSelectedScopePath = (name: string, path: string[] | undefined) =>
|
||||
expect(getSelectedScope(name)?.path).toEqual(path);
|
||||
export const expectTreeScopePath = (name: string, path: string[] | undefined) =>
|
||||
|
||||
@@ -2,8 +2,6 @@ import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data';
|
||||
import { DataSourceRef } from '@grafana/schema/dist/esm/common/common.gen';
|
||||
import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
|
||||
|
||||
import * as api from '../../internal/api';
|
||||
|
||||
export const mocksScopes: Scope[] = [
|
||||
{
|
||||
metadata: { name: 'cloud' },
|
||||
@@ -369,10 +367,6 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const fetchNodesSpy = jest.spyOn(api, 'fetchNodes');
|
||||
export const fetchScopeSpy = jest.spyOn(api, 'fetchScope');
|
||||
export const fetchSelectedScopesSpy = jest.spyOn(api, 'fetchSelectedScopes');
|
||||
export const fetchDashboardsSpy = jest.spyOn(api, 'fetchDashboards');
|
||||
export const dashboardReloadSpy = jest.spyOn(getDashboardScenePageStateManager(), 'reloadDashboard');
|
||||
|
||||
export const getMock = jest
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { cleanup, waitFor } from '@testing-library/react';
|
||||
import { KBarProvider } from 'kbar';
|
||||
import { render } from 'test/test-utils';
|
||||
|
||||
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
|
||||
import { config, setPluginImportUtils } from '@grafana/runtime';
|
||||
import { sceneGraph } from '@grafana/scenes';
|
||||
import { defaultDashboard } from '@grafana/schema';
|
||||
import { AppChrome } from 'app/core/components/AppChrome/AppChrome';
|
||||
import { transformSaveModelToScene } from 'app/features/dashboard-scene/serialization/transformSaveModelToScene';
|
||||
import { DashboardDataDTO, DashboardDTO, DashboardMeta } from 'app/types';
|
||||
|
||||
import { initializeScopes, scopesDashboardsScene, scopesSelectorScene } from '../../instance';
|
||||
import { getInitialDashboardsState } from '../../internal/ScopesDashboardsScene';
|
||||
import { initialSelectorState } from '../../internal/ScopesSelectorScene';
|
||||
import { ScopesContextProvider } from '../../ScopesContextProvider';
|
||||
import { ScopesService } from '../../ScopesService';
|
||||
import { ScopesDashboardsService } from '../../dashboards/ScopesDashboardsService';
|
||||
import { ScopesSelectorService } from '../../selector/ScopesSelectorService';
|
||||
|
||||
import { clearMocks } from './actions';
|
||||
import { getMock } from './mocks';
|
||||
|
||||
const getDashboardDTO: (
|
||||
overrideDashboard: Partial<DashboardDataDTO>,
|
||||
@@ -176,33 +178,38 @@ setPluginImportUtils({
|
||||
getPanelPluginFromCache: () => undefined,
|
||||
});
|
||||
|
||||
export function renderDashboard(
|
||||
export async function renderDashboard(
|
||||
overrideDashboard: Partial<DashboardDataDTO> = {},
|
||||
overrideMeta: Partial<DashboardMeta> = {}
|
||||
) {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.spyOn(console, 'error').mockImplementation(jest.fn());
|
||||
clearMocks();
|
||||
initializeScopes();
|
||||
|
||||
const dto: DashboardDTO = getDashboardDTO(overrideDashboard, overrideMeta);
|
||||
const scene = transformSaveModelToScene(dto);
|
||||
|
||||
render(
|
||||
<KBarProvider>
|
||||
<AppChrome>
|
||||
<scene.Component model={scene} />
|
||||
</AppChrome>
|
||||
<ScopesContextProvider>
|
||||
<AppChrome>
|
||||
<scene.Component model={scene} />
|
||||
</AppChrome>
|
||||
</ScopesContextProvider>
|
||||
</KBarProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(sceneGraph.getScopesBridge(scene)).toBeDefined());
|
||||
|
||||
return scene;
|
||||
}
|
||||
|
||||
export async function resetScenes() {
|
||||
export async function resetScenes(spies: jest.SpyInstance[] = []) {
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
jest.useRealTimers();
|
||||
scopesSelectorScene?.setState(initialSelectorState);
|
||||
scopesDashboardsScene?.setState(getInitialDashboardsState());
|
||||
getMock.mockClear();
|
||||
spies.forEach((spy) => spy.mockClear());
|
||||
ScopesService.instance?.reset();
|
||||
ScopesSelectorService.instance?.reset();
|
||||
ScopesDashboardsService.instance?.reset();
|
||||
cleanup();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
|
||||
import { scopesSelectorScene } from '../../instance';
|
||||
import { ScopesService } from '../../ScopesService';
|
||||
import { ScopesSelectorService } from '../../selector/ScopesSelectorService';
|
||||
|
||||
const selectors = {
|
||||
tree: {
|
||||
@@ -33,14 +34,12 @@ const selectors = {
|
||||
};
|
||||
|
||||
export const getSelectorInput = () => screen.getByTestId<HTMLInputElement>(selectors.selector.input);
|
||||
export const querySelectorInput = () => screen.queryByTestId<HTMLInputElement>(selectors.selector.input);
|
||||
export const querySelectorApply = () => screen.queryByTestId(selectors.selector.apply);
|
||||
export const getSelectorApply = () => screen.getByTestId(selectors.selector.apply);
|
||||
export const getSelectorCancel = () => screen.getByTestId(selectors.selector.cancel);
|
||||
|
||||
export const getDashboardsExpand = () => screen.getByTestId(selectors.dashboards.expand);
|
||||
export const getDashboardsContainer = () => screen.getByTestId(selectors.dashboards.container);
|
||||
export const queryDashboardsExpand = () => screen.queryByTestId(selectors.dashboards.expand);
|
||||
export const queryDashboardsContainer = () => screen.queryByTestId(selectors.dashboards.container);
|
||||
export const queryDashboardsSearch = () => screen.queryByTestId(selectors.dashboards.search);
|
||||
export const getDashboardsSearch = () => screen.getByTestId<HTMLInputElement>(selectors.dashboards.search);
|
||||
@@ -88,8 +87,9 @@ export const getResultCloudDevRadio = () =>
|
||||
export const getResultCloudOpsRadio = () =>
|
||||
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-ops', 'result'));
|
||||
|
||||
export const getListOfSelectedScopes = () => scopesSelectorScene?.state.scopes;
|
||||
export const getListOfTreeScopes = () => scopesSelectorScene?.state.treeScopes;
|
||||
export const getListOfScopes = () => ScopesService.instance?.state.value;
|
||||
export const getListOfSelectedScopes = () => ScopesSelectorService.instance?.state.selectedScopes;
|
||||
export const getListOfTreeScopes = () => ScopesSelectorService.instance?.state.treeScopes;
|
||||
export const getSelectedScope = (name: string) =>
|
||||
getListOfSelectedScopes()?.find((selectedScope) => selectedScope.scope.metadata.name === name);
|
||||
export const getTreeScope = (name: string) => getListOfTreeScopes()?.find((treeScope) => treeScope.scopeName === name);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
||||
|
||||
import { scopesDashboardsScene, scopesSelectorScene } from '../instance';
|
||||
import { ScopesService } from '../ScopesService';
|
||||
|
||||
import { enterEditMode, openSelector, toggleDashboards } from './utils/actions';
|
||||
import {
|
||||
expectDashboardsClosed,
|
||||
expectDashboardsNotInDocument,
|
||||
expectDashboardsDisabled,
|
||||
expectScopesSelectorClosed,
|
||||
expectScopesSelectorNotInDocument,
|
||||
expectScopesSelectorDisabled,
|
||||
} from './utils/assertions';
|
||||
import { getDatasource, getInstanceSettings, getMock } from './utils/mocks';
|
||||
import { renderDashboard, resetScenes } from './utils/render';
|
||||
@@ -30,8 +30,8 @@ describe('View mode', () => {
|
||||
config.featureToggles.groupByVariable = true;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
dashboardScene = renderDashboard();
|
||||
beforeEach(async () => {
|
||||
dashboardScene = await renderDashboard();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -40,8 +40,8 @@ describe('View mode', () => {
|
||||
|
||||
it('Enters view mode', async () => {
|
||||
await enterEditMode(dashboardScene);
|
||||
expect(scopesSelectorScene?.state?.isReadOnly).toEqual(true);
|
||||
expect(scopesDashboardsScene?.state?.isPanelOpened).toEqual(false);
|
||||
expect(ScopesService.instance?.state.readOnly).toEqual(true);
|
||||
expect(ScopesService.instance?.state.drawerOpened).toEqual(false);
|
||||
});
|
||||
|
||||
it('Closes selector on enter', async () => {
|
||||
@@ -58,11 +58,11 @@ describe('View mode', () => {
|
||||
|
||||
it('Does not show selector when view mode is active', async () => {
|
||||
await enterEditMode(dashboardScene);
|
||||
expectScopesSelectorNotInDocument();
|
||||
expectScopesSelectorDisabled();
|
||||
});
|
||||
|
||||
it('Does not show the expand button when view mode is active', async () => {
|
||||
await enterEditMode(dashboardScene);
|
||||
expectDashboardsNotInDocument();
|
||||
expectDashboardsDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { scopesDashboardsScene } from './instance';
|
||||
|
||||
export const useScopesDashboardsState = () => {
|
||||
return scopesDashboardsScene?.useState();
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Scope } from '@grafana/data';
|
||||
import { sceneGraph, SceneObject } from '@grafana/scenes';
|
||||
|
||||
import { ScopesFacade } from './ScopesFacadeScene';
|
||||
import { scopesDashboardsScene, scopesSelectorScene } from './instance';
|
||||
import { getScopesFromSelectedScopes } from './internal/utils';
|
||||
|
||||
export function getSelectedScopes(): Scope[] {
|
||||
return getScopesFromSelectedScopes(scopesSelectorScene?.state.scopes ?? []);
|
||||
}
|
||||
|
||||
export function getSelectedScopesNames(): string[] {
|
||||
return getSelectedScopes().map((scope) => scope.metadata.name);
|
||||
}
|
||||
|
||||
export function enableScopes() {
|
||||
scopesSelectorScene?.enable();
|
||||
scopesDashboardsScene?.enable();
|
||||
}
|
||||
|
||||
export function disableScopes() {
|
||||
scopesSelectorScene?.disable();
|
||||
scopesDashboardsScene?.disable();
|
||||
}
|
||||
|
||||
export function exitScopesReadOnly() {
|
||||
scopesSelectorScene?.exitReadOnly();
|
||||
scopesDashboardsScene?.exitReadOnly();
|
||||
}
|
||||
|
||||
export function enterScopesReadOnly() {
|
||||
scopesSelectorScene?.enterReadOnly();
|
||||
scopesDashboardsScene?.enterReadOnly();
|
||||
}
|
||||
|
||||
export function getClosestScopesFacade(scene: SceneObject): ScopesFacade | null {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return sceneGraph.findObject(scene, (obj) => obj instanceof ScopesFacade) as ScopesFacade | null;
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
VariableValueSelectors,
|
||||
} from '@grafana/scenes';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { getSelectedScopes } from 'app/features/scopes';
|
||||
|
||||
import { DataTrailSettings } from './DataTrailSettings';
|
||||
import { DataTrailHistory } from './DataTrailsHistory';
|
||||
@@ -426,7 +425,11 @@ export class DataTrail extends SceneObjectBase<DataTrailState> implements SceneO
|
||||
if (timeRange) {
|
||||
const datasourceUid = sceneGraph.interpolate(trail, VAR_DATASOURCE_EXPR);
|
||||
const otelTargets = await totalOtelResources(datasourceUid, timeRange);
|
||||
const deploymentEnvironments = await getDeploymentEnvironments(datasourceUid, timeRange, getSelectedScopes());
|
||||
const deploymentEnvironments = await getDeploymentEnvironments(
|
||||
datasourceUid,
|
||||
timeRange,
|
||||
sceneGraph.getScopesBridge(trail)?.getValue() ?? []
|
||||
);
|
||||
const hasOtelResources = otelTargets.jobs.length > 0 && otelTargets.instances.length > 0;
|
||||
// loading from the url with otel resources selected will result in turning on OTel experience
|
||||
const otelResourcesVariable = sceneGraph.lookupVariable(VAR_OTEL_AND_METRIC_FILTERS, this);
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Routes, Route } from 'react-router-dom-v5-compat';
|
||||
|
||||
import {
|
||||
DataQueryRequest,
|
||||
DataSourceGetTagKeysOptions,
|
||||
DataSourceGetTagValuesOptions,
|
||||
PageLayoutType,
|
||||
} from '@grafana/data';
|
||||
import { PageLayoutType } from '@grafana/data';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState, UrlSyncContextProvider } from '@grafana/scenes';
|
||||
import { useStyles2 } from '@grafana/ui/';
|
||||
import {
|
||||
SceneComponentProps,
|
||||
SceneObjectBase,
|
||||
SceneObjectState,
|
||||
SceneScopesBridge,
|
||||
UrlSyncContextProvider,
|
||||
} from '@grafana/scenes';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { getClosestScopesFacade, ScopesFacade, ScopesSelector } from 'app/features/scopes';
|
||||
|
||||
import { AppChromeUpdate } from '../../core/components/AppChrome/AppChromeUpdate';
|
||||
|
||||
import { DataTrail } from './DataTrail';
|
||||
import { DataTrailsHome } from './DataTrailsHome';
|
||||
@@ -25,35 +21,14 @@ import { getMetricName, getUrlForTrail, newMetricsTrail } from './utils';
|
||||
export interface DataTrailsAppState extends SceneObjectState {
|
||||
trail: DataTrail;
|
||||
home: DataTrailsHome;
|
||||
scopesBridge?: SceneScopesBridge | undefined;
|
||||
}
|
||||
|
||||
export class DataTrailsApp extends SceneObjectBase<DataTrailsAppState> {
|
||||
private _scopesFacade: ScopesFacade | null;
|
||||
protected _renderBeforeActivation = true;
|
||||
|
||||
public constructor(state: DataTrailsAppState) {
|
||||
super(state);
|
||||
|
||||
this._scopesFacade = getClosestScopesFacade(this);
|
||||
}
|
||||
|
||||
public enrichDataRequest(): Partial<DataQueryRequest> {
|
||||
if (!config.featureToggles.promQLScope) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
scopes: this._scopesFacade?.value,
|
||||
};
|
||||
}
|
||||
|
||||
public enrichFiltersRequest(): Partial<DataSourceGetTagKeysOptions | DataSourceGetTagValuesOptions> {
|
||||
if (!config.featureToggles.promQLScope) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
scopes: this._scopesFacade?.value,
|
||||
};
|
||||
}
|
||||
|
||||
goToUrlForTrail(trail: DataTrail) {
|
||||
@@ -62,33 +37,35 @@ export class DataTrailsApp extends SceneObjectBase<DataTrailsAppState> {
|
||||
}
|
||||
|
||||
static Component = ({ model }: SceneComponentProps<DataTrailsApp>) => {
|
||||
const { trail, home } = model.useState();
|
||||
const { trail, home, scopesBridge } = model.useState();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* The routes are relative to the HOME_ROUTE */}
|
||||
<Route
|
||||
path={'/'}
|
||||
element={
|
||||
<Page
|
||||
navId="explore/metrics"
|
||||
layout={PageLayoutType.Standard}
|
||||
// Returning null to prevent default behavior which renders a header
|
||||
renderTitle={() => null}
|
||||
subTitle=""
|
||||
>
|
||||
<home.Component model={home} />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route path={TRAILS_ROUTE.replace(HOME_ROUTE, '')} element={<DataTrailView trail={trail} />} />
|
||||
</Routes>
|
||||
<>
|
||||
{scopesBridge && <SceneScopesBridge.Component model={scopesBridge} />}
|
||||
<Routes>
|
||||
{/* The routes are relative to the HOME_ROUTE */}
|
||||
<Route
|
||||
path={'/'}
|
||||
element={
|
||||
<Page
|
||||
navId="explore/metrics"
|
||||
layout={PageLayoutType.Standard}
|
||||
// Returning null to prevent default behavior which renders a header
|
||||
renderTitle={() => null}
|
||||
subTitle=""
|
||||
>
|
||||
<home.Component model={home} />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route path={TRAILS_ROUTE.replace(HOME_ROUTE, '')} element={<DataTrailView trail={trail} />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function DataTrailView({ trail }: { trail: DataTrail }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const { metric } = trail.useState();
|
||||
|
||||
@@ -108,15 +85,6 @@ function DataTrailView({ trail }: { trail: DataTrail }) {
|
||||
return (
|
||||
<UrlSyncContextProvider scene={trail}>
|
||||
<Page navId="explore/metrics" pageNav={{ text: getMetricName(metric) }} layout={PageLayoutType.Custom}>
|
||||
{config.featureToggles.enableScopesInMetricsExplore && (
|
||||
<AppChromeUpdate
|
||||
actions={
|
||||
<div className={styles.topNavContainer}>
|
||||
<ScopesSelector />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<trail.Component model={trail} />
|
||||
</Page>
|
||||
</UrlSyncContextProvider>
|
||||
@@ -127,37 +95,32 @@ let dataTrailsApp: DataTrailsApp;
|
||||
|
||||
export function getDataTrailsApp() {
|
||||
if (!dataTrailsApp) {
|
||||
const $behaviors = config.featureToggles.enableScopesInMetricsExplore
|
||||
? [
|
||||
new ScopesFacade({
|
||||
handler: (facade) => {
|
||||
const trail = facade.parent && 'trail' in facade.parent.state ? facade.parent.state.trail : undefined;
|
||||
|
||||
if (trail instanceof DataTrail) {
|
||||
trail.publishEvent(new RefreshMetricsEvent());
|
||||
trail.checkDataSourceForOTelResources();
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
: undefined;
|
||||
const scopesBridge =
|
||||
config.featureToggles.scopeFilters && config.featureToggles.enableScopesInMetricsExplore
|
||||
? new SceneScopesBridge({})
|
||||
: undefined;
|
||||
|
||||
dataTrailsApp = new DataTrailsApp({
|
||||
trail: newMetricsTrail(),
|
||||
home: new DataTrailsHome({}),
|
||||
$behaviors,
|
||||
scopesBridge,
|
||||
$behaviors: [
|
||||
() => {
|
||||
scopesBridge?.setEnabled(true);
|
||||
|
||||
const sub = scopesBridge?.subscribeToValue(() => {
|
||||
dataTrailsApp.state.trail.publishEvent(new RefreshMetricsEvent());
|
||||
dataTrailsApp.state.trail.checkDataSourceForOTelResources();
|
||||
});
|
||||
|
||||
return () => {
|
||||
scopesBridge?.setEnabled(false);
|
||||
sub?.unsubscribe();
|
||||
};
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return dataTrailsApp;
|
||||
}
|
||||
|
||||
const getStyles = () => ({
|
||||
topNavContainer: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyItems: 'flex-start',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
} from '@grafana/scenes';
|
||||
import { Alert, Badge, Field, Icon, IconButton, InlineSwitch, Input, Select, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { getSelectedScopes } from 'app/features/scopes';
|
||||
|
||||
import { MetricScene } from '../MetricScene';
|
||||
import { StatusWrapper } from '../StatusWrapper';
|
||||
@@ -257,7 +256,7 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> i
|
||||
const response = await getMetricNames(
|
||||
datasourceUid,
|
||||
timeRange,
|
||||
getSelectedScopes(),
|
||||
sceneGraph.getScopesBridge(this)?.getValue() ?? [],
|
||||
filters,
|
||||
jobsList,
|
||||
instancesList,
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('limitAdhocProviders', () => {
|
||||
} as unknown as MetricDatasourceHelper;
|
||||
|
||||
dataTrail = {
|
||||
forEachChild: jest.fn(),
|
||||
getQueries: jest.fn().mockReturnValue([]),
|
||||
} as unknown as DataTrail;
|
||||
});
|
||||
|
||||
@@ -20,12 +20,12 @@ import {
|
||||
SceneObject,
|
||||
SceneObjectState,
|
||||
SceneObjectUrlValues,
|
||||
SceneScopesBridge,
|
||||
SceneTimeRange,
|
||||
sceneUtils,
|
||||
SceneVariable,
|
||||
SceneVariableState,
|
||||
} from '@grafana/scenes';
|
||||
import { getClosestScopesFacade } from 'app/features/scopes';
|
||||
|
||||
import { getDatasourceSrv } from '../plugins/datasource_srv';
|
||||
|
||||
@@ -53,6 +53,10 @@ export function getTrailFor(model: SceneObject): DataTrail {
|
||||
return sceneGraph.getAncestor(model, DataTrail);
|
||||
}
|
||||
|
||||
export function getScopesBridgeFor(model: SceneObject): SceneScopesBridge | undefined {
|
||||
return sceneGraph.getScopesBridge(getTrailFor(model));
|
||||
}
|
||||
|
||||
export function getTrailSettings(model: SceneObject): DataTrailSettings {
|
||||
return sceneGraph.getAncestor(model, DataTrail).state.settings;
|
||||
}
|
||||
@@ -193,7 +197,7 @@ export function limitAdhocProviders(
|
||||
|
||||
const opts = {
|
||||
filters,
|
||||
scopes: getClosestScopesFacade(variable)?.value,
|
||||
scopes: sceneGraph.getScopesBridge(dataTrail)?.getValue(),
|
||||
queries: dataTrail.getQueries(),
|
||||
};
|
||||
|
||||
@@ -237,7 +241,7 @@ export function limitAdhocProviders(
|
||||
const opts = {
|
||||
key: filter.key,
|
||||
filters,
|
||||
scopes: getClosestScopesFacade(variable)?.value,
|
||||
scopes: sceneGraph.getScopesBridge(dataTrail)?.getValue(),
|
||||
queries: dataTrail.getQueries(),
|
||||
};
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.3, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.16.7, @babel/code-frame@npm:^7.24.2, @babel/code-frame@npm:^7.26.2":
|
||||
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.3, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.16.7, @babel/code-frame@npm:^7.24.2, @babel/code-frame@npm:^7.25.9, @babel/code-frame@npm:^7.26.2":
|
||||
version: 7.26.2
|
||||
resolution: "@babel/code-frame@npm:7.26.2"
|
||||
dependencies:
|
||||
@@ -362,6 +362,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/parser@npm:^7.25.9":
|
||||
version: 7.26.7
|
||||
resolution: "@babel/parser@npm:7.26.7"
|
||||
dependencies:
|
||||
"@babel/types": "npm:^7.26.7"
|
||||
bin:
|
||||
parser: ./bin/babel-parser.js
|
||||
checksum: 10/3ccc384366ca9a9b49c54f5b24c9d8cff9a505f2fbdd1cfc04941c8e1897084cc32f100e77900c12bc14a176cf88daa3c155faad680d9a23491b997fd2a59ffc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.9":
|
||||
version: 7.25.9
|
||||
resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.25.9"
|
||||
@@ -1425,7 +1436,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/template@npm:^7.22.5, @babel/template@npm:^7.24.7, @babel/template@npm:^7.25.9, @babel/template@npm:^7.26.9, @babel/template@npm:^7.3.3":
|
||||
"@babel/template@npm:^7.22.5, @babel/template@npm:^7.24.7, @babel/template@npm:^7.25.9, @babel/template@npm:^7.3.3":
|
||||
version: 7.25.9
|
||||
resolution: "@babel/template@npm:7.25.9"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:^7.25.9"
|
||||
"@babel/parser": "npm:^7.25.9"
|
||||
"@babel/types": "npm:^7.25.9"
|
||||
checksum: 10/e861180881507210150c1335ad94aff80fd9e9be6202e1efa752059c93224e2d5310186ddcdd4c0f0b0fc658ce48cb47823f15142b5c00c8456dde54f5de80b2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/template@npm:^7.26.9":
|
||||
version: 7.26.9
|
||||
resolution: "@babel/template@npm:7.26.9"
|
||||
dependencies:
|
||||
@@ -1461,6 +1483,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/types@npm:^7.26.7":
|
||||
version: 7.26.7
|
||||
resolution: "@babel/types@npm:7.26.7"
|
||||
dependencies:
|
||||
"@babel/helper-string-parser": "npm:^7.25.9"
|
||||
"@babel/helper-validator-identifier": "npm:^7.25.9"
|
||||
checksum: 10/2264efd02cc261ca5d1c5bc94497c8995238f28afd2b7483b24ea64dd694cf46b00d51815bf0c87f0d0061ea221569c77893aeecb0d4b4bb254e9c2f938d7669
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@bcoe/v8-coverage@npm:^0.2.3":
|
||||
version: 0.2.3
|
||||
resolution: "@bcoe/v8-coverage@npm:0.2.3"
|
||||
@@ -3597,11 +3629,11 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@grafana/scenes-react@npm:6.2.1":
|
||||
version: 6.2.1
|
||||
resolution: "@grafana/scenes-react@npm:6.2.1"
|
||||
"@grafana/scenes-react@npm:6.3.1":
|
||||
version: 6.3.1
|
||||
resolution: "@grafana/scenes-react@npm:6.3.1"
|
||||
dependencies:
|
||||
"@grafana/scenes": "npm:6.2.1"
|
||||
"@grafana/scenes": "npm:6.3.1"
|
||||
lru-cache: "npm:^10.2.2"
|
||||
react-use: "npm:^17.4.0"
|
||||
peerDependencies:
|
||||
@@ -3613,13 +3645,13 @@ __metadata:
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
react-router-dom: ^6.28.0
|
||||
checksum: 10/b8f44f087999fd6074233090a005de03e9d0ed1ba965a05659773cf48afb3c11ed508371fcf0c4cdc3c9a360b3f33e5126ed9082e2f4ee306459d768bd0a5a34
|
||||
checksum: 10/77dd6f7bbe3699ece25435623a78f2ef5d831ab83b59612baa362581f7c3f6c02cb420b5acfff4e3d266872a694e08b98340370468bb246d102b20e9fcc79438
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/scenes@npm:6.2.1":
|
||||
version: 6.2.1
|
||||
resolution: "@grafana/scenes@npm:6.2.1"
|
||||
"@grafana/scenes@npm:6.3.1":
|
||||
version: 6.3.1
|
||||
resolution: "@grafana/scenes@npm:6.3.1"
|
||||
dependencies:
|
||||
"@floating-ui/react": "npm:^0.26.16"
|
||||
"@leeoniya/ufuzzy": "npm:^1.0.16"
|
||||
@@ -3637,7 +3669,7 @@ __metadata:
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
react-router-dom: ^6.28.0
|
||||
checksum: 10/44e9a0386dd09a1a7a45bcdcbf285a00996e885cf56640179cbc35deb1f26af37ce34744321193f98aa078a7e02fc8a06b8f07c03b392229cc42ed56733bab05
|
||||
checksum: 10/98e3e96b9ce12ae67aa458819dc9eed1eabd1b7421e768074bd97a0fa2a0b1e080d4121b1ddd357ce41ccaf9252439163aae7e612f12cba878ee6dca34f73831
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -18071,8 +18103,8 @@ __metadata:
|
||||
"@grafana/prometheus": "workspace:*"
|
||||
"@grafana/runtime": "workspace:*"
|
||||
"@grafana/saga-icons": "workspace:*"
|
||||
"@grafana/scenes": "npm:6.2.1"
|
||||
"@grafana/scenes-react": "npm:6.2.1"
|
||||
"@grafana/scenes": "npm:6.3.1"
|
||||
"@grafana/scenes-react": "npm:6.3.1"
|
||||
"@grafana/schema": "workspace:*"
|
||||
"@grafana/sql": "workspace:*"
|
||||
"@grafana/tsconfig": "npm:^2.0.0"
|
||||
|
||||
Reference in New Issue
Block a user