@@ -119,6 +120,9 @@ function getStyles(theme: GrafanaTheme2) {
flexGrow: 0,
gridArea: 'controls',
padding: theme.spacing(2),
+ ':empty': {
+ display: 'none',
+ },
}),
controlsWrapperWithScopes: css({
padding: theme.spacing(2, 2, 2, 0),
@@ -139,7 +143,11 @@ function getStyles(theme: GrafanaTheme2) {
flexGrow: 1,
display: 'flex',
gap: '8px',
- marginBottom: theme.spacing(2),
+ paddingBottom: theme.spacing(2),
+ boxSizing: 'border-box',
+ }),
+ bodyWithoutControls: css({
+ paddingTop: theme.spacing(2),
}),
};
}
diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts
index 984b7c488b0..c1a7172044f 100644
--- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts
+++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts
@@ -1,6 +1,7 @@
import { AppEvents } from '@grafana/data';
import { SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import appEvents from 'app/core/app_events';
+import { KioskMode } from 'app/types';
import { DashboardGridItem } from './DashboardGridItem';
import { DashboardScene } from './DashboardScene';
@@ -39,6 +40,29 @@ describe('DashboardSceneUrlSync', () => {
(scene.state.body as SceneGridLayout).setState({ UNSAFE_fitPanels: true });
expect(scene.urlSync?.getUrlState().autofitpanels).toBe('true');
});
+
+ it('Should set kiosk mode when url has kiosk', () => {
+ const scene = buildTestScene();
+
+ scene.urlSync?.updateFromUrl({ kiosk: 'invalid' });
+ expect(scene.state.kioskMode).toBe(undefined);
+ scene.urlSync?.updateFromUrl({ kiosk: '' });
+ expect(scene.state.kioskMode).toBe(KioskMode.Full);
+ scene.urlSync?.updateFromUrl({ kiosk: 'tv' });
+ expect(scene.state.kioskMode).toBe(KioskMode.TV);
+ scene.urlSync?.updateFromUrl({ kiosk: 'true' });
+ expect(scene.state.kioskMode).toBe(KioskMode.Full);
+ });
+
+ it('Should get the kiosk mode from the scene state', () => {
+ const scene = buildTestScene();
+
+ expect(scene.urlSync?.getUrlState().kiosk).toBe(undefined);
+ scene.setState({ kioskMode: KioskMode.TV });
+ expect(scene.urlSync?.getUrlState().kiosk).toBe(KioskMode.TV);
+ scene.setState({ kioskMode: KioskMode.Full });
+ expect(scene.urlSync?.getUrlState().kiosk).toBe('');
+ });
});
describe('entering edit mode', () => {
diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts
index 49fbd51be06..fb891974cfe 100644
--- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts
+++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts
@@ -11,6 +11,7 @@ import {
VizPanel,
} from '@grafana/scenes';
import appEvents from 'app/core/app_events';
+import { KioskMode } from 'app/types';
import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer';
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
@@ -28,7 +29,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
constructor(private _scene: DashboardScene) {}
getKeys(): string[] {
- return ['inspect', 'viewPanel', 'editPanel', 'editview', 'autofitpanels'];
+ return ['inspect', 'viewPanel', 'editPanel', 'editview', 'autofitpanels', 'kiosk'];
}
getUrlState(): SceneObjectUrlValues {
@@ -39,6 +40,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
viewPanel: state.viewPanelScene?.getUrlKey(),
editview: state.editview?.getUrlKey(),
editPanel: state.editPanel?.getUrlKey() || undefined,
+ kiosk: state.kioskMode === KioskMode.Full ? '' : state.kioskMode === KioskMode.TV ? 'tv' : undefined,
};
}
@@ -159,6 +161,14 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
}
}
+ if (typeof values.kiosk === 'string') {
+ if (values.kiosk === 'true' || values.kiosk === '') {
+ update.kioskMode = KioskMode.Full;
+ } else if (values.kiosk === 'tv') {
+ update.kioskMode = KioskMode.TV;
+ }
+ }
+
if (Object.keys(update).length > 0) {
this._scene.setState(update);
}
diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
index 922d2d6543b..18a6a336ccf 100644
--- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
+++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
@@ -566,7 +566,12 @@ export function ToolbarActions({ dashboard }: Props) {
Save dashboard
-
+
);
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx
index 72b6c60055a..40eb79ade3a 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx
+++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesDashboardsScene.tsx
@@ -2,17 +2,18 @@ import { css } from '@emotion/css';
import React from 'react';
import { Link } from 'react-router-dom';
-import { GrafanaTheme2, Scope, ScopeDashboardBinding, urlUtil } from '@grafana/data';
+import { GrafanaTheme2, Scope, urlUtil } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { CustomScrollbar, Icon, Input, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { t } from 'app/core/internationalization';
-import { fetchDashboards } from './api';
+import { fetchSuggestedDashboards } from './api';
+import { SuggestedDashboard } from './types';
export interface ScopesDashboardsSceneState extends SceneObjectState {
- dashboards: ScopeDashboardBinding[];
- filteredDashboards: ScopeDashboardBinding[];
+ dashboards: SuggestedDashboard[];
+ filteredDashboards: SuggestedDashboard[];
isLoading: boolean;
searchQuery: string;
}
@@ -36,7 +37,7 @@ export class ScopesDashboardsScene extends SceneObjectBase
- dashboardTitle.toLowerCase().includes(lowerCasedSearchQuery)
- );
+ return dashboards.filter(({ dashboardTitle }) => dashboardTitle.toLowerCase().includes(lowerCasedSearchQuery));
}
}
@@ -74,7 +73,7 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps
}
- placeholder={t('scopes.suggestedDashboards.search', 'Filter')}
+ placeholder={t('scopes.suggestedDashboards.search', 'Search')}
disabled={isLoading}
data-testid="scopes-dashboards-search"
onChange={(evt) => model.changeSearchQuery(evt.currentTarget.value)}
@@ -89,7 +88,7 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps
) : (
- {filteredDashboards.map(({ spec: { dashboard, dashboardTitle } }) => (
+ {filteredDashboards.map(({ dashboard, dashboardTitle }) => (
},
loadingNodeName: undefined,
scopes: [],
- dirtyScopeNames: [],
+ treeScopes: [],
isLoadingScopes: false,
isOpened: false,
});
@@ -72,14 +73,16 @@ export class ScopesFiltersScene extends SceneObjectBase
}
public getUrlState() {
- return { scopes: this.getScopeNames() };
+ return {
+ scopes: this.state.scopes.map(({ scope }) => scope.metadata.name),
+ };
}
public updateFromUrl(values: SceneObjectUrlValues) {
- let dirtyScopeNames = values.scopes ?? [];
- dirtyScopeNames = Array.isArray(dirtyScopeNames) ? dirtyScopeNames : [dirtyScopeNames];
+ let scopeNames = values.scopes ?? [];
+ scopeNames = Array.isArray(scopeNames) ? scopeNames : [scopeNames];
- this.updateScopes(dirtyScopeNames);
+ this.updateScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] })));
}
public fetchBaseNodes() {
@@ -126,7 +129,7 @@ export class ScopesFiltersScene extends SceneObjectBase
}
public toggleNodeSelect(path: string[]) {
- let dirtyScopeNames = [...this.state.dirtyScopeNames];
+ let treeScopes = [...this.state.treeScopes];
let siblings = this.state.nodes;
@@ -134,22 +137,27 @@ export class ScopesFiltersScene extends SceneObjectBase
siblings = siblings[path[idx]].nodes;
}
- const name = path[path.length - 1];
- const { linkId } = siblings[name];
+ const nodeName = path[path.length - 1];
+ const { linkId } = siblings[nodeName];
- const selectedIdx = dirtyScopeNames.findIndex((scopeName) => scopeName === linkId);
+ const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId);
if (selectedIdx === -1) {
fetchScope(linkId!);
const selectedFromSameNode =
- dirtyScopeNames.length === 0 || Object.values(siblings).some(({ linkId }) => linkId === dirtyScopeNames[0]);
+ treeScopes.length === 0 || Object.values(siblings).some(({ linkId }) => linkId === treeScopes[0].scopeName);
- this.setState({ dirtyScopeNames: !selectedFromSameNode ? [linkId!] : [...dirtyScopeNames, linkId!] });
+ const treeScope = {
+ scopeName: linkId!,
+ path,
+ };
+
+ this.setState({ treeScopes: !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope] });
} else {
- dirtyScopeNames.splice(selectedIdx, 1);
+ treeScopes.splice(selectedIdx, 1);
- this.setState({ dirtyScopeNames });
+ this.setState({ treeScopes });
}
}
@@ -164,62 +172,53 @@ export class ScopesFiltersScene extends SceneObjectBase
}
public getSelectedScopes(): Scope[] {
- return this.state.scopes;
+ return this.state.scopes.map(({ scope }) => scope);
}
- public async updateScopes(dirtyScopeNames = this.state.dirtyScopeNames) {
- if (isEqual(dirtyScopeNames, this.getScopeNames())) {
+ public async updateScopes(treeScopes = this.state.treeScopes) {
+ if (isEqual(treeScopes, this.getTreeScopes())) {
return;
}
- this.setState({ dirtyScopeNames, isLoadingScopes: true });
+ this.setState({ treeScopes, isLoadingScopes: true });
- this.setState({ scopes: await fetchScopes(dirtyScopeNames), isLoadingScopes: false });
+ this.setState({ scopes: await fetchSelectedScopes(treeScopes), isLoadingScopes: false });
}
public resetDirtyScopeNames() {
- this.setState({ dirtyScopeNames: this.getScopeNames() });
+ this.setState({ treeScopes: this.getTreeScopes() });
}
public removeAllScopes() {
- this.setState({ scopes: [], dirtyScopeNames: [], isLoadingScopes: false });
+ this.setState({ scopes: [], treeScopes: [], isLoadingScopes: false });
}
public enterViewMode() {
this.setState({ isOpened: false });
}
- private getScopeNames(): string[] {
- return this.state.scopes.map(({ metadata: { name } }) => name);
+ private getTreeScopes(): TreeScope[] {
+ return this.state.scopes.map(({ scope, path }) => ({
+ scopeName: scope.metadata.name,
+ path,
+ }));
}
}
export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps) {
const styles = useStyles2(getStyles);
- const { nodes, loadingNodeName, dirtyScopeNames, isLoadingScopes, isOpened, scopes } = model.useState();
+ const { nodes, loadingNodeName, treeScopes, isLoadingScopes, isOpened, scopes } = model.useState();
const { isViewing } = model.scopesParent.useState();
- const scopesTitles = scopes.map(({ spec: { title } }) => title).join(', ');
-
return (
<>
- 0 && !isViewing ? (
- model.removeAllScopes()}
- />
- ) : undefined
- }
- onClick={() => model.open()}
+ model.open()}
+ onRemoveAllClick={() => model.removeAllScopes()}
/>
{isOpened && (
@@ -238,7 +237,7 @@ export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps model.updateNode(path, isExpanded, query)}
onNodeSelectToggle={(path) => model.toggleNodeSelect(path)}
/>
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx
new file mode 100644
index 00000000000..b44d0ee4d11
--- /dev/null
+++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx
@@ -0,0 +1,119 @@
+import { css } from '@emotion/css';
+import { groupBy } from 'lodash';
+import React, { useMemo } from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { IconButton, Input, Tooltip } from '@grafana/ui';
+import { useStyles2 } from '@grafana/ui/';
+import { t } from 'app/core/internationalization';
+
+import { NodesMap, SelectedScope } from './types';
+
+export interface ScopesInputProps {
+ nodes: NodesMap;
+ scopes: SelectedScope[];
+ isDisabled: boolean;
+ isLoading: boolean;
+ onInputClick: () => void;
+ onRemoveAllClick: () => void;
+}
+
+export function ScopesInput({
+ nodes,
+ scopes,
+ isDisabled,
+ isLoading,
+ onInputClick,
+ onRemoveAllClick,
+}: ScopesInputProps) {
+ const styles = useStyles2(getStyles);
+
+ const scopesPaths = useMemo(() => {
+ const pathsTitles = scopes.map(({ scope, path }) => {
+ let currentLevel = nodes;
+
+ let titles: string[];
+
+ if (path.length > 0) {
+ titles = path.map((nodeName) => {
+ const { title, nodes } = currentLevel[nodeName];
+
+ currentLevel = nodes;
+
+ return title;
+ });
+
+ if (titles[0] === '') {
+ titles.splice(0, 1);
+ }
+ } else {
+ titles = [scope.spec.title];
+ }
+
+ const scopeName = titles.pop();
+
+ return [titles.join(' > '), scopeName];
+ });
+
+ const groupedByPath = groupBy(pathsTitles, ([path]) => path);
+
+ return Object.entries(groupedByPath)
+ .map(([path, pathScopes]) => {
+ const scopesTitles = pathScopes.map(([, scopeTitle]) => scopeTitle).join(', ');
+
+ return (path ? [path, scopesTitles] : [scopesTitles]).join(' > ');
+ })
+ .map((path) => (
+
+ {path}
+
+ ));
+ }, [nodes, scopes, styles]);
+
+ const scopesTitles = useMemo(() => scopes.map(({ scope }) => scope.spec.title).join(', '), [scopes]);
+
+ const input = (
+ 0 && !isDisabled ? (
+ onRemoveAllClick()}
+ />
+ ) : undefined
+ }
+ onClick={() => {
+ if (!isDisabled) {
+ onInputClick();
+ }
+ }}
+ />
+ );
+
+ if (scopes.length === 0) {
+ return input;
+ }
+
+ return (
+ {scopesPaths}>} interactive={true}>
+ {input}
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ scopePath: css({
+ color: theme.colors.text.primary,
+ fontSize: theme.typography.pxToRem(14),
+ margin: theme.spacing(1, 0),
+ }),
+ };
+};
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx
index e52e3fa0b24..2c8975b4d0e 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx
+++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx
@@ -9,12 +9,14 @@ import { ScopesFiltersScene } from './ScopesFiltersScene';
import { ScopesScene } from './ScopesScene';
import {
buildTestScene,
- fetchDashboardsSpy,
+ fetchSuggestedDashboardsSpy,
fetchNodesSpy,
fetchScopeSpy,
- fetchScopesSpy,
+ fetchSelectedScopesSpy,
getApplicationsClustersExpand,
getApplicationsClustersSelect,
+ getApplicationsClustersSlothClusterNorthSelect,
+ getApplicationsClustersSlothClusterSouthSelect,
getApplicationsExpand,
getApplicationsSearch,
getApplicationsSlothPictureFactorySelect,
@@ -34,6 +36,7 @@ import {
mocksNodes,
mocksScopeDashboardBindings,
mocksScopes,
+ queryAllDashboard,
queryFiltersApply,
queryApplicationsClustersSlothClusterNorthTitle,
queryApplicationsClustersTitle,
@@ -104,8 +107,8 @@ describe('ScopesScene', () => {
fetchNodesSpy.mockClear();
fetchScopeSpy.mockClear();
- fetchScopesSpy.mockClear();
- fetchDashboardsSpy.mockClear();
+ fetchSelectedScopesSpy.mockClear();
+ fetchSuggestedDashboardsSpy.mockClear();
dashboardScene = buildTestScene();
scopesScene = dashboardScene.state.scopes!;
@@ -134,7 +137,12 @@ describe('ScopesScene', () => {
});
it('Selects the proper scopes', async () => {
- await act(async () => filtersScene.updateScopes(['slothPictureFactory', 'slothVoteTracker']));
+ await act(async () =>
+ filtersScene.updateScopes([
+ { scopeName: 'slothPictureFactory', path: [] },
+ { scopeName: 'slothVoteTracker', path: [] },
+ ])
+ );
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
expect(getApplicationsSlothVoteTrackerSelect()).toBeChecked();
@@ -203,7 +211,7 @@ describe('ScopesScene', () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getClustersSelect());
await userEvents.click(getFiltersApply());
- await waitFor(() => expect(fetchScopesSpy).toHaveBeenCalled());
+ await waitFor(() => expect(fetchSelectedScopesSpy).toHaveBeenCalled());
expect(filtersScene.getSelectedScopes()).toEqual(
mocksScopes.filter(({ metadata: { name } }) => name === 'indexHelperCluster')
);
@@ -213,7 +221,7 @@ describe('ScopesScene', () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getClustersSelect());
await userEvents.click(getFiltersCancel());
- await waitFor(() => expect(fetchScopesSpy).not.toHaveBeenCalled());
+ await waitFor(() => expect(fetchSelectedScopesSpy).not.toHaveBeenCalled());
expect(filtersScene.getSelectedScopes()).toEqual([]);
});
@@ -236,7 +244,7 @@ describe('ScopesScene', () => {
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
- await waitFor(() => expect(fetchDashboardsSpy).not.toHaveBeenCalled());
+ await waitFor(() => expect(fetchSuggestedDashboardsSpy).not.toHaveBeenCalled());
});
it('Fetches dashboards list when the list is expanded', async () => {
@@ -245,7 +253,7 @@ describe('ScopesScene', () => {
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
- await waitFor(() => expect(fetchDashboardsSpy).toHaveBeenCalled());
+ await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled());
});
it('Fetches dashboards list when the list is expanded after scope selection', async () => {
@@ -254,7 +262,7 @@ describe('ScopesScene', () => {
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
await userEvents.click(getDashboardsExpand());
- await waitFor(() => expect(fetchDashboardsSpy).toHaveBeenCalled());
+ await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled());
});
it('Shows dashboards for multiple scopes', async () => {
@@ -294,6 +302,20 @@ describe('ScopesScene', () => {
await userEvents.type(getDashboardsSearch(), '1');
expect(queryDashboard('2')).not.toBeInTheDocument();
});
+
+ it('Deduplicates the dashboards list', async () => {
+ await userEvents.click(getDashboardsExpand());
+ await userEvents.click(getFiltersInput());
+ await userEvents.click(getApplicationsExpand());
+ await userEvents.click(getApplicationsClustersExpand());
+ await userEvents.click(getApplicationsClustersSlothClusterNorthSelect());
+ await userEvents.click(getApplicationsClustersSlothClusterSouthSelect());
+ await userEvents.click(getFiltersApply());
+ expect(queryAllDashboard('5')).toHaveLength(1);
+ expect(queryAllDashboard('6')).toHaveLength(1);
+ expect(queryAllDashboard('7')).toHaveLength(1);
+ expect(queryAllDashboard('8')).toHaveLength(1);
+ });
});
describe('View mode', () => {
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx
index 62e59eb4a3a..a7f991913c5 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx
+++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx
@@ -32,7 +32,7 @@ export class ScopesScene extends SceneObjectBase {
this.state.filters.subscribeToState((newState, prevState) => {
if (newState.scopes !== prevState.scopes) {
if (this.state.isExpanded) {
- this.state.dashboards.fetchDashboards(newState.scopes);
+ this.state.dashboards.fetchDashboards(this.state.filters.getSelectedScopes());
}
sceneGraph.getTimeRange(this.parent!).onRefresh();
diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx
index 06566d270bc..787e089448c 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx
+++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx
@@ -5,15 +5,15 @@ import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { Checkbox, Icon, IconButton, Input, useStyles2 } from '@grafana/ui';
-import { t } from 'app/core/internationalization';
+import { t, Trans } from 'app/core/internationalization';
-import { NodesMap } from './types';
+import { NodesMap, TreeScope } from './types';
export interface ScopesTreeLevelProps {
nodes: NodesMap;
nodePath: string[];
loadingNodeName: string | undefined;
- scopeNames: string[];
+ scopes: TreeScope[];
onNodeUpdate: (path: string[], isExpanded: boolean, query: string) => void;
onNodeSelectToggle: (path: string[]) => void;
}
@@ -22,7 +22,7 @@ export function ScopesTreeLevel({
nodes,
nodePath,
loadingNodeName,
- scopeNames,
+ scopes,
onNodeUpdate,
onNodeSelectToggle,
}: ScopesTreeLevelProps) {
@@ -34,6 +34,7 @@ export function ScopesTreeLevel({
const childNodesArr = Object.values(childNodes);
const isNodeLoading = loadingNodeName === nodeId;
+ const scopeNames = scopes.map(({ scopeName }) => scopeName);
const anyChildExpanded = childNodesArr.some(({ isExpanded }) => isExpanded);
const anyChildSelected = childNodesArr.some(({ linkId }) => linkId && scopeNames.includes(linkId!));
@@ -45,13 +46,19 @@ export function ScopesTreeLevel({
}
className={styles.searchInput}
- placeholder={t('scopes.tree.search', 'Filter')}
+ placeholder={t('scopes.tree.search', 'Search')}
defaultValue={node.query}
data-testid={`scopes-tree-${nodeId}-search`}
onInput={(evt) => onQueryUpdate(nodePath, true, evt.currentTarget.value)}
/>
)}
+ {!anyChildExpanded && !node.query && (
+
+ Recommended
+
+ )}
+
{isNodeLoading &&
}
@@ -102,7 +109,7 @@ export function ScopesTreeLevel({
nodes={node.nodes}
nodePath={childNodePath}
loadingNodeName={loadingNodeName}
- scopeNames={scopeNames}
+ scopes={scopes}
onNodeUpdate={onNodeUpdate}
onNodeSelectToggle={onNodeSelectToggle}
/>
@@ -121,6 +128,10 @@ const getStyles = (theme: GrafanaTheme2) => {
searchInput: css({
margin: theme.spacing(1, 0),
}),
+ headline: css({
+ color: theme.colors.text.secondary,
+ margin: theme.spacing(1, 0),
+ }),
loader: css({
margin: theme.spacing(0.5, 0),
}),
diff --git a/public/app/features/dashboard-scene/scene/Scopes/api.ts b/public/app/features/dashboard-scene/scene/Scopes/api.ts
index 43e1e7931ed..71ec07cec19 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/api.ts
+++ b/public/app/features/dashboard-scene/scene/Scopes/api.ts
@@ -1,7 +1,8 @@
import { Scope, ScopeSpec, ScopeNode, ScopeDashboardBinding } from '@grafana/data';
import { config, getBackendSrv } from '@grafana/runtime';
import { ScopedResourceClient } from 'app/features/apiserver/client';
-import { NodesMap } from 'app/features/dashboard-scene/scene/Scopes/types';
+
+import { NodesMap, SelectedScope, SuggestedDashboard, TreeScope } from './types';
const group = 'scope.grafana.app';
const version = 'v0alpha1';
@@ -90,6 +91,19 @@ export async function fetchScopes(names: string[]): Promise
{
return await Promise.all(names.map(fetchScope));
}
+export async function fetchSelectedScopes(treeScopes: TreeScope[]): Promise {
+ const scopes = await fetchScopes(treeScopes.map(({ scopeName }) => scopeName));
+
+ return scopes.reduce((acc, scope, idx) => {
+ acc.push({
+ scope,
+ path: treeScopes[idx].path,
+ });
+
+ return acc;
+ }, []);
+}
+
export async function fetchDashboards(scopes: Scope[]): Promise {
try {
const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(dashboardsEndpoint, {
@@ -101,3 +115,23 @@ export async function fetchDashboards(scopes: Scope[]): Promise {
+ const items = await fetchDashboards(scopes);
+
+ return Object.values(
+ items.reduce>((acc, item) => {
+ if (!acc[item.spec.dashboard]) {
+ acc[item.spec.dashboard] = {
+ dashboard: item.spec.dashboard,
+ dashboardTitle: item.spec.dashboardTitle,
+ items: [],
+ };
+ }
+
+ acc[item.spec.dashboard].items.push(item);
+
+ return acc;
+ }, {})
+ );
+}
diff --git a/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx b/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx
index f204bc9560f..7ca1fcaa8de 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx
+++ b/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx
@@ -89,6 +89,30 @@ export const mocksScopeDashboardBindings: ScopeDashboardBinding[] = [
metadata: { name: 'binding4' },
spec: { dashboard: '4', dashboardTitle: 'My Dashboard 4', scope: 'slothVoteTracker' },
},
+ {
+ metadata: { name: 'binding5' },
+ spec: { dashboard: '5', dashboardTitle: 'My Dashboard 5', scope: 'slothClusterNorth' },
+ },
+ {
+ metadata: { name: 'binding6' },
+ spec: { dashboard: '6', dashboardTitle: 'My Dashboard 6', scope: 'slothClusterNorth' },
+ },
+ {
+ metadata: { name: 'binding7' },
+ spec: { dashboard: '7', dashboardTitle: 'My Dashboard 7', scope: 'slothClusterNorth' },
+ },
+ {
+ metadata: { name: 'binding8' },
+ spec: { dashboard: '5', dashboardTitle: 'My Dashboard 5', scope: 'slothClusterSouth' },
+ },
+ {
+ metadata: { name: 'binding9' },
+ spec: { dashboard: '6', dashboardTitle: 'My Dashboard 6', scope: 'slothClusterSouth' },
+ },
+ {
+ metadata: { name: 'binding10' },
+ spec: { dashboard: '8', dashboardTitle: 'My Dashboard 8', scope: 'slothClusterSouth' },
+ },
] as const;
export const mocksNodes: Array = [
@@ -225,8 +249,8 @@ export const mocksNodes: Array = [
export const fetchNodesSpy = jest.spyOn(api, 'fetchNodes');
export const fetchScopeSpy = jest.spyOn(api, 'fetchScope');
-export const fetchScopesSpy = jest.spyOn(api, 'fetchScopes');
-export const fetchDashboardsSpy = jest.spyOn(api, 'fetchDashboards');
+export const fetchSelectedScopesSpy = jest.spyOn(api, 'fetchSelectedScopes');
+export const fetchSuggestedDashboardsSpy = jest.spyOn(api, 'fetchSuggestedDashboards');
const selectors = {
tree: {
@@ -261,6 +285,7 @@ export const getDashboardsExpand = () => screen.getByTestId(selectors.dashboards
export const queryDashboardsContainer = () => screen.queryByTestId(selectors.dashboards.container);
export const getDashboardsContainer = () => screen.getByTestId(selectors.dashboards.container);
export const getDashboardsSearch = () => screen.getByTestId(selectors.dashboards.search);
+export const queryAllDashboard = (uid: string) => screen.queryAllByTestId(selectors.dashboards.dashboard(uid));
export const queryDashboard = (uid: string) => screen.queryByTestId(selectors.dashboards.dashboard(uid));
export const getDashboard = (uid: string) => screen.getByTestId(selectors.dashboards.dashboard(uid));
@@ -281,6 +306,10 @@ export const getApplicationsClustersSelect = () => screen.getByTestId(selectors.
export const getApplicationsClustersExpand = () => screen.getByTestId(selectors.tree.expand('applications.clusters'));
export const queryApplicationsClustersSlothClusterNorthTitle = () =>
screen.queryByTestId(selectors.tree.title('applications.clusters-slothClusterNorth'));
+export const getApplicationsClustersSlothClusterNorthSelect = () =>
+ screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterNorth'));
+export const getApplicationsClustersSlothClusterSouthSelect = () =>
+ screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterSouth'));
export const getClustersSelect = () => screen.getByTestId(selectors.tree.select('clusters'));
export const getClustersExpand = () => screen.getByTestId(selectors.tree.expand('clusters'));
diff --git a/public/app/features/dashboard-scene/scene/Scopes/types.ts b/public/app/features/dashboard-scene/scene/Scopes/types.ts
index ed68e0c8ff6..5dc7cfc1e5d 100644
--- a/public/app/features/dashboard-scene/scene/Scopes/types.ts
+++ b/public/app/features/dashboard-scene/scene/Scopes/types.ts
@@ -1,4 +1,4 @@
-import { ScopeNodeSpec } from '@grafana/data';
+import { Scope, ScopeDashboardBinding, ScopeNodeSpec } from '@grafana/data';
export interface Node extends ScopeNodeSpec {
name: string;
@@ -10,3 +10,19 @@ export interface Node extends ScopeNodeSpec {
}
export type NodesMap = Record;
+
+export interface SelectedScope {
+ scope: Scope;
+ path: string[];
+}
+
+export interface TreeScope {
+ scopeName: string;
+ path: string[];
+}
+
+export interface SuggestedDashboard {
+ dashboard: string;
+ dashboardTitle: string;
+ items: ScopeDashboardBinding[];
+}
diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts
index c6c0b3941a9..e633b7fb0ce 100644
--- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts
@@ -346,7 +346,6 @@ export function panelRepeaterToPanels(
return [libraryVizPanelToPanel(repeater.state.body, { x, y, w, h })];
}
- // console.log('repeater.state', repeater.state);
if (repeater.state.repeatedPanels) {
const itemHeight = repeater.state.itemHeight ?? 10;
const rowCount = Math.ceil(repeater.state.repeatedPanels!.length / repeater.getMaxPerRow());
diff --git a/public/app/features/dashboard/state/analyticsProcessor.ts b/public/app/features/dashboard/state/analyticsProcessor.ts
index 416fda502ba..ce85f7d8e3d 100644
--- a/public/app/features/dashboard/state/analyticsProcessor.ts
+++ b/public/app/features/dashboard/state/analyticsProcessor.ts
@@ -2,7 +2,7 @@ import { reportMetaAnalytics, MetaAnalyticsEventName, DashboardViewEventPayload
import { DashboardModel } from './DashboardModel';
-export function emitDashboardViewEvent(dashboard: DashboardModel) {
+export function emitDashboardViewEvent(dashboard: Pick) {
const eventData: DashboardViewEventPayload = {
/** @deprecated */
dashboardId: dashboard.id,
diff --git a/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx b/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx
index d9dfdc14d9a..1b393b3b8f0 100644
--- a/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx
+++ b/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx
@@ -4,7 +4,7 @@ import { useAsync, useDebounce } from 'react-use';
import { FetchError, isFetchError } from '@grafana/runtime';
import { LibraryPanel } from '@grafana/schema/dist/esm/index.gen';
import { Button, Field, Input, Modal } from '@grafana/ui';
-import { OldFolderPicker } from 'app/core/components/Select/OldFolderPicker';
+import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { t, Trans } from 'app/core/internationalization';
import { PanelModel } from '../../../dashboard/state';
@@ -36,6 +36,7 @@ export const AddLibraryPanelContents = ({
const onCreate = useCallback(() => {
panel.libraryPanel = { uid: '', name: panelName };
+
saveLibraryPanel(panel, folderUid!).then((res: LibraryPanel | FetchError) => {
if (!isFetchError(res)) {
onDismiss?.();
@@ -84,9 +85,9 @@ export const AddLibraryPanelContents = ({
'Library panel permissions are derived from the folder permissions'
)}
>
- setFolderUid(uid)}
- initialFolderUid={initialFolderUid}
+ setFolderUid(uid)}
+ value={folderUid}
inputId="share-panel-library-panel-folder-picker"
/>
diff --git a/public/app/features/logs/components/LogRows.test.tsx b/public/app/features/logs/components/LogRows.test.tsx
index 5bf9fcb53da..0d6dce447d9 100644
--- a/public/app/features/logs/components/LogRows.test.tsx
+++ b/public/app/features/logs/components/LogRows.test.tsx
@@ -3,9 +3,9 @@ import userEvent from '@testing-library/user-event';
import { range } from 'lodash';
import React from 'react';
-import { CoreApp, LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
+import { LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
-import { LogRows, PREVIEW_LIMIT } from './LogRows';
+import { LogRows, PREVIEW_LIMIT, Props } from './LogRows';
import { createLogRow } from './__mocks__/logRow';
jest.mock('@grafana/runtime', () => ({
@@ -207,7 +207,7 @@ describe('LogRows', () => {
});
describe('Popover menu', () => {
- function setup(app = CoreApp.Explore) {
+ function setup(overrides: Partial = {}) {
const rows: LogRowModel[] = [createLogRow({ uid: '1' })];
return render(
{
displayedFields={[]}
onClickFilterOutString={() => {}}
onClickFilterString={() => {}}
- app={app}
+ {...overrides}
/>
);
}
@@ -232,6 +232,8 @@ describe('Popover menu', () => {
orgGetSelection = document.getSelection;
jest.spyOn(document, 'getSelection').mockReturnValue({
toString: () => 'selected log line',
+ removeAllRanges: () => {},
+ addRange: (range: Range) => {},
} as Selection);
});
afterAll(() => {
@@ -248,9 +250,30 @@ describe('Popover menu', () => {
expect(screen.getByText('Add as line contains filter')).toBeInTheDocument();
expect(screen.getByText('Add as line does not contain filter')).toBeInTheDocument();
});
- it('Does not appear outside Explore', async () => {
- setup(CoreApp.Unknown);
+ it('Does not appear when the props are not defined', async () => {
+ setup({
+ onClickFilterOutString: undefined,
+ onClickFilterString: undefined,
+ });
await userEvent.click(screen.getByText('log message 1'));
expect(screen.queryByText('Copy selection')).not.toBeInTheDocument();
});
+ it('Appears after selecting test', async () => {
+ const onClickFilterOutString = jest.fn();
+ const onClickFilterString = jest.fn();
+ setup({
+ onClickFilterOutString,
+ onClickFilterString,
+ });
+ await userEvent.click(screen.getByText('log message 1'));
+ expect(screen.getByText('Copy selection')).toBeInTheDocument();
+ await userEvent.click(screen.getByText('Add as line contains filter'));
+
+ await userEvent.click(screen.getByText('log message 1'));
+ expect(screen.getByText('Copy selection')).toBeInTheDocument();
+ await userEvent.click(screen.getByText('Add as line does not contain filter'));
+
+ expect(onClickFilterOutString).toHaveBeenCalledTimes(1);
+ expect(onClickFilterString).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/public/app/features/logs/components/LogRows.tsx b/public/app/features/logs/components/LogRows.tsx
index 41100a4c634..87306b91e2a 100644
--- a/public/app/features/logs/components/LogRows.tsx
+++ b/public/app/features/logs/components/LogRows.tsx
@@ -105,7 +105,7 @@ class UnThemedLogRows extends PureComponent {
};
popoverMenuSupported() {
- if (!config.featureToggles.logRowsPopoverMenu || this.props.app !== CoreApp.Explore) {
+ if (!config.featureToggles.logRowsPopoverMenu) {
return false;
}
return Boolean(this.props.onClickFilterOutString || this.props.onClickFilterString);
diff --git a/public/app/features/playlist/PlaylistSrv.ts b/public/app/features/playlist/PlaylistSrv.ts
index 8fd715dd8f4..c193de2a032 100644
--- a/public/app/features/playlist/PlaylistSrv.ts
+++ b/public/app/features/playlist/PlaylistSrv.ts
@@ -12,6 +12,9 @@ export const queryParamsToPreserve: { [key: string]: boolean } = {
kiosk: true,
autofitpanels: true,
orgId: true,
+ '_dash.hideTimePicker': true,
+ '_dash.hideVariables': true,
+ '_dash.hideLinks': true,
};
export interface PlaylistSrvState {
diff --git a/public/app/features/playlist/StartModal.tsx b/public/app/features/playlist/StartModal.tsx
index 01d63469b58..ba88d771026 100644
--- a/public/app/features/playlist/StartModal.tsx
+++ b/public/app/features/playlist/StartModal.tsx
@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import { SelectableValue, UrlQueryMap, urlUtil } from '@grafana/data';
-import { locationService } from '@grafana/runtime';
-import { Button, Checkbox, Field, FieldSet, Modal, RadioButtonGroup } from '@grafana/ui';
+import { config, locationService } from '@grafana/runtime';
+import { Box, Button, Checkbox, Field, FieldSet, Modal, RadioButtonGroup, Stack } from '@grafana/ui';
import { Playlist, PlaylistMode } from './types';
@@ -14,6 +14,9 @@ export interface Props {
export const StartModal = ({ playlist, onDismiss }: Props) => {
const [mode, setMode] = useState(false);
const [autoFit, setAutofit] = useState(false);
+ const [displayTimePicker, setDisplayTimePicker] = useState(true);
+ const [displayVariables, setDisplayVariables] = useState(true);
+ const [displayLinks, setDisplayLinks] = useState(true);
const modes: Array> = [
{ label: 'Normal', value: false },
@@ -29,6 +32,17 @@ export const StartModal = ({ playlist, onDismiss }: Props) => {
if (autoFit) {
params.autofitpanels = true;
}
+
+ if (!displayTimePicker) {
+ params['_dash.hideTimePicker'] = true;
+ }
+ if (!displayVariables) {
+ params['_dash.hideVariables'] = true;
+ }
+ if (!displayLinks) {
+ params['_dash.hideLinks'] = true;
+ }
+
locationService.push(urlUtil.renderUrl(`/playlists/play/${playlist.uid}`, params));
};
@@ -38,13 +52,41 @@ export const StartModal = ({ playlist, onDismiss }: Props) => {
- setAutofit(e.currentTarget.checked)}
- />
+
+ setAutofit(e.currentTarget.checked)}
+ />
+
+ {config.featureToggles.dashboardScene && (
+
+
+
+ setDisplayTimePicker(e.currentTarget.checked)}
+ />
+ setDisplayVariables(e.currentTarget.checked)}
+ />
+ setDisplayLinks(e.currentTarget.checked)}
+ />
+
+
+
+ )}