diff --git a/public/app/features/playlist/PlaylistPage.test.tsx b/public/app/features/playlist/PlaylistPage.test.tsx new file mode 100644 index 00000000000..3937c8580c1 --- /dev/null +++ b/public/app/features/playlist/PlaylistPage.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { PlaylistPage, PlaylistPageProps } from './PlaylistPage'; +import { locationService } from '../../../../packages/grafana-runtime/src'; + +const fnMock = jest.fn(); + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => ({ + get: fnMock, + }), +})); + +jest.mock('app/core/services/context_srv', () => ({ + contextSrv: { + isEditor: true, + }, +})); + +function getTestContext(propOverrides?: object) { + const props: PlaylistPageProps = { + navModel: { + main: { + text: 'Playlist', + }, + node: { + text: 'playlist', + }, + }, + route: { + path: '/playlists', + component: jest.fn(), + }, + queryParams: { state: 'ok' }, + match: { params: { name: 'playlist', sourceName: 'test playlist' }, isExact: false, url: 'asdf', path: '' }, + history: locationService.getHistory(), + location: { pathname: '', hash: '', search: '', state: '' }, + }; + + Object.assign(props, propOverrides); + + return render(); +} + +describe('PlaylistPage', () => { + describe('when mounted without a playlist', () => { + it('page should load', () => { + fnMock.mockResolvedValue([]); + const { getByText } = getTestContext(); + expect(getByText(/loading/i)).toBeInTheDocument(); + }); + it('then show empty list', async () => { + const { getByText } = getTestContext(); + await waitFor(() => getByText('There are no playlists created yet')); + }); + }); + describe('when mounted with a playlist', () => { + it('page should load', () => { + fnMock.mockResolvedValue([ + { + id: 0, + name: 'A test playlist', + interval: '10m', + items: [ + { title: 'First item', type: 'dashboard_by_id', order: 1, value: '1' }, + { title: 'Middle item', type: 'dashboard_by_id', order: 2, value: '2' }, + { title: 'Last item', type: 'dashboard_by_tag', order: 2, value: 'Last item' }, + ], + }, + ]); + const { getByText } = getTestContext(); + expect(getByText(/loading/i)).toBeInTheDocument(); + }); + it('then playlist title and buttons should appear on the page', async () => { + const { getByRole, getByText } = getTestContext(); + await waitFor(() => getByText('A test playlist')); + expect(getByRole('button', { name: /Start playlist/i })).toBeInTheDocument(); + expect(getByRole('link', { name: /Edit playlist/i })).toBeInTheDocument(); + expect(getByRole('button', { name: /Delete playlist/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/playlist/PlaylistPage.tsx b/public/app/features/playlist/PlaylistPage.tsx index ccaae7c9cd8..69a11f8321f 100644 --- a/public/app/features/playlist/PlaylistPage.tsx +++ b/public/app/features/playlist/PlaylistPage.tsx @@ -1,32 +1,45 @@ import React, { FC, useState } from 'react'; import { connect, MapStateToProps } from 'react-redux'; -import { NavModel, SelectableValue, urlUtil } from '@grafana/data'; +import { NavModel } from '@grafana/data'; import Page from 'app/core/components/Page/Page'; import { StoreState } from 'app/types'; import { GrafanaRouteComponentProps } from '../../core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; import { useAsync } from 'react-use'; -import { getBackendSrv, locationService } from '@grafana/runtime'; import { PlaylistDTO } from './types'; -import { Button, Card, Checkbox, Field, LinkButton, Modal, RadioButtonGroup, VerticalGroup } from '@grafana/ui'; -import { contextSrv } from 'app/core/core'; +import { Button, Card, ConfirmModal, LinkButton } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; import PageActionBar from 'app/core/components/PageActionBar/PageActionBar'; import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA'; +import { deletePlaylist, getAllPlaylist } from './api'; +import { StartModal } from './StartModal'; interface ConnectedProps { navModel: NavModel; } -interface Props extends ConnectedProps, GrafanaRouteComponentProps {} +export interface PlaylistPageProps extends ConnectedProps, GrafanaRouteComponentProps {} -export const PlaylistPage: FC = ({ navModel }) => { +export const PlaylistPage: FC = ({ navModel }) => { const [searchQuery, setSearchQuery] = useState(''); const [startPlaylist, setStartPlaylist] = useState(); + const [playlistToDelete, setPlaylistToDelete] = useState(); + const [forcePlaylistsFetch, setForcePlaylistsFetch] = useState(0); const { value: playlists, loading } = useAsync(async () => { - return getBackendSrv().get('/api/playlists', { query: searchQuery }) as Promise; - }); + return getAllPlaylist(searchQuery) as Promise; + }, [forcePlaylistsFetch]); const hasPlaylists = playlists && playlists.length > 0; + const onDismissDelete = () => setPlaylistToDelete(undefined); + const onDeletePlaylist = () => { + if (!playlistToDelete) { + return; + } + deletePlaylist(playlistToDelete.id).finally(() => { + setForcePlaylistsFetch(forcePlaylistsFetch + 1); + setPlaylistToDelete(undefined); + }); + }; let content = ( = ({ navModel }) => { Start playlist {contextSrv.isEditor && ( - - Edit playlist - + <> + + Edit playlist + + + )} @@ -61,7 +84,6 @@ export const PlaylistPage: FC = ({ navModel }) => { ); } - return ( @@ -73,6 +95,16 @@ export const PlaylistPage: FC = ({ navModel }) => { /> )} {content} + {playlistToDelete && ( + + )} {startPlaylist && setStartPlaylist(undefined)} />} @@ -84,52 +116,3 @@ const mapStateToProps: MapStateToProps = (state: }); export default connect(mapStateToProps)(PlaylistPage); - -export interface StartModalProps { - playlist: PlaylistDTO; - onDismiss: () => void; -} - -export const StartModal: FC = ({ playlist, onDismiss }) => { - const [mode, setMode] = useState(false); - const [autoFit, setAutofit] = useState(false); - - const modes: Array> = [ - { label: 'Normal', value: false }, - { label: 'TV', value: 'tv' }, - { label: 'Kiosk', value: true }, - ]; - - const onStart = () => { - const params: any = {}; - if (mode) { - params.kiosk = mode; - } - if (autoFit) { - params.autofitpanels = true; - } - locationService.push(urlUtil.renderUrl(`/playlists/play/${playlist.id}`, params)); - }; - - return ( - - - - - - setAutofit(e.currentTarget.checked)} - /> - - - - - - ); -}; diff --git a/public/app/features/playlist/StartModal.tsx b/public/app/features/playlist/StartModal.tsx new file mode 100644 index 00000000000..371eecb8bec --- /dev/null +++ b/public/app/features/playlist/StartModal.tsx @@ -0,0 +1,54 @@ +import React, { FC, useState } from 'react'; +import { SelectableValue, urlUtil } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { PlaylistDTO } from './types'; +import { Button, Checkbox, Field, Modal, RadioButtonGroup, VerticalGroup } from '@grafana/ui'; + +export interface StartModalProps { + playlist: PlaylistDTO; + onDismiss: () => void; +} + +export const StartModal: FC = ({ playlist, onDismiss }) => { + const [mode, setMode] = useState(false); + const [autoFit, setAutofit] = useState(false); + + const modes: Array> = [ + { label: 'Normal', value: false }, + { label: 'TV', value: 'tv' }, + { label: 'Kiosk', value: true }, + ]; + + const onStart = () => { + const params: any = {}; + if (mode) { + params.kiosk = mode; + } + if (autoFit) { + params.autofitpanels = true; + } + locationService.push(urlUtil.renderUrl(`/playlists/play/${playlist.id}`, params)); + }; + + return ( + + + + + + setAutofit(e.currentTarget.checked)} + /> + + + + + + ); +}; diff --git a/public/app/features/playlist/api.ts b/public/app/features/playlist/api.ts index 2c588c10b4f..d7b6f2739a0 100644 --- a/public/app/features/playlist/api.ts +++ b/public/app/features/playlist/api.ts @@ -1,16 +1,20 @@ import { getBackendSrv } from '@grafana/runtime'; -import { Playlist } from './types'; +import { Playlist, PlaylistDTO } from './types'; import { dispatch } from '../../store/store'; import { notifyApp } from '../../core/actions'; import { createErrorNotification, createSuccessNotification } from '../../core/copy/appNotification'; export async function createPlaylist(playlist: Playlist) { - await withErrorHandling(async () => await getBackendSrv().post('/api/playlists', playlist)); + await withErrorHandling(() => getBackendSrv().post('/api/playlists', playlist)); } export async function updatePlaylist(id: number, playlist: Playlist) { - await withErrorHandling(async () => await getBackendSrv().put(`/api/playlists/${id}`, playlist)); + await withErrorHandling(() => getBackendSrv().put(`/api/playlists/${id}`, playlist)); +} + +export async function deletePlaylist(id: number) { + await withErrorHandling(() => getBackendSrv().delete(`/api/playlists/${id}`), 'Playlist deleted'); } export async function getPlaylist(id: number): Promise { @@ -18,10 +22,15 @@ export async function getPlaylist(id: number): Promise { return result; } -async function withErrorHandling(apiCall: () => Promise) { +export async function getAllPlaylist(query: string): Promise { + const result: PlaylistDTO[] = await getBackendSrv().get('/api/playlists/', { query }); + return result; +} + +async function withErrorHandling(apiCall: () => Promise, message = 'Playlist saved') { try { await apiCall(); - dispatch(notifyApp(createSuccessNotification('Playlist saved'))); + dispatch(notifyApp(createSuccessNotification(message))); } catch (e) { dispatch(notifyApp(createErrorNotification('Unable to save playlist', e))); }