From 51e7b87f39ec3c4a0a09b046d15007075fd84f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 23 Mar 2021 07:45:04 +0100 Subject: [PATCH] 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 --- .../src/selectors/pages.ts | 10 + .../components/Select/DashboardPicker.tsx | 8 +- .../playlist/PlaylistEditPage.test.tsx | 80 ++++++++ .../features/playlist/PlaylistEditPage.tsx | 55 +++++ .../features/playlist/PlaylistForm.test.tsx | 192 ++++++++++++++++++ public/app/features/playlist/PlaylistForm.tsx | 86 ++++++++ .../playlist/PlaylistNewPage.test.tsx | 74 +++++++ .../app/features/playlist/PlaylistNewPage.tsx | 51 +++++ .../app/features/playlist/PlaylistTable.tsx | 25 +++ .../features/playlist/PlaylistTableRow.tsx | 100 +++++++++ .../features/playlist/PlaylistTableRows.tsx | 43 ++++ public/app/features/playlist/api.ts | 28 +++ .../features/playlist/playlist_edit_ctrl.ts | 9 +- public/app/features/playlist/styles.ts | 16 ++ public/app/features/playlist/types.ts | 15 ++ public/app/features/playlist/usePlaylist.tsx | 23 +++ .../features/playlist/usePlaylistItems.tsx | 82 ++++++++ public/app/routes/routes.tsx | 12 ++ 18 files changed, 900 insertions(+), 9 deletions(-) create mode 100644 public/app/features/playlist/PlaylistEditPage.test.tsx create mode 100644 public/app/features/playlist/PlaylistEditPage.tsx create mode 100644 public/app/features/playlist/PlaylistForm.test.tsx create mode 100644 public/app/features/playlist/PlaylistForm.tsx create mode 100644 public/app/features/playlist/PlaylistNewPage.test.tsx create mode 100644 public/app/features/playlist/PlaylistNewPage.tsx create mode 100644 public/app/features/playlist/PlaylistTable.tsx create mode 100644 public/app/features/playlist/PlaylistTableRow.tsx create mode 100644 public/app/features/playlist/PlaylistTableRows.tsx create mode 100644 public/app/features/playlist/api.ts create mode 100644 public/app/features/playlist/styles.ts create mode 100644 public/app/features/playlist/usePlaylist.tsx create mode 100644 public/app/features/playlist/usePlaylistItems.tsx diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 5f6a686021e..f9d9301d873 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -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', + }, }; diff --git a/public/app/core/components/Select/DashboardPicker.tsx b/public/app/core/components/Select/DashboardPicker.tsx index 451e45e842a..71366fc1eaa 100644 --- a/public/app/core/components/Select/DashboardPicker.tsx +++ b/public/app/core/components/Select/DashboardPicker.tsx @@ -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 { + value: number; + label: string; +} export interface Props { - onChange: (dashboard: DashboardDTO) => void; + onChange: (dashboard: DashboardPickerItem) => void; value?: SelectableValue; width?: number; isClearable?: boolean; diff --git a/public/app/features/playlist/PlaylistEditPage.test.tsx b/public/app/features/playlist/PlaylistEditPage.test.tsx new file mode 100644 index 00000000000..d0123151fd7 --- /dev/null +++ b/public/app/features/playlist/PlaylistEditPage.test.tsx @@ -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 = {}) { + 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( + + ); + 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'); + }); + }); +}); diff --git a/public/app/features/playlist/PlaylistEditPage.tsx b/public/app/features/playlist/PlaylistEditPage.tsx new file mode 100644 index 00000000000..f41ce64681e --- /dev/null +++ b/public/app/features/playlist/PlaylistEditPage.tsx @@ -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 {} + +export const PlaylistEditPage: FC = ({ 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 ( + + +

Edit Playlist

+ +

+ 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. +

+ + +
+
+ ); +}; + +const mapStateToProps: MapStateToProps = (state: StoreState) => ({ + navModel: getNavModel(state.navIndex, 'playlists'), +}); + +export default connect(mapStateToProps)(PlaylistEditPage); diff --git a/public/app/features/playlist/PlaylistForm.test.tsx b/public/app/features/playlist/PlaylistForm.test.tsx new file mode 100644 index 00000000000..da11373cc2b --- /dev/null +++ b/public/app/features/playlist/PlaylistForm.test.tsx @@ -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 = {}) { + const onSubmitMock = jest.fn(); + const playlist = ({ name, items, interval } as unknown) as Playlist; + const { rerender } = render(); + + 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(); +} diff --git a/public/app/features/playlist/PlaylistForm.tsx b/public/app/features/playlist/PlaylistForm.tsx new file mode 100644 index 00000000000..e916983d3bd --- /dev/null +++ b/public/app/features/playlist/PlaylistForm.tsx @@ -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 = ({ onSubmit, playlist }) => { + const { name, interval, items: propItems } = playlist; + const { items, addById, addByTag, deleteItem, moveDown, moveUp } = usePlaylistItems(propItems); + return ( + <> +
onSubmit({ ...list, items })} validateOn={'onBlur'}> + {({ register, errors }) => { + const isDisabled = items.length === 0 || Object.keys(errors).length > 0; + return ( + <> + + + + + + + + + +
+

Add dashboards

+ + + + + + + + +
+ + + + + Cancel + + + + ); + }} + + + ); +}; diff --git a/public/app/features/playlist/PlaylistNewPage.test.tsx b/public/app/features/playlist/PlaylistNewPage.test.tsx new file mode 100644 index 00000000000..5b1c00bd2b9 --- /dev/null +++ b/public/app/features/playlist/PlaylistNewPage.test.tsx @@ -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 = {}) { + 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( + + ); + + 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'); + }); + }); +}); diff --git a/public/app/features/playlist/PlaylistNewPage.tsx b/public/app/features/playlist/PlaylistNewPage.tsx new file mode 100644 index 00000000000..d37597a7085 --- /dev/null +++ b/public/app/features/playlist/PlaylistNewPage.tsx @@ -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 = ({ navModel }) => { + const styles = useStyles(getPlaylistStyles); + const { playlist, loading } = usePlaylist(); + const onSubmit = async (playlist: Playlist) => { + await createPlaylist(playlist); + locationService.push('/playlists'); + }; + + return ( + + +

New Playlist

+ +

+ 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. +

+ + +
+
+ ); +}; + +const mapStateToProps: MapStateToProps = (state: StoreState) => ({ + navModel: getNavModel(state.navIndex, 'playlists'), +}); + +export default connect(mapStateToProps)(PlaylistNewPage); diff --git a/public/app/features/playlist/PlaylistTable.tsx b/public/app/features/playlist/PlaylistTable.tsx new file mode 100644 index 00000000000..b479ec5f587 --- /dev/null +++ b/public/app/features/playlist/PlaylistTable.tsx @@ -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 = ({ items, onMoveUp, onMoveDown, onDelete }) => { + return ( +
+

Dashboards

+ + + + + +
+
+ ); +}; diff --git a/public/app/features/playlist/PlaylistTableRow.tsx b/public/app/features/playlist/PlaylistTableRow.tsx new file mode 100644 index 00000000000..01b5680ca85 --- /dev/null +++ b/public/app/features/playlist/PlaylistTableRow.tsx @@ -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 = ({ 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 ( + + {item.type === 'dashboard_by_id' ? ( + + + {item.title} + + ) : null} + {item.type === 'dashboard_by_tag' ? ( + + + + + ) : null} + + {!first ? ( + + ) : null} + {!last ? ( + + ) : null} + + + + ); +}; + +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; + `, + }; +} diff --git a/public/app/features/playlist/PlaylistTableRows.tsx b/public/app/features/playlist/PlaylistTableRows.tsx new file mode 100644 index 00000000000..d1af268e06c --- /dev/null +++ b/public/app/features/playlist/PlaylistTableRows.tsx @@ -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 = ({ items, onMoveUp, onMoveDown, onDelete }) => { + if (items.length === 0) { + return ( + + + Playlist is empty, add dashboards below. + + + ); + } + + return ( + <> + {items.map((item, index) => { + const first = index === 0; + const last = index === items.length - 1; + return ( + + ); + })} + + ); +}; diff --git a/public/app/features/playlist/api.ts b/public/app/features/playlist/api.ts new file mode 100644 index 00000000000..2c588c10b4f --- /dev/null +++ b/public/app/features/playlist/api.ts @@ -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 { + const result: Playlist = await getBackendSrv().get(`/api/playlists/${id}`); + return result; +} + +async function withErrorHandling(apiCall: () => Promise) { + try { + await apiCall(); + dispatch(notifyApp(createSuccessNotification('Playlist saved'))); + } catch (e) { + dispatch(notifyApp(createErrorNotification('Unable to save playlist', e))); + } +} diff --git a/public/app/features/playlist/playlist_edit_ctrl.ts b/public/app/features/playlist/playlist_edit_ctrl.ts index 983babf8685..0f7df5defa0 100644 --- a/public/app/features/playlist/playlist_edit_ctrl.ts +++ b/public/app/features/playlist/playlist_edit_ctrl.ts @@ -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; diff --git a/public/app/features/playlist/styles.ts b/public/app/features/playlist/styles.ts new file mode 100644 index 00000000000..2602c8f3865 --- /dev/null +++ b/public/app/features/playlist/styles.ts @@ -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}; + `, + }; +} diff --git a/public/app/features/playlist/types.ts b/public/app/features/playlist/types.ts index 955ba0093a9..8f2f0377dfd 100644 --- a/public/app/features/playlist/types.ts +++ b/public/app/features/playlist/types.ts @@ -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; +} diff --git a/public/app/features/playlist/usePlaylist.tsx b/public/app/features/playlist/usePlaylist.tsx new file mode 100644 index 00000000000..6bce65aadc2 --- /dev/null +++ b/public/app/features/playlist/usePlaylist.tsx @@ -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({ items: [], interval: '5m', name: '' }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const initPlaylist = async () => { + if (!playlistId) { + setLoading(false); + return; + } + const list = await getPlaylist(playlistId); + setPlaylist(list); + setLoading(false); + }; + initPlaylist(); + }, []); + + return { playlist, loading }; +} diff --git a/public/app/features/playlist/usePlaylistItems.tsx b/public/app/features/playlist/usePlaylistItems.tsx new file mode 100644 index 00000000000..f4848a93024 --- /dev/null +++ b/public/app/features/playlist/usePlaylistItems.tsx @@ -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(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 }; +} diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 6a000f8e7d5..eeca4b047eb 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -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: '/*',