Scenes: Be able to show/hide dashboard controls in Kiosk mode (#88920)

This commit is contained in:
Ivan Ortega Alba 2024-06-17 17:58:48 +02:00 committed by GitHub
parent 7bb883e375
commit 0abe4fc709
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 319 additions and 23 deletions

View File

@ -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 <Trans />", "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"]
],

View File

@ -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(<scene.Component model={scene} />);
}).not.toThrow();
});
it('should render visible controls', async () => {
const scene = buildTestScene({
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
});
const renderer = render(<scene.Component model={scene} />);
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(<scene.Component model={scene} />);
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<DashboardControlsState>): 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;
}

View File

@ -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<DashboardControlsState> {
static Component = DashboardControlsRenderer;
@ -33,6 +39,30 @@ export class DashboardControls extends SceneObjectBase<DashboardControlsState> {
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<DashboardControlsState> = {};
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<DashboardControlsState>) {
super({
variableControls: [],
@ -51,26 +81,42 @@ export class DashboardControls extends SceneObjectBase<DashboardControlsState> {
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<DashboardControls>) {
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 (
<div
data-testid={selectors.pages.Dashboard.Controls}
className={cx(styles.controls, meta.isEmbedded && styles.embedded)}
>
<Stack grow={1} wrap={'wrap'}>
{variableControls.map((c) => (
<c.Component model={c} key={c.state.key} />
))}
{!hideVariableControls && variableControls.map((c) => <c.Component model={c} key={c.state.key} />)}
<Box grow={1} />
{!editPanel && <DashboardLinksControls links={links} uid={dashboard.state.uid} />}
{!hideLinksControls && !editPanel && <DashboardLinksControls links={links} uid={dashboard.state.uid} />}
{editPanel && <PanelEditControls panelEditor={editPanel} />}
</Stack>
{!hideTimeControls && (

View File

@ -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<DashboardSceneState> {

View File

@ -24,6 +24,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
const bodyToRender = model.getBodyToRender();
const navModel = getNavModel(navIndex, 'dashboards/browse');
const isHomePage = !meta.url && !meta.slug && !meta.isNew && !meta.isSnapshot;
const hasControls = controls?.hasControls();
if (editview) {
return (
@ -37,7 +38,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
const emptyState = <DashboardEmpty dashboard={model} canCreate={!!model.state.meta.canEdit} />;
const withPanels = (
<div className={cx(styles.body)}>
<div className={cx(styles.body, !hasControls && styles.bodyWithoutControls)}>
<bodyToRender.Component model={bodyToRender} />
</div>
);
@ -49,14 +50,14 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
<div
className={cx(
styles.pageContainer,
controls && !scopes && styles.pageContainerWithControls,
hasControls && !scopes && styles.pageContainerWithControls,
scopes && styles.pageContainerWithScopes,
scopes && isScopesExpanded && styles.pageContainerWithScopesExpanded
)}
>
{scopes && <scopes.Component model={scopes} />}
<NavToolbarActions dashboard={model} />
{!isHomePage && controls && (
{!isHomePage && controls && hasControls && (
<div
className={cx(styles.controlsWrapper, scopes && !isScopesExpanded && styles.controlsWrapperWithScopes)}
>
@ -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),
}),
};
}

View File

@ -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', () => {

View File

@ -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);
}

View File

@ -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());

View File

@ -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 {

View File

@ -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<PlaylistMode>(false);
const [autoFit, setAutofit] = useState(false);
const [displayTimePicker, setDisplayTimePicker] = useState(true);
const [displayVariables, setDisplayVariables] = useState(true);
const [displayLinks, setDisplayLinks] = useState(true);
const modes: Array<SelectableValue<PlaylistMode>> = [
{ 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) => {
<Field label="Mode">
<RadioButtonGroup value={mode} options={modes} onChange={setMode} />
</Field>
<Checkbox
label="Autofit"
description="Panel heights will be adjusted to fit screen size"
name="autofix"
value={autoFit}
onChange={(e) => setAutofit(e.currentTarget.checked)}
/>
<Field>
<Checkbox
label="Autofit"
description="Panel heights will be adjusted to fit screen size"
name="autofix"
value={autoFit}
onChange={(e) => setAutofit(e.currentTarget.checked)}
/>
</Field>
{config.featureToggles.dashboardScene && (
<Field label="Display dashboard controls" description="Customize dashboard elements visibility">
<Box marginTop={2} marginBottom={2}>
<Stack direction="column" alignItems="start" justifyContent="left" gap={2}>
<Checkbox
label="Time and refresh"
name="displayTimePicker"
value={displayTimePicker}
onChange={(e) => setDisplayTimePicker(e.currentTarget.checked)}
/>
<Checkbox
label="Variables"
name="displayVariableControls"
value={displayVariables}
onChange={(e) => setDisplayVariables(e.currentTarget.checked)}
/>
<Checkbox
label="Dashboard links"
name="displayLinks"
value={displayLinks}
onChange={(e) => setDisplayLinks(e.currentTarget.checked)}
/>
</Stack>
</Box>
</Field>
)}
</FieldSet>
<Modal.ButtonRow>
<Button variant="primary" onClick={onStart}>