Playlist: Migrates New/Edit pages to React (#32218)

* WIP: initial commit

* Playlist: Migrates New/Edit to React

* Tests: adds tests for PlaylistForm

* Tests: adds more tests

* Chore: moved some styles

* Chore: updates after PR review
This commit is contained in:
Hugo Häggmark
2021-03-23 07:45:04 +01:00
committed by GitHub
parent 066c9c8ff4
commit 51e7b87f39
18 changed files with 900 additions and 9 deletions

View File

@@ -149,4 +149,14 @@ export const Pages = {
page: 'Plugin page',
signatureInfo: 'Plugin signature info',
},
PlaylistForm: {
name: 'Playlist name',
interval: 'Playlist interval',
itemRow: 'Playlist item row',
itemIdType: 'Playlist item dashboard by ID type',
itemTagType: 'Playlist item dashboard by Tag type',
itemMoveUp: 'Move playlist item order up',
itemMoveDown: 'Move playlist item order down',
itemDelete: 'Delete playlist item',
},
};

View File

@@ -4,10 +4,14 @@ import { SelectableValue } from '@grafana/data';
import { AsyncSelect } from '@grafana/ui';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardSearchHit } from 'app/features/search/types';
import { DashboardDTO } from 'app/types';
export interface DashboardPickerItem extends Pick<DashboardSearchHit, 'uid' | 'id'> {
value: number;
label: string;
}
export interface Props {
onChange: (dashboard: DashboardDTO) => void;
onChange: (dashboard: DashboardPickerItem) => void;
value?: SelectableValue;
width?: number;
isClearable?: boolean;

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Playlist } from './types';
import { PlaylistEditPage } from './PlaylistEditPage';
import { backendSrv } from 'app/core/services/backend_srv';
import userEvent from '@testing-library/user-event';
import { locationService } from '@grafana/runtime';
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as any),
getBackendSrv: () => backendSrv,
}));
async function getTestContext({ name, interval, items }: Partial<Playlist> = {}) {
jest.clearAllMocks();
const playlist = ({ name, items, interval } as unknown) as Playlist;
const queryParams = {};
const route: any = {};
const match: any = { params: { id: 1 } };
const location: any = {};
const history: any = {};
const navModel: any = {
node: {},
main: {},
};
const getMock = jest.spyOn(backendSrv, 'get');
const putMock = jest.spyOn(backendSrv, 'put');
getMock.mockResolvedValue({
name: 'Test Playlist',
interval: '5s',
items: [{ title: 'First item', type: 'dashboard_by_id', order: 1, value: '1' }],
});
const { rerender } = render(
<PlaylistEditPage
queryParams={queryParams}
route={route}
match={match}
location={location}
history={history}
navModel={navModel}
/>
);
await waitFor(() => expect(getMock).toHaveBeenCalledTimes(1));
return { playlist, rerender, putMock };
}
describe('PlaylistEditPage', () => {
describe('when mounted', () => {
it('then it should load playlist and header should be correct', async () => {
await getTestContext();
expect(screen.getByRole('heading', { name: /edit playlist/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /playlist name/i })).toHaveValue('Test Playlist');
expect(screen.getByRole('textbox', { name: /playlist interval/i })).toHaveValue('5s');
expect(screen.getAllByRole('row', { name: /playlist item row/i })).toHaveLength(1);
});
});
describe('when submitted', () => {
it('then correct api should be called', async () => {
const { putMock } = await getTestContext();
expect(locationService.getLocation().pathname).toEqual('/');
userEvent.clear(screen.getByRole('textbox', { name: /playlist name/i }));
userEvent.type(screen.getByRole('textbox', { name: /playlist name/i }), 'A Name');
userEvent.clear(screen.getByRole('textbox', { name: /playlist interval/i }));
userEvent.type(screen.getByRole('textbox', { name: /playlist interval/i }), '10s');
fireEvent.submit(screen.getByRole('button', { name: /save/i }));
await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1));
expect(putMock).toHaveBeenCalledWith('/api/playlists/1', {
name: 'A Name',
interval: '10s',
items: [{ title: 'First item', type: 'dashboard_by_id', order: 1, value: '1' }],
});
expect(locationService.getLocation().pathname).toEqual('/playlists');
});
});
});

View File

@@ -0,0 +1,55 @@
import React, { FC } from 'react';
import { connect, MapStateToProps } from 'react-redux';
import { NavModel } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { useStyles } from '@grafana/ui';
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 { PlaylistForm } from './PlaylistForm';
import { updatePlaylist } from './api';
import { Playlist } from './types';
import { usePlaylist } from './usePlaylist';
import { getPlaylistStyles } from './styles';
interface ConnectedProps {
navModel: NavModel;
}
export interface RouteParams {
id: number;
}
interface Props extends ConnectedProps, GrafanaRouteComponentProps<RouteParams> {}
export const PlaylistEditPage: FC<Props> = ({ navModel, match }) => {
const styles = useStyles(getPlaylistStyles);
const { playlist, loading } = usePlaylist(match.params.id);
const onSubmit = async (playlist: Playlist) => {
await updatePlaylist(match.params.id, playlist);
locationService.push('/playlists');
};
return (
<Page navModel={navModel}>
<Page.Contents isLoading={loading}>
<h3 className={styles.subHeading}>Edit Playlist</h3>
<p className={styles.description}>
A playlist rotates through a pre-selected list of Dashboards. A Playlist can be a great way to build
situational awareness, or just show off your metrics to your team or visitors.
</p>
<PlaylistForm onSubmit={onSubmit} playlist={playlist} />
</Page.Contents>
</Page>
);
};
const mapStateToProps: MapStateToProps<ConnectedProps, {}, StoreState> = (state: StoreState) => ({
navModel: getNavModel(state.navIndex, 'playlists'),
});
export default connect(mapStateToProps)(PlaylistEditPage);

View File

@@ -0,0 +1,192 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { within } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { Playlist } from './types';
import { PlaylistForm } from './PlaylistForm';
function getTestContext({ name, interval, items }: Partial<Playlist> = {}) {
const onSubmitMock = jest.fn();
const playlist = ({ name, items, interval } as unknown) as Playlist;
const { rerender } = render(<PlaylistForm onSubmit={onSubmitMock} playlist={playlist} />);
return { onSubmitMock, playlist, rerender };
}
const playlist: Playlist = {
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' },
],
};
function rows() {
return screen.getAllByRole('row', { name: /playlist item row/i });
}
describe('PlaylistForm', () => {
describe('when mounted without playlist', () => {
it('then it should contain name and interval fields', () => {
getTestContext();
expect(screen.getByRole('textbox', { name: /playlist name/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /playlist interval/i })).toBeInTheDocument();
expect(screen.queryByRole('row', { name: /playlist item row/i })).not.toBeInTheDocument();
});
it('then name field should have empty string as default value', () => {
getTestContext();
expect(screen.getByRole('textbox', { name: /playlist name/i })).toHaveValue('');
});
it('then interval field should have 5m as default value', () => {
getTestContext();
expect(screen.getByRole('textbox', { name: /playlist interval/i })).toHaveValue('5m');
});
});
describe('when mounted with a playlist', () => {
it('then name field should have correct value', () => {
getTestContext(playlist);
expect(screen.getByRole('textbox', { name: /playlist name/i })).toHaveValue('A test playlist');
});
it('then interval field should have correct value', () => {
getTestContext(playlist);
expect(screen.getByRole('textbox', { name: /playlist interval/i })).toHaveValue('10m');
});
it('then items row count should be correct', () => {
getTestContext(playlist);
expect(screen.getAllByRole('row', { name: /playlist item row/i })).toHaveLength(3);
});
it('then the first item row should be correct', () => {
getTestContext(playlist);
expectCorrectRow({ index: 0, type: 'id', title: 'first item', first: true });
});
it('then the middle item row should be correct', () => {
getTestContext(playlist);
expectCorrectRow({ index: 1, type: 'id', title: 'middle item' });
});
it('then the last item row should be correct', () => {
getTestContext(playlist);
expectCorrectRow({ index: 2, type: 'tag', title: 'last item', last: true });
});
});
describe('when deleting a playlist item', () => {
it('then the item should be removed and other items should be correct', () => {
getTestContext(playlist);
expect(rows()).toHaveLength(3);
userEvent.click(within(rows()[2]).getByRole('button', { name: /delete playlist item/i }));
expect(rows()).toHaveLength(2);
expectCorrectRow({ index: 0, type: 'id', title: 'first item', first: true });
expectCorrectRow({ index: 1, type: 'id', title: 'middle item', last: true });
});
});
describe('when moving a playlist item up', () => {
it('then the item should be removed and other items should be correct', () => {
getTestContext(playlist);
userEvent.click(within(rows()[2]).getByRole('button', { name: /move playlist item order up/i }));
expectCorrectRow({ index: 0, type: 'id', title: 'first item', first: true });
expectCorrectRow({ index: 1, type: 'tag', title: 'last item' });
expectCorrectRow({ index: 2, type: 'id', title: 'middle item', last: true });
});
});
describe('when moving a playlist item down', () => {
it('then the item should be removed and other items should be correct', () => {
getTestContext(playlist);
userEvent.click(within(rows()[0]).getByRole('button', { name: /move playlist item order down/i }));
expectCorrectRow({ index: 0, type: 'id', title: 'middle item', first: true });
expectCorrectRow({ index: 1, type: 'id', title: 'first item' });
expectCorrectRow({ index: 2, type: 'tag', title: 'last item', last: true });
});
});
describe('when submitting the form', () => {
it('then the correct item should be submitted', async () => {
const { onSubmitMock } = getTestContext(playlist);
fireEvent.submit(screen.getByRole('button', { name: /save/i }));
await waitFor(() => expect(onSubmitMock).toHaveBeenCalledTimes(1));
expect(onSubmitMock).toHaveBeenCalledWith(playlist);
});
describe('and name is missing', () => {
it('then an alert should appear and nothing should be submitted', async () => {
const { onSubmitMock } = getTestContext({ ...playlist, name: undefined });
fireEvent.submit(screen.getByRole('button', { name: /save/i }));
expect(await screen.findAllByRole('alert')).toHaveLength(1);
expect(onSubmitMock).not.toHaveBeenCalled();
});
});
describe('and interval is missing', () => {
it('then an alert should appear and nothing should be submitted', async () => {
const { onSubmitMock } = getTestContext(playlist);
userEvent.clear(screen.getByRole('textbox', { name: /playlist interval/i }));
fireEvent.submit(screen.getByRole('button', { name: /save/i }));
expect(await screen.findAllByRole('alert')).toHaveLength(1);
expect(onSubmitMock).not.toHaveBeenCalled();
});
});
});
describe('when items are missing', () => {
it('then save button is disabled', async () => {
getTestContext({ ...playlist, items: [] });
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
});
});
});
interface ExpectCorrectRowArgs {
index: number;
type: 'id' | 'tag';
title: string;
first?: boolean;
last?: boolean;
}
function expectCorrectRow({ index, type, title, first = false, last = false }: ExpectCorrectRowArgs) {
const row = within(rows()[index]);
const cell = `playlist item dashboard by ${type} type ${title}`;
const regex = new RegExp(cell, 'i');
expect(row.getByRole('cell', { name: regex })).toBeInTheDocument();
if (first) {
expect(row.queryByRole('button', { name: /move playlist item order up/i })).not.toBeInTheDocument();
} else {
expect(row.getByRole('button', { name: /move playlist item order up/i })).toBeInTheDocument();
}
if (last) {
expect(row.queryByRole('button', { name: /move playlist item order down/i })).not.toBeInTheDocument();
} else {
expect(row.getByRole('button', { name: /move playlist item order down/i })).toBeInTheDocument();
}
expect(row.getByRole('button', { name: /delete playlist item/i })).toBeInTheDocument();
}

View File

@@ -0,0 +1,86 @@
import React, { FC } from 'react';
import { config } from '@grafana/runtime';
import { Button, Field, Form, HorizontalGroup, Input, LinkButton } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { Playlist } from './types';
import { DashboardPicker } from '../../core/components/Select/DashboardPicker';
import { TagFilter } from '../../core/components/TagFilter/TagFilter';
import { SearchSrv } from '../../core/services/search_srv';
import { usePlaylistItems } from './usePlaylistItems';
import { PlaylistTable } from './PlaylistTable';
interface PlaylistFormProps {
onSubmit: (playlist: Playlist) => void;
playlist: Playlist;
}
const searchSrv = new SearchSrv();
export const PlaylistForm: FC<PlaylistFormProps> = ({ onSubmit, playlist }) => {
const { name, interval, items: propItems } = playlist;
const { items, addById, addByTag, deleteItem, moveDown, moveUp } = usePlaylistItems(propItems);
return (
<>
<Form onSubmit={(list: Playlist) => onSubmit({ ...list, items })} validateOn={'onBlur'}>
{({ register, errors }) => {
const isDisabled = items.length === 0 || Object.keys(errors).length > 0;
return (
<>
<Field label="Name" invalid={!!errors.name} error={errors?.name?.message}>
<Input
type="text"
name="name"
ref={register({ required: 'Name is required' })}
placeholder="Name"
defaultValue={name}
aria-label={selectors.pages.PlaylistForm.name}
/>
</Field>
<Field label="Interval" invalid={!!errors.interval} error={errors?.interval?.message}>
<Input
type="text"
name="interval"
ref={register({ required: 'Interval is required' })}
placeholder="5m"
defaultValue={interval ?? '5m'}
aria-label={selectors.pages.PlaylistForm.interval}
/>
</Field>
<PlaylistTable items={items} onMoveUp={moveUp} onMoveDown={moveDown} onDelete={deleteItem} />
<div className="gf-form-group">
<h3 className="page-headering">Add dashboards</h3>
<Field label="Add by title">
<DashboardPicker onChange={addById} isClearable />
</Field>
<Field label="Add by tag">
<TagFilter
isClearable
tags={[]}
hideValues
tagOptions={searchSrv.getDashboardTags}
onChange={addByTag}
placeholder={''}
/>
</Field>
</div>
<HorizontalGroup>
<Button variant="primary" disabled={isDisabled}>
Save
</Button>
<LinkButton variant="secondary" href={`${config.appSubUrl}/playlists`}>
Cancel
</LinkButton>
</HorizontalGroup>
</>
);
}}
</Form>
</>
);
};

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Playlist } from './types';
import { PlaylistNewPage } from './PlaylistNewPage';
import { backendSrv } from '../../core/services/backend_srv';
import { locationService } from '@grafana/runtime';
jest.mock('./usePlaylist', () => ({
// so we don't need to add dashboard items in test
usePlaylist: jest.fn().mockReturnValue({
playlist: { items: [{ title: 'First item', type: 'dashboard_by_id', order: 1, value: '1' }], loading: false },
}),
}));
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as any),
getBackendSrv: () => backendSrv,
}));
function getTestContext({ name, interval, items }: Partial<Playlist> = {}) {
jest.clearAllMocks();
const playlist = ({ name, items, interval } as unknown) as Playlist;
const queryParams = {};
const route: any = {};
const match: any = {};
const location: any = {};
const history: any = {};
const navModel: any = {
node: {},
main: {},
};
const backendSrvMock = jest.spyOn(backendSrv, 'post');
const { rerender } = render(
<PlaylistNewPage
queryParams={queryParams}
route={route}
match={match}
location={location}
history={history}
navModel={navModel}
/>
);
return { playlist, rerender, backendSrvMock };
}
describe('PlaylistNewPage', () => {
describe('when mounted', () => {
it('then header should be correct', () => {
getTestContext();
expect(screen.getByRole('heading', { name: /new playlist/i })).toBeInTheDocument();
});
});
describe('when submitted', () => {
it('then correct api should be called', async () => {
const { backendSrvMock } = getTestContext();
expect(locationService.getLocation().pathname).toEqual('/');
userEvent.type(screen.getByRole('textbox', { name: /playlist name/i }), 'A Name');
fireEvent.submit(screen.getByRole('button', { name: /save/i }));
await waitFor(() => expect(backendSrvMock).toHaveBeenCalledTimes(1));
expect(backendSrvMock).toHaveBeenCalledWith('/api/playlists', {
name: 'A Name',
interval: '5m',
items: [{ title: 'First item', type: 'dashboard_by_id', order: 1, value: '1' }],
});
expect(locationService.getLocation().pathname).toEqual('/playlists');
});
});
});

View File

@@ -0,0 +1,51 @@
import React, { FC } from 'react';
import { connect, MapStateToProps } from 'react-redux';
import { NavModel } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { useStyles } from '@grafana/ui';
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 { PlaylistForm } from './PlaylistForm';
import { createPlaylist } from './api';
import { Playlist } from './types';
import { usePlaylist } from './usePlaylist';
import { getPlaylistStyles } from './styles';
interface ConnectedProps {
navModel: NavModel;
}
interface Props extends ConnectedProps, GrafanaRouteComponentProps {}
export const PlaylistNewPage: FC<Props> = ({ navModel }) => {
const styles = useStyles(getPlaylistStyles);
const { playlist, loading } = usePlaylist();
const onSubmit = async (playlist: Playlist) => {
await createPlaylist(playlist);
locationService.push('/playlists');
};
return (
<Page navModel={navModel}>
<Page.Contents isLoading={loading}>
<h3 className={styles.subHeading}>New Playlist</h3>
<p className={styles.description}>
A playlist rotates through a pre-selected list of Dashboards. A Playlist can be a great way to build
situational awareness, or just show off your metrics to your team or visitors.
</p>
<PlaylistForm onSubmit={onSubmit} playlist={playlist} />
</Page.Contents>
</Page>
);
};
const mapStateToProps: MapStateToProps<ConnectedProps, {}, StoreState> = (state: StoreState) => ({
navModel: getNavModel(state.navIndex, 'playlists'),
});
export default connect(mapStateToProps)(PlaylistNewPage);

View File

@@ -0,0 +1,25 @@
import React, { FC } from 'react';
import { PlaylistTableRows } from './PlaylistTableRows';
import { PlaylistItem } from './types';
interface PlaylistTableProps {
items: PlaylistItem[];
onMoveUp: (item: PlaylistItem) => void;
onMoveDown: (item: PlaylistItem) => void;
onDelete: (item: PlaylistItem) => void;
}
export const PlaylistTable: FC<PlaylistTableProps> = ({ items, onMoveUp, onMoveDown, onDelete }) => {
return (
<div className="gf-form-group">
<h3 className="page-headering">Dashboards</h3>
<table className="filter-table">
<tbody>
<PlaylistTableRows items={items} onMoveUp={onMoveUp} onMoveDown={onMoveDown} onDelete={onDelete} />
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,100 @@
import React, { FC, MouseEvent } from 'react';
import { css, cx } from 'emotion';
import { Icon, IconButton, useStyles } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { TagBadge } from '../../core/components/TagFilter/TagBadge';
import { PlaylistItem } from './types';
import { selectors } from '@grafana/e2e-selectors';
interface PlaylistTableRowProps {
first: boolean;
last: boolean;
item: PlaylistItem;
onMoveUp: (item: PlaylistItem) => void;
onMoveDown: (item: PlaylistItem) => void;
onDelete: (item: PlaylistItem) => void;
}
export const PlaylistTableRow: FC<PlaylistTableRowProps> = ({ item, onDelete, onMoveDown, onMoveUp, first, last }) => {
const styles = useStyles(getStyles);
const onDeleteClick = (event: MouseEvent) => {
event.preventDefault();
onDelete(item);
};
const onMoveDownClick = (event: MouseEvent) => {
event.preventDefault();
onMoveDown(item);
};
const onMoveUpClick = (event: MouseEvent) => {
event.preventDefault();
onMoveUp(item);
};
return (
<tr aria-label={selectors.pages.PlaylistForm.itemRow} key={item.title}>
{item.type === 'dashboard_by_id' ? (
<td className={cx(styles.td, styles.item)}>
<Icon name="apps" aria-label={selectors.pages.PlaylistForm.itemIdType} />
<span>{item.title}</span>
</td>
) : null}
{item.type === 'dashboard_by_tag' ? (
<td className={cx(styles.td, styles.item)}>
<Icon name="tag-alt" aria-label={selectors.pages.PlaylistForm.itemTagType} />
<TagBadge key={item.id} label={item.title} removeIcon={false} count={0} />
</td>
) : null}
<td className={cx(styles.td, styles.settings)}>
{!first ? (
<IconButton
name="arrow-up"
size="md"
onClick={onMoveUpClick}
aria-label={selectors.pages.PlaylistForm.itemMoveUp}
type="button"
/>
) : null}
{!last ? (
<IconButton
name="arrow-down"
size="md"
onClick={onMoveDownClick}
aria-label={selectors.pages.PlaylistForm.itemMoveDown}
type="button"
/>
) : null}
<IconButton
name="times"
size="md"
onClick={onDeleteClick}
aria-label={selectors.pages.PlaylistForm.itemDelete}
type="button"
/>
</td>
</tr>
);
};
function getStyles(theme: GrafanaTheme) {
return {
td: css`
label: td;
line-height: 28px;
max-width: 335px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`,
item: css`
label: item;
span {
margin-left: ${theme.spacing.xs};
}
`,
settings: css`
label: settings;
text-align: right;
`,
};
}

View File

@@ -0,0 +1,43 @@
import React, { FC } from 'react';
import { PlaylistTableRow } from './PlaylistTableRow';
import { PlaylistItem } from './types';
interface PlaylistTableRowsProps {
items: PlaylistItem[];
onMoveUp: (item: PlaylistItem) => void;
onMoveDown: (item: PlaylistItem) => void;
onDelete: (item: PlaylistItem) => void;
}
export const PlaylistTableRows: FC<PlaylistTableRowsProps> = ({ items, onMoveUp, onMoveDown, onDelete }) => {
if (items.length === 0) {
return (
<tr>
<td>
<em>Playlist is empty, add dashboards below.</em>
</td>
</tr>
);
}
return (
<>
{items.map((item, index) => {
const first = index === 0;
const last = index === items.length - 1;
return (
<PlaylistTableRow
first={first}
last={last}
item={item}
onDelete={onDelete}
onMoveDown={onMoveDown}
onMoveUp={onMoveUp}
key={item.title}
/>
);
})}
</>
);
};

View File

@@ -0,0 +1,28 @@
import { getBackendSrv } from '@grafana/runtime';
import { Playlist } 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));
}
export async function updatePlaylist(id: number, playlist: Playlist) {
await withErrorHandling(async () => await getBackendSrv().put(`/api/playlists/${id}`, playlist));
}
export async function getPlaylist(id: number): Promise<Playlist> {
const result: Playlist = await getBackendSrv().get(`/api/playlists/${id}`);
return result;
}
async function withErrorHandling(apiCall: () => Promise<void>) {
try {
await apiCall();
dispatch(notifyApp(createSuccessNotification('Playlist saved')));
} catch (e) {
dispatch(notifyApp(createErrorNotification('Unable to save playlist', e)));
}
}

View File

@@ -6,13 +6,8 @@ import { NavModelSrv } from 'app/core/nav_model_srv';
import { AppEventEmitter } from 'app/types';
import { AppEvents } from '@grafana/data';
import { promiseToDigest } from '../../core/utils/promiseToDigest';
import { PlaylistItem } from './types';
export interface PlaylistItem {
value: any;
id: any;
type: string;
order: any;
}
export class PlaylistEditCtrl {
filteredDashboards: any = [];
filteredTags: any = [];
@@ -67,7 +62,7 @@ export class PlaylistEditCtrl {
}
addPlaylistItem(playlistItem: PlaylistItem) {
playlistItem.value = playlistItem.id.toString();
playlistItem.value = playlistItem.id!.toString();
playlistItem.type = 'dashboard_by_id';
playlistItem.order = this.playlistItems.length + 1;

View File

@@ -0,0 +1,16 @@
import { GrafanaTheme } from '@grafana/data';
import { css } from 'emotion';
export function getPlaylistStyles(theme: GrafanaTheme) {
return {
description: css`
label: description;
width: 555px;
margin-bottom: 20px;
`,
subHeading: css`
label: sub-heading;
margin-bottom: ${theme.spacing.md};
`,
};
}

View File

@@ -10,3 +10,18 @@ export interface PlayListItemDTO {
playlistid: string;
type: 'dashboard' | 'tag';
}
export interface Playlist {
name: string;
interval: string;
items?: PlaylistItem[];
}
export interface PlaylistItem {
id?: number;
value: string; //tag or id.toString()
type: 'dashboard_by_id' | 'dashboard_by_tag';
order: number;
title: string;
playlistId?: number;
}

View File

@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
import { Playlist } from './types';
import { getPlaylist } from './api';
export function usePlaylist(playlistId?: number) {
const [playlist, setPlaylist] = useState<Playlist>({ items: [], interval: '5m', name: '' });
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
const initPlaylist = async () => {
if (!playlistId) {
setLoading(false);
return;
}
const list = await getPlaylist(playlistId);
setPlaylist(list);
setLoading(false);
};
initPlaylist();
}, []);
return { playlist, loading };
}

View File

@@ -0,0 +1,82 @@
import { useCallback, useState } from 'react';
import { PlaylistItem } from './types';
import { DashboardPickerItem } from '../../core/components/Select/DashboardPicker';
export function usePlaylistItems(playlistItems?: PlaylistItem[]) {
const [items, setItems] = useState<PlaylistItem[]>(playlistItems ?? []);
const addById = useCallback(
(dashboard: DashboardPickerItem) => {
if (items.find((item) => item.id === dashboard.id)) {
return;
}
const newItem: PlaylistItem = {
id: dashboard.id,
title: dashboard.label,
type: 'dashboard_by_id',
value: dashboard.id.toString(10),
order: items.length + 1,
};
setItems([...items, newItem]);
},
[items]
);
const addByTag = useCallback(
(tags: string[]) => {
const tag = tags[0];
if (!tag || items.find((item) => item.value === tag)) {
return;
}
const newItem: PlaylistItem = {
title: tag,
type: 'dashboard_by_tag',
value: tag,
order: items.length + 1,
};
setItems([...items, newItem]);
},
[items]
);
const movePlaylistItem = useCallback(
(item: PlaylistItem, offset: number) => {
const newItems = [...items];
const currentPosition = newItems.indexOf(item);
const newPosition = currentPosition + offset;
if (newPosition >= 0 && newPosition < newItems.length) {
newItems.splice(currentPosition, 1);
newItems.splice(newPosition, 0, item);
}
setItems(newItems);
},
[items]
);
const moveUp = useCallback(
(item: PlaylistItem) => {
movePlaylistItem(item, -1);
},
[items]
);
const moveDown = useCallback(
(item: PlaylistItem) => {
movePlaylistItem(item, 1);
},
[items]
);
const deleteItem = useCallback(
(item: PlaylistItem) => {
setItems(items.filter((i) => i !== item));
},
[items]
);
return { items, addById, addByTag, deleteItem, moveDown, moveUp };
}

View File

@@ -396,6 +396,18 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "PlaylistStartPage"*/ 'app/features/playlist/PlaylistStartPage')
),
},
{
path: '/playlists/new',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "PlaylistNewPage"*/ 'app/features/playlist/PlaylistNewPage')
),
},
{
path: '/playlists/edit/:id',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "PlaylistEditPage"*/ 'app/features/playlist/PlaylistEditPage')
),
},
...extraRoutes,
{
path: '/*',