PlayList: add delete functionality to remove playlist (#38120)

* WIP: add delete functionality to playlist

* fixes deleted item to be removed instantly without manual refresh

* update confirmModal to reference playlist name

* refactor confirmModal message to be clear enough

* WIP: some unit tests for the playlistPage

* added more tests and did some cleanup

* some code refactoring

* adds ability for user roles to control playlist delete

* some abstraction to cleanup code

* modified alert message for delete button to correspond with action

* tried a better approach to modify the alert message

* fixes playlist lookup on each render

* update handlers to not use anonymous function

* exposed getBackendSrv().get api to fetch all playlist

* used better naming convention

* removes unecessary async/await construct

* some code refactoring

* used the correct param structure
This commit is contained in:
Uchechukwu Obasi 2021-09-01 13:23:57 +01:00 committed by GitHub
parent 326455a9b8
commit 4e8ab0512c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 195 additions and 66 deletions

View File

@ -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(<PlaylistPage {...props} />);
}
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();
});
});
});

View File

@ -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<Props> = ({ navModel }) => {
export const PlaylistPage: FC<PlaylistPageProps> = ({ navModel }) => {
const [searchQuery, setSearchQuery] = useState('');
const [startPlaylist, setStartPlaylist] = useState<PlaylistDTO | undefined>();
const [playlistToDelete, setPlaylistToDelete] = useState<PlaylistDTO | undefined>();
const [forcePlaylistsFetch, setForcePlaylistsFetch] = useState(0);
const { value: playlists, loading } = useAsync(async () => {
return getBackendSrv().get('/api/playlists', { query: searchQuery }) as Promise<PlaylistDTO[]>;
});
return getAllPlaylist(searchQuery) as Promise<PlaylistDTO[]>;
}, [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 = (
<EmptyListCTA
@ -51,9 +64,19 @@ export const PlaylistPage: FC<Props> = ({ navModel }) => {
Start playlist
</Button>
{contextSrv.isEditor && (
<LinkButton key="edit" variant="secondary" href={`/playlists/edit/${playlist.id}`} icon="cog">
Edit playlist
</LinkButton>
<>
<LinkButton key="edit" variant="secondary" href={`/playlists/edit/${playlist.id}`} icon="cog">
Edit playlist
</LinkButton>
<Button
disabled={false}
onClick={() => setPlaylistToDelete({ id: playlist.id, name: playlist.name })}
icon="trash-alt"
variant="destructive"
>
Delete playlist
</Button>
</>
)}
</Card.Actions>
</Card>
@ -61,7 +84,6 @@ export const PlaylistPage: FC<Props> = ({ navModel }) => {
</>
);
}
return (
<Page navModel={navModel}>
<Page.Contents isLoading={loading}>
@ -73,6 +95,16 @@ export const PlaylistPage: FC<Props> = ({ navModel }) => {
/>
)}
{content}
{playlistToDelete && (
<ConfirmModal
title={playlistToDelete.name}
confirmText="delete"
body={`Are you sure you want to delete '${playlistToDelete.name}' playlist?`}
onConfirm={onDeletePlaylist}
isOpen={Boolean(playlistToDelete)}
onDismiss={onDismissDelete}
/>
)}
{startPlaylist && <StartModal playlist={startPlaylist} onDismiss={() => setStartPlaylist(undefined)} />}
</Page.Contents>
</Page>
@ -84,52 +116,3 @@ const mapStateToProps: MapStateToProps<ConnectedProps, {}, StoreState> = (state:
});
export default connect(mapStateToProps)(PlaylistPage);
export interface StartModalProps {
playlist: PlaylistDTO;
onDismiss: () => void;
}
export const StartModal: FC<StartModalProps> = ({ playlist, onDismiss }) => {
const [mode, setMode] = useState<any>(false);
const [autoFit, setAutofit] = useState(false);
const modes: Array<SelectableValue<any>> = [
{ 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 (
<Modal isOpen={true} icon="play" title="Start playlist" onDismiss={onDismiss}>
<VerticalGroup>
<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)}
/>
</VerticalGroup>
<Modal.ButtonRow>
<Button variant="primary" onClick={onStart}>
Start {playlist.name}
</Button>
</Modal.ButtonRow>
</Modal>
);
};

View File

@ -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<StartModalProps> = ({ playlist, onDismiss }) => {
const [mode, setMode] = useState<any>(false);
const [autoFit, setAutofit] = useState(false);
const modes: Array<SelectableValue<any>> = [
{ 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 (
<Modal isOpen={true} icon="play" title="Start playlist" onDismiss={onDismiss}>
<VerticalGroup>
<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)}
/>
</VerticalGroup>
<Modal.ButtonRow>
<Button variant="primary" onClick={onStart}>
Start {playlist.name}
</Button>
</Modal.ButtonRow>
</Modal>
);
};

View File

@ -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<Playlist> {
@ -18,10 +22,15 @@ export async function getPlaylist(id: number): Promise<Playlist> {
return result;
}
async function withErrorHandling(apiCall: () => Promise<void>) {
export async function getAllPlaylist(query: string): Promise<PlaylistDTO[]> {
const result: PlaylistDTO[] = await getBackendSrv().get('/api/playlists/', { query });
return result;
}
async function withErrorHandling(apiCall: () => Promise<void>, 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)));
}