Playlist: run on Scenes (#83551)

* DashboardScene: Implement playlist controls

* Mock the runtime config properly

* PlaylistSrv: with state you can subscribe to (#83828)

---------

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Ivan Ortega Alba 2024-03-11 13:33:32 +01:00 committed by GitHub
parent 1ab8857e48
commit 4a81a0388b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 151 additions and 17 deletions

View File

@ -57,6 +57,11 @@ export const Pages = {
navV2: 'data-testid Dashboard navigation',
publicDashboardTag: 'data-testid public dashboard tag',
shareButton: 'data-testid share-button',
playlistControls: {
prev: 'data-testid playlist previous dashboard button',
stop: 'data-testid playlist stop dashboard button',
next: 'data-testid playlist next dashboard button',
},
},
SubMenu: {
submenu: 'Dashboard submenu',

View File

@ -14,6 +14,7 @@ jest.mock('@grafana/runtime', () => ({
post: postMock,
}),
config: {
...jest.requireActual('@grafana/runtime').config,
loginError: false,
buildInfo: {
version: 'v1.0',

View File

@ -12,6 +12,7 @@ jest.mock('@grafana/runtime', () => ({
post: postMock,
}),
config: {
...jest.requireActual('@grafana/runtime').config,
buildInfo: {
version: 'v1.0',
commit: '1',

View File

@ -9,6 +9,7 @@ import { createRootReducer } from './root';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
...jest.requireActual('@grafana/runtime').config,
bootData: {
navTree: [],
user: {},

View File

@ -13,6 +13,7 @@ jest.mock('@grafana/runtime', () => ({
getPanelPluginFromCache: jest.fn(() => pluginToLoad),
}),
config: {
...jest.requireActual('@grafana/runtime').config,
panels: {
text: {
skipDataQuery: true,

View File

@ -57,6 +57,15 @@ jest.mock('@grafana/runtime', () => ({
},
}));
jest.mock('app/features/playlist/PlaylistSrv', () => ({
...jest.requireActual('app/features/playlist/PlaylistSrv'),
playlistSrv: {
isPlaying: false,
next: jest.fn(),
prev: jest.fn(),
stop: jest.fn(),
},
}));
const worker = createWorker();
mockResultsOfDetectChangesWorker({ hasChanges: true, hasTimeChanges: false, hasVariableValueChanges: false });

View File

@ -109,6 +109,7 @@ export interface DashboardSceneState extends SceneObjectState {
overlay?: SceneObject;
/** True when a user copies a panel in the dashboard */
hasCopiedPanel?: boolean;
/** The dashboard doesn't have panels */
isEmpty?: boolean;
}

View File

@ -4,11 +4,26 @@ import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { selectors } from '@grafana/e2e-selectors';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
import { ToolbarActions } from './NavToolbarActions';
jest.mock('app/features/playlist/PlaylistSrv', () => ({
playlistSrv: {
useState: jest.fn().mockReturnValue({ isPlaying: false }),
setState: jest.fn(),
isPlaying: true,
start: jest.fn(),
next: jest.fn(),
prev: jest.fn(),
stop: jest.fn(),
},
}));
describe('NavToolbarActions', () => {
describe('Give an already saved dashboard', () => {
it('Should show correct buttons when not in editing', async () => {
@ -23,6 +38,44 @@ describe('NavToolbarActions', () => {
expect(await screen.findByText('Share')).toBeInTheDocument();
});
it('Should the correct buttons when playing a playlist', async () => {
jest.mocked(playlistSrv).useState.mockReturnValueOnce({ isPlaying: true });
setup();
expect(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.prev)).toBeInTheDocument();
expect(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.stop)).toBeInTheDocument();
expect(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.next)).toBeInTheDocument();
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
expect(screen.queryByText('Share')).not.toBeInTheDocument();
});
it('Should call the playlist srv when using playlist controls', async () => {
jest.mocked(playlistSrv).useState.mockReturnValueOnce({ isPlaying: true });
setup();
// Previous dashboard
expect(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.prev)).toBeInTheDocument();
await userEvent.click(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.prev));
expect(playlistSrv.prev).toHaveBeenCalledTimes(1);
// Next dashboard
expect(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.next)).toBeInTheDocument();
await userEvent.click(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.next));
expect(playlistSrv.next).toHaveBeenCalledTimes(1);
// Stop playlist
expect(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.stop)).toBeInTheDocument();
await userEvent.click(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.stop));
expect(playlistSrv.stop).toHaveBeenCalledTimes(1);
});
it('Should hide the playlist controls when it is not playing', async () => {
setup();
expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.prev)).not.toBeInTheDocument();
expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.stop)).not.toBeInTheDocument();
expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.next)).not.toBeInTheDocument();
});
it('Should show correct buttons when editing', async () => {
setup();
@ -36,6 +89,9 @@ describe('NavToolbarActions', () => {
expect(await screen.findByLabelText('Add library panel')).toBeInTheDocument();
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
expect(screen.queryByText('Share')).not.toBeInTheDocument();
expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.prev)).not.toBeInTheDocument();
expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.stop)).not.toBeInTheDocument();
expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.next)).not.toBeInTheDocument();
});
it('Should show correct buttons when in settings menu', async () => {
@ -46,6 +102,9 @@ describe('NavToolbarActions', () => {
expect(await screen.findByText('Save dashboard')).toBeInTheDocument();
expect(await screen.findByText('Back to dashboard')).toBeInTheDocument();
expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.prev)).not.toBeInTheDocument();
expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.stop)).not.toBeInTheDocument();
expect(screen.queryByText(selectors.pages.Dashboard.DashNav.playlistControls.next)).not.toBeInTheDocument();
});
});
});

View File

@ -2,13 +2,15 @@ import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { locationService } from '@grafana/runtime';
import { Button, ButtonGroup, Dropdown, Icon, Menu, ToolbarButton, ToolbarButtonRow, useStyles2 } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator';
import { contextSrv } from 'app/core/core';
import { t } from 'app/core/internationalization';
import { t, Trans } from 'app/core/internationalization';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { ShareModal } from '../sharing/ShareModal';
import { DashboardInteractions } from '../utils/interactions';
@ -47,6 +49,8 @@ export function ToolbarActions({ dashboard }: Props) {
editPanel,
hasCopiedPanel: copiedPanel,
} = dashboard.useState();
const { isPlaying } = playlistSrv.useState();
const canSaveAs = contextSrv.hasEditPermissionInFolders;
const toolbarActions: ToolbarAction[] = [];
const buttonWithExtraMargin = useStyles2(getStyles);
@ -170,6 +174,49 @@ export function ToolbarActions({ dashboard }: Props) {
),
});
toolbarActions.push({
group: 'playlist-actions',
condition: isPlaying && !editview && !isEditingPanel && !isEditing,
render: () => (
<ToolbarButton
key="play-list-prev"
data-testid={selectors.pages.Dashboard.DashNav.playlistControls.prev}
tooltip={t('dashboard.toolbar.playlist-previous', 'Go to previous dashboard')}
icon="backward"
onClick={() => playlistSrv.prev()}
/>
),
});
toolbarActions.push({
group: 'playlist-actions',
condition: isPlaying && !editview && !isEditingPanel && !isEditing,
render: () => (
<ToolbarButton
key="play-list-stop"
onClick={() => playlistSrv.stop()}
data-testid={selectors.pages.Dashboard.DashNav.playlistControls.stop}
>
<Trans i18nKey="dashboard.toolbar.playlist-stop">Stop playlist</Trans>
</ToolbarButton>
),
});
toolbarActions.push({
group: 'playlist-actions',
condition: isPlaying && !editview && !isEditingPanel && !isEditing,
render: () => (
<ToolbarButton
key="play-list-next"
data-testid={selectors.pages.Dashboard.DashNav.playlistControls.next}
tooltip={t('dashboard.toolbar.playlist-next', 'Go to next dashboard')}
icon="forward"
onClick={() => playlistSrv.next()}
narrow
/>
),
});
if (dynamicDashNavActions.left.length > 0 && !isEditingPanel) {
dynamicDashNavActions.left.map((action, index) => {
const props = { dashboard: getDashboardSrv().getCurrent()! };
@ -225,7 +272,7 @@ export function ToolbarActions({ dashboard }: Props) {
toolbarActions.push({
group: 'main-buttons',
condition: uid && !isEditing && !meta.isSnapshot,
condition: uid && !isEditing && !meta.isSnapshot && !isPlaying,
render: () => (
<Button
key="share-dashboard-button"
@ -245,7 +292,7 @@ export function ToolbarActions({ dashboard }: Props) {
toolbarActions.push({
group: 'main-buttons',
condition: !isEditing && dashboard.canEditDashboard() && !isViewingPanel && !isEditingPanel,
condition: !isEditing && dashboard.canEditDashboard() && !isViewingPanel && !isEditingPanel && !isPlaying,
render: () => (
<Button
onClick={() => {

View File

@ -42,6 +42,7 @@ jest.mock('@grafana/runtime', () => ({
return runRequestMock(ds, request);
},
config: {
...jest.requireActual('@grafana/runtime').config,
publicDashboardAccessToken: 'ac123',
},
}));

View File

@ -27,7 +27,6 @@ jest.mock('@grafana/runtime', () => ({
partial: jest.fn(),
},
reportInteraction: jest.fn(),
config: {},
}));
jest.mock('app/features/dashboard/utils/dashboard', () => ({

View File

@ -177,7 +177,7 @@ export const DashNav = React.memo<Props>((props) => {
};
const isPlaylistRunning = () => {
return playlistSrv.isPlaying;
return playlistSrv.state.isPlaying;
};
const renderLeftActions = () => {

View File

@ -22,7 +22,6 @@ jest.mock('@grafana/runtime', () => ({
partial: jest.fn(),
},
reportInteraction: jest.fn(),
config: {},
}));
jest.mock('app/features/dashboard/utils/dashboard', () => ({

View File

@ -97,7 +97,7 @@ async function fetchDashboard(
}
}
if (args.fixUrl && dashDTO.meta.url && !playlistSrv.isPlaying) {
if (args.fixUrl && dashDTO.meta.url && !playlistSrv.state.isPlaying) {
// check if the current url is correct (might be old slug)
const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url);
const currentPath = locationService.getLocation().pathname;

View File

@ -17,6 +17,7 @@ import { LibraryPanelsSearch, LibraryPanelsSearchProps } from './LibraryPanelsSe
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
...jest.requireActual('@grafana/runtime').config,
panels: {
timeseries: {
info: { logos: { small: '' } },

View File

@ -124,7 +124,7 @@ describe('PlaylistSrv', () => {
locationService.push('/datasources');
expect(srv.isPlaying).toBe(false);
expect(srv.state.isPlaying).toBe(false);
});
it('storeUpdated should not stop playlist when navigating to next dashboard', async () => {
@ -137,6 +137,6 @@ describe('PlaylistSrv', () => {
// eslint-disable-next-line
expect((srv as any).validPlaylistUrl).toBe('/url/to/bbb');
expect(srv.isPlaying).toBe(true);
expect(srv.state.isPlaying).toBe(true);
});
});

View File

@ -3,6 +3,7 @@ import { pickBy } from 'lodash';
import { locationUtil, urlUtil, rangeUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { getPlaylistAPI, loadDashboards } from './api';
import { PlaylistAPI } from './types';
@ -13,7 +14,11 @@ export const queryParamsToPreserve: { [key: string]: boolean } = {
orgId: true,
};
export class PlaylistSrv {
export interface PlaylistSrvState {
isPlaying: boolean;
}
export class PlaylistSrv extends StateManagerBase<PlaylistSrvState> {
private nextTimeoutId: ReturnType<typeof setTimeout> | undefined;
private urls: string[] = []; // the URLs we need to load
private index = 0;
@ -24,9 +29,9 @@ export class PlaylistSrv {
private locationListenerUnsub?: () => void;
private api: PlaylistAPI;
isPlaying = false;
public constructor() {
super({ isPlaying: false });
constructor() {
this.locationUpdated = this.locationUpdated.bind(this);
this.api = getPlaylistAPI();
}
@ -76,17 +81,19 @@ export class PlaylistSrv {
this.startUrl = window.location.href;
this.index = 0;
this.isPlaying = true;
this.setState({ isPlaying: true });
// setup location tracking
this.locationListenerUnsub = locationService.getHistory().listen(this.locationUpdated);
const urls: string[] = [];
let playlist = await this.api.getPlaylist(playlistUid);
if (!playlist.items?.length) {
// alert
return;
}
this.interval = rangeUtil.intervalToMs(playlist.interval);
const items = await loadDashboards(playlist.items);
@ -102,19 +109,21 @@ export class PlaylistSrv {
// alert... not found, etc
return;
}
this.urls = urls;
this.isPlaying = true;
this.setState({ isPlaying: true });
this.next();
return;
}
stop() {
if (!this.isPlaying) {
if (!this.state.isPlaying) {
return;
}
this.index = 0;
this.isPlaying = false;
this.setState({ isPlaying: false });
if (this.locationListenerUnsub) {
this.locationListenerUnsub();