From 0abe4fc709133521cae92ba044b641a6caebd150 Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Mon, 17 Jun 2024 17:58:48 +0200 Subject: [PATCH] Scenes: Be able to show/hide dashboard controls in Kiosk mode (#88920) --- .betterer.results | 3 + .../scene/DashboardControls.test.tsx | 159 ++++++++++++++++++ .../scene/DashboardControls.tsx | 60 ++++++- .../dashboard-scene/scene/DashboardScene.tsx | 4 +- .../scene/DashboardSceneRenderer.tsx | 16 +- .../scene/DashboardSceneUrlSync.test.ts | 24 +++ .../scene/DashboardSceneUrlSync.ts | 12 +- .../transformSceneToSaveModel.ts | 1 - public/app/features/playlist/PlaylistSrv.ts | 3 + public/app/features/playlist/StartModal.tsx | 60 ++++++- 10 files changed, 319 insertions(+), 23 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/DashboardControls.test.tsx diff --git a/.betterer.results b/.betterer.results index aec2e678222..d266b08b826 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2871,6 +2871,9 @@ exports[`better eslint`] = { "public/app/features/dashboard-scene/saving/shared.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], + "public/app/features/dashboard-scene/scene/DashboardControls.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.test.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.test.tsx new file mode 100644 index 00000000000..52b46d49c99 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/DashboardControls.test.tsx @@ -0,0 +1,159 @@ +import { render } from '@testing-library/react'; +import React from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { SceneDataLayerControls, SceneVariableSet, TextBoxVariable, VariableValueSelectors } from '@grafana/scenes'; + +import { DashboardControls, DashboardControlsState } from './DashboardControls'; +import { DashboardScene } from './DashboardScene'; + +describe('DashboardControls', () => { + describe('Given a standard scene', () => { + it('should initialize with default values', () => { + const scene = buildTestScene(); + expect(scene.state.variableControls).toEqual([]); + expect(scene.state.timePicker).toBeDefined(); + expect(scene.state.refreshPicker).toBeDefined(); + }); + + it('should return if time controls are hidden', () => { + const scene = buildTestScene({ hideTimeControls: false, hideVariableControls: false, hideLinksControls: false }); + expect(scene.hasControls()).toBeTruthy(); + scene.setState({ hideTimeControls: true }); + expect(scene.hasControls()).toBeTruthy(); + scene.setState({ hideVariableControls: true, hideLinksControls: true }); + expect(scene.hasControls()).toBeFalsy(); + }); + }); + + describe('Component', () => { + it('should render', () => { + const scene = buildTestScene(); + expect(() => { + render(); + }).not.toThrow(); + }); + + it('should render visible controls', async () => { + const scene = buildTestScene({ + variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()], + }); + const renderer = render(); + + expect(await renderer.findByTestId(selectors.pages.Dashboard.Controls)).toBeInTheDocument(); + expect(await renderer.findByTestId(selectors.components.DashboardLinks.container)).toBeInTheDocument(); + expect(await renderer.findByTestId(selectors.components.TimePicker.openButton)).toBeInTheDocument(); + expect(await renderer.findByTestId(selectors.components.RefreshPicker.runButtonV2)).toBeInTheDocument(); + expect(await renderer.findByTestId(selectors.pages.Dashboard.SubMenu.submenuItem)).toBeInTheDocument(); + }); + + it('should render with hidden controls', async () => { + const scene = buildTestScene({ + hideTimeControls: true, + hideVariableControls: true, + hideLinksControls: true, + variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()], + }); + const renderer = render(); + + expect(await renderer.queryByTestId(selectors.pages.Dashboard.Controls)).not.toBeInTheDocument(); + }); + }); + + describe('UrlSync', () => { + it('should return keys', () => { + const scene = buildTestScene(); + // @ts-expect-error + expect(scene._urlSync.getKeys()).toEqual(['_dash.hideTimePicker', '_dash.hideVariables', '_dash.hideLinks']); + }); + + it('should return url state', () => { + const scene = buildTestScene(); + expect(scene.getUrlState()).toEqual({ + '_dash.hideTimePicker': undefined, + '_dash.hideVariables': undefined, + '_dash.hideLinks': undefined, + }); + scene.setState({ + hideTimeControls: true, + hideVariableControls: true, + hideLinksControls: true, + }); + expect(scene.getUrlState()).toEqual({ + '_dash.hideTimePicker': 'true', + '_dash.hideVariables': 'true', + '_dash.hideLinks': 'true', + }); + }); + + it('should update from url', () => { + const scene = buildTestScene(); + scene.updateFromUrl({ + '_dash.hideTimePicker': 'true', + '_dash.hideVariables': 'true', + '_dash.hideLinks': 'true', + }); + expect(scene.state.hideTimeControls).toBeTruthy(); + expect(scene.state.hideVariableControls).toBeTruthy(); + expect(scene.state.hideLinksControls).toBeTruthy(); + scene.updateFromUrl({ + '_dash.hideTimePicker': '', + '_dash.hideVariables': '', + '_dash.hideLinks': '', + }); + expect(scene.state.hideTimeControls).toBeTruthy(); + expect(scene.state.hideVariableControls).toBeTruthy(); + expect(scene.state.hideLinksControls).toBeTruthy(); + scene.updateFromUrl({}); + expect(scene.state.hideTimeControls).toBeFalsy(); + expect(scene.state.hideVariableControls).toBeFalsy(); + expect(scene.state.hideLinksControls).toBeFalsy(); + }); + + it('should not call setState if no changes', () => { + const scene = buildTestScene(); + const setState = jest.spyOn(scene, 'setState'); + scene.updateFromUrl({}); + scene.updateFromUrl({}); + expect(setState).toHaveBeenCalledTimes(1); + }); + }); +}); + +function buildTestScene(state?: Partial): DashboardControls { + const variable = new TextBoxVariable({ + name: 'A', + label: 'A', + description: 'A', + type: 'textbox', + value: 'Text', + }); + const dashboard = new DashboardScene({ + uid: 'A', + links: [ + { + title: 'Link', + url: 'http://localhost:3000/$A', + type: 'link', + asDropdown: false, + icon: '', + includeVars: true, + keepTime: true, + tags: [], + targetBlank: false, + tooltip: 'Link', + }, + ], + $variables: new SceneVariableSet({ + variables: [variable], + }), + controls: new DashboardControls({ + ...state, + }), + }); + + dashboard.activate(); + variable.activate(); + + return dashboard.state.controls as DashboardControls; +} diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx index fb46c138d4a..39db075bc44 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx @@ -1,7 +1,7 @@ import { css, cx } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, VariableHide } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { SceneObjectState, @@ -12,6 +12,9 @@ import { SceneRefreshPicker, SceneDebugger, VariableDependencyConfig, + sceneGraph, + SceneObjectUrlSyncConfig, + SceneObjectUrlValues, } from '@grafana/scenes'; import { Box, Stack, useStyles2 } from '@grafana/ui'; @@ -20,12 +23,15 @@ import { getDashboardSceneFor } from '../utils/utils'; import { DashboardLinksControls } from './DashboardLinksControls'; -interface DashboardControlsState extends SceneObjectState { +export interface DashboardControlsState extends SceneObjectState { variableControls: SceneObject[]; timePicker: SceneTimePicker; refreshPicker: SceneRefreshPicker; hideTimeControls?: boolean; + hideVariableControls?: boolean; + hideLinksControls?: boolean; } + export class DashboardControls extends SceneObjectBase { static Component = DashboardControlsRenderer; @@ -33,6 +39,30 @@ export class DashboardControls extends SceneObjectBase { onAnyVariableChanged: this._onAnyVariableChanged.bind(this), }); + protected _urlSync = new SceneObjectUrlSyncConfig(this, { + keys: ['_dash.hideTimePicker', '_dash.hideVariables', '_dash.hideLinks'], + }); + + getUrlState() { + return { + '_dash.hideTimePicker': this.state.hideTimeControls ? 'true' : undefined, + '_dash.hideVariables': this.state.hideVariableControls ? 'true' : undefined, + '_dash.hideLinks': this.state.hideLinksControls ? 'true' : undefined, + }; + } + + updateFromUrl(values: SceneObjectUrlValues) { + const update: Partial = {}; + + update.hideTimeControls = values['_dash.hideTimePicker'] === 'true' || values['_dash.hideTimePicker'] === ''; + update.hideVariableControls = values['_dash.hideVariables'] === 'true' || values['_dash.hideVariables'] === ''; + update.hideLinksControls = values['_dash.hideLinks'] === 'true' || values['_dash.hideLinks'] === ''; + + if (Object.entries(update).some(([k, v]) => v !== this.state[k as keyof DashboardControlsState])) { + this.setState(update); + } + } + public constructor(state: Partial) { super({ variableControls: [], @@ -51,26 +81,42 @@ export class DashboardControls extends SceneObjectBase { this.forceRender(); } } + + public hasControls(): boolean { + const hasVariables = sceneGraph + .getVariables(this) + ?.state.variables.some((v) => v.state.hide !== VariableHide.hideVariable); + const hasAnnotations = sceneGraph.getDataLayers(this).some((d) => d.state.isEnabled && !d.state.isHidden); + const hasLinks = getDashboardSceneFor(this).state.links?.length > 0; + const hideLinks = this.state.hideLinksControls || !hasLinks; + const hideVariables = this.state.hideVariableControls || (!hasAnnotations && !hasVariables); + const hideTimePicker = this.state.hideTimeControls; + + return !(hideVariables && hideLinks && hideTimePicker); + } } function DashboardControlsRenderer({ model }: SceneComponentProps) { - const { variableControls, refreshPicker, timePicker, hideTimeControls } = model.useState(); + const { variableControls, refreshPicker, timePicker, hideTimeControls, hideVariableControls, hideLinksControls } = + model.useState(); const dashboard = getDashboardSceneFor(model); const { links, meta, editPanel } = dashboard.useState(); const styles = useStyles2(getStyles); const showDebugger = location.search.includes('scene-debugger'); + if (!model.hasControls()) { + return null; + } + return (
- {variableControls.map((c) => ( - - ))} + {!hideVariableControls && variableControls.map((c) => )} - {!editPanel && } + {!hideLinksControls && !editPanel && } {editPanel && } {!hideTimeControls && ( diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 13e6ec7dff7..83b0edbc9c3 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -35,7 +35,7 @@ import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { deleteDashboard } from 'app/features/manage-dashboards/state/actions'; import { VariablesChanged } from 'app/features/variables/types'; -import { DashboardDTO, DashboardMeta, SaveDashboardResponseDTO } from 'app/types'; +import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types'; import { ShowConfirmModalEvent } from 'app/types/events'; import { PanelEditor } from '../panel-edit/PanelEditor'; @@ -125,6 +125,8 @@ export interface DashboardSceneState extends SceneObjectState { isEmpty?: boolean; /** Scene object that handles the scopes selector */ scopes?: ScopesScene; + /** Kiosk mode */ + kioskMode?: KioskMode; } export class DashboardScene extends SceneObjectBase { diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx index 6e9a5d659bb..f8e77d21856 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx @@ -24,6 +24,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps; const withPanels = ( -
+
); @@ -49,14 +50,14 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps {scopes && } - {!isHomePage && controls && ( + {!isHomePage && controls && hasControls && (
@@ -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/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/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)} + /> + + + + )}