mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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',
|
||||
},
|
||||
};
|
||||
|
@@ -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;
|
||||
|
80
public/app/features/playlist/PlaylistEditPage.test.tsx
Normal file
80
public/app/features/playlist/PlaylistEditPage.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
55
public/app/features/playlist/PlaylistEditPage.tsx
Normal file
55
public/app/features/playlist/PlaylistEditPage.tsx
Normal 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);
|
192
public/app/features/playlist/PlaylistForm.test.tsx
Normal file
192
public/app/features/playlist/PlaylistForm.test.tsx
Normal 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();
|
||||
}
|
86
public/app/features/playlist/PlaylistForm.tsx
Normal file
86
public/app/features/playlist/PlaylistForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
74
public/app/features/playlist/PlaylistNewPage.test.tsx
Normal file
74
public/app/features/playlist/PlaylistNewPage.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
51
public/app/features/playlist/PlaylistNewPage.tsx
Normal file
51
public/app/features/playlist/PlaylistNewPage.tsx
Normal 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);
|
25
public/app/features/playlist/PlaylistTable.tsx
Normal file
25
public/app/features/playlist/PlaylistTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
100
public/app/features/playlist/PlaylistTableRow.tsx
Normal file
100
public/app/features/playlist/PlaylistTableRow.tsx
Normal 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;
|
||||
`,
|
||||
};
|
||||
}
|
43
public/app/features/playlist/PlaylistTableRows.tsx
Normal file
43
public/app/features/playlist/PlaylistTableRows.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
28
public/app/features/playlist/api.ts
Normal file
28
public/app/features/playlist/api.ts
Normal 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)));
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
||||
|
16
public/app/features/playlist/styles.ts
Normal file
16
public/app/features/playlist/styles.ts
Normal 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};
|
||||
`,
|
||||
};
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
23
public/app/features/playlist/usePlaylist.tsx
Normal file
23
public/app/features/playlist/usePlaylist.tsx
Normal 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 };
|
||||
}
|
82
public/app/features/playlist/usePlaylistItems.tsx
Normal file
82
public/app/features/playlist/usePlaylistItems.tsx
Normal 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 };
|
||||
}
|
@@ -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: '/*',
|
||||
|
Reference in New Issue
Block a user