mirror of
https://github.com/grafana/grafana.git
synced 2024-11-25 18:30:41 -06:00
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:
parent
326455a9b8
commit
4e8ab0512c
83
public/app/features/playlist/PlaylistPage.test.tsx
Normal file
83
public/app/features/playlist/PlaylistPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
54
public/app/features/playlist/StartModal.tsx
Normal file
54
public/app/features/playlist/StartModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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)));
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user