mirror of
https://github.com/grafana/grafana.git
synced 2024-11-24 09:50:29 -06:00
Playlists: Migrate to UIDs and load dashboards in the frontend (#54125)
This commit is contained in:
parent
3ea9ece16e
commit
ac93cf1db2
@ -4897,18 +4897,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
|
||||
],
|
||||
"public/app/features/playlist/PlaylistSrv.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
|
||||
],
|
||||
"public/app/features/playlist/usePlaylistItems.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
|
||||
],
|
||||
"public/app/features/plugins/__mocks__/pluginMocks.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
|
@ -218,11 +218,6 @@ export const Pages = {
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
@ -66,6 +66,7 @@ func (hs *HTTPServer) populateDashboardsByTag(ctx context.Context, orgID int64,
|
||||
return result
|
||||
}
|
||||
|
||||
// Deprecated -- the frontend can do this better
|
||||
func (hs *HTTPServer) LoadPlaylistDashboards(ctx context.Context, orgID int64, signedInUser *user.SignedInUser, playlistUID string) (dtos.PlaylistDashboardsSlice, error) {
|
||||
playlistItems, _ := hs.LoadPlaylistItems(ctx, playlistUID, orgID)
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { SelectableValue, StandardEditorProps } from '@grafana/data';
|
||||
import { DashboardPicker as BasePicker, DashboardPickerDTO } from 'app/core/components/Select/DashboardPicker';
|
||||
@ -8,12 +8,10 @@ export interface DashboardPickerOptions {
|
||||
isClearable?: boolean;
|
||||
}
|
||||
|
||||
type Props = StandardEditorProps<string, DashboardPickerOptions, any>;
|
||||
|
||||
/** This will return the item UID */
|
||||
export const DashboardPicker: FC<StandardEditorProps<string, DashboardPickerOptions, any>> = ({
|
||||
value,
|
||||
onChange,
|
||||
item,
|
||||
}) => {
|
||||
export const DashboardPicker = ({ value, onChange, item }: Props) => {
|
||||
const { placeholder, isClearable } = item?.settings ?? {};
|
||||
|
||||
const onPicked = useCallback(
|
||||
|
@ -17,7 +17,7 @@ export type DashboardPickerDTO = Pick<DashboardDTO['dashboard'], 'uid' | 'title'
|
||||
|
||||
const formatLabel = (folderTitle = 'General', dashboardTitle: string) => `${folderTitle}/${dashboardTitle}`;
|
||||
|
||||
const getDashboards = debounce((query = ''): Promise<Array<SelectableValue<DashboardPickerDTO>>> => {
|
||||
async function findDashboards(query = '') {
|
||||
return backendSrv.search({ type: 'dash-db', query, limit: 100 }).then((result: DashboardSearchItem[]) => {
|
||||
return result.map((item: DashboardSearchItem) => ({
|
||||
value: {
|
||||
@ -30,7 +30,9 @@ const getDashboards = debounce((query = ''): Promise<Array<SelectableValue<Dashb
|
||||
label: formatLabel(item?.folderTitle, item.title),
|
||||
}));
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
const getDashboards = debounce(findDashboards, 250, { leading: true });
|
||||
|
||||
// TODO: this component should provide a way to apply different filters to the search APIs
|
||||
export const DashboardPicker = ({
|
||||
@ -84,6 +86,7 @@ export const DashboardPicker = ({
|
||||
placeholder={placeholder}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
value={current}
|
||||
defaultOptions={true}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
@ -10,6 +10,7 @@ import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoa
|
||||
import { DashboardSrv, getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
|
||||
import { toStateKey } from 'app/features/variables/utils';
|
||||
import { DashboardDTO, DashboardInitPhase, DashboardRoutes, StoreState, ThunkDispatch, ThunkResult } from 'app/types';
|
||||
|
||||
@ -70,7 +71,7 @@ async function fetchDashboard(
|
||||
case DashboardRoutes.Normal: {
|
||||
const dashDTO: DashboardDTO = await dashboardLoaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid);
|
||||
|
||||
if (args.fixUrl && dashDTO.meta.url) {
|
||||
if (args.fixUrl && dashDTO.meta.url && !playlistSrv.isPlaying) {
|
||||
// check if the current url is correct (might be old slug)
|
||||
const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url);
|
||||
const currentPath = locationService.getLocation().pathname;
|
||||
|
@ -60,6 +60,10 @@ const setup = (propOverrides?: object) => {
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('should render component', () => {
|
||||
expect(() => setup()).not.toThrow();
|
||||
});
|
||||
|
@ -13,7 +13,7 @@ jest.mock('@grafana/runtime', () => ({
|
||||
getBackendSrv: () => backendSrv,
|
||||
}));
|
||||
|
||||
jest.mock('../../core/components/TagFilter/TagFilter', () => ({
|
||||
jest.mock('app/core/components/TagFilter/TagFilter', () => ({
|
||||
TagFilter: () => {
|
||||
return <>mocked-tag-filter</>;
|
||||
},
|
||||
@ -32,7 +32,7 @@ async function getTestContext({ name, interval, items, uid }: Partial<Playlist>
|
||||
getMock.mockResolvedValue({
|
||||
name: 'Test Playlist',
|
||||
interval: '5s',
|
||||
items: [{ title: 'First item', type: 'dashboard_by_id', order: 1, value: '1' }],
|
||||
items: [{ title: 'First item', type: 'dashboard_by_uid', order: 1, value: '1' }],
|
||||
uid: 'foo',
|
||||
});
|
||||
const { rerender } = render(
|
||||
@ -51,7 +51,7 @@ describe('PlaylistEditPage', () => {
|
||||
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);
|
||||
expect(screen.getAllByRole('row')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -69,7 +69,7 @@ describe('PlaylistEditPage', () => {
|
||||
expect(putMock).toHaveBeenCalledWith('/api/playlists/foo', {
|
||||
name: 'A Name',
|
||||
interval: '10s',
|
||||
items: [{ title: 'First item', type: 'dashboard_by_id', order: 1, value: '1' }],
|
||||
items: [{ title: 'First item', type: 'dashboard_by_uid', order: 1, value: '1' }],
|
||||
});
|
||||
expect(locationService.getLocation().pathname).toEqual('/playlists');
|
||||
});
|
||||
|
@ -1,16 +1,15 @@
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { GrafanaRouteComponentProps } from '../../core/navigation/types';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
|
||||
import { PlaylistForm } from './PlaylistForm';
|
||||
import { updatePlaylist } from './api';
|
||||
import { getPlaylist, updatePlaylist } from './api';
|
||||
import { getPlaylistStyles } from './styles';
|
||||
import { Playlist } from './types';
|
||||
import { usePlaylist } from './usePlaylist';
|
||||
|
||||
export interface RouteParams {
|
||||
uid: string;
|
||||
@ -18,9 +17,10 @@ export interface RouteParams {
|
||||
|
||||
interface Props extends GrafanaRouteComponentProps<RouteParams> {}
|
||||
|
||||
export const PlaylistEditPage: FC<Props> = ({ match }) => {
|
||||
export const PlaylistEditPage = ({ match }: Props) => {
|
||||
const styles = useStyles2(getPlaylistStyles);
|
||||
const { playlist, loading } = usePlaylist(match.params.uid);
|
||||
const playlist = useAsync(() => getPlaylist(match.params.uid), [match.params]);
|
||||
|
||||
const onSubmit = async (playlist: Playlist) => {
|
||||
await updatePlaylist(match.params.uid, playlist);
|
||||
locationService.push('/playlists');
|
||||
@ -28,15 +28,21 @@ export const PlaylistEditPage: FC<Props> = ({ match }) => {
|
||||
|
||||
return (
|
||||
<Page navId="dashboards/playlists">
|
||||
<Page.Contents isLoading={loading}>
|
||||
<Page.Contents isLoading={playlist.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>
|
||||
{playlist.error && <div>Error loading playlist: {JSON.stringify(playlist.error)}</div>}
|
||||
|
||||
<PlaylistForm onSubmit={onSubmit} playlist={playlist} />
|
||||
{playlist.value && (
|
||||
<>
|
||||
<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.value} />
|
||||
</>
|
||||
)}
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
|
@ -6,7 +6,7 @@ import React from 'react';
|
||||
import { PlaylistForm } from './PlaylistForm';
|
||||
import { Playlist } from './types';
|
||||
|
||||
jest.mock('../../core/components/TagFilter/TagFilter', () => ({
|
||||
jest.mock('app/core/components/TagFilter/TagFilter', () => ({
|
||||
TagFilter: () => {
|
||||
return <>mocked-tag-filter</>;
|
||||
},
|
||||
@ -24,25 +24,29 @@ 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' },
|
||||
{ type: 'dashboard_by_uid', value: 'uid_1' },
|
||||
{ type: 'dashboard_by_uid', value: 'uid_2' },
|
||||
{ type: 'dashboard_by_tag', value: 'tag_A' },
|
||||
],
|
||||
uid: 'foo',
|
||||
};
|
||||
|
||||
function rows() {
|
||||
return screen.getAllByRole('row', { name: /playlist item row/i });
|
||||
return screen.getAllByRole('row');
|
||||
}
|
||||
|
||||
describe('PlaylistForm', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
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();
|
||||
expect(screen.queryByRole('row')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('then name field should have empty string as default value', () => {
|
||||
@ -74,25 +78,15 @@ describe('PlaylistForm', () => {
|
||||
it('then items row count should be correct', () => {
|
||||
getTestContext(playlist);
|
||||
|
||||
expect(screen.getAllByRole('row', { name: /playlist item row/i })).toHaveLength(3);
|
||||
expect(screen.getAllByRole('row')).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 });
|
||||
expectCorrectRow({ index: 0, type: 'dashboard_by_uid', value: 'uid_1' });
|
||||
expectCorrectRow({ index: 1, type: 'dashboard_by_uid', value: 'uid_2' });
|
||||
expectCorrectRow({ index: 2, type: 'dashboard_by_tag', value: 'tag_A' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -103,30 +97,8 @@ describe('PlaylistForm', () => {
|
||||
expect(rows()).toHaveLength(3);
|
||||
await 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', async () => {
|
||||
getTestContext(playlist);
|
||||
|
||||
await 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', async () => {
|
||||
getTestContext(playlist);
|
||||
|
||||
await 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 });
|
||||
expectCorrectRow({ index: 0, type: 'dashboard_by_uid', value: 'uid_1' });
|
||||
expectCorrectRow({ index: 1, type: 'dashboard_by_uid', value: 'uid_2' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -140,9 +112,9 @@ describe('PlaylistForm', () => {
|
||||
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' },
|
||||
{ type: 'dashboard_by_uid', value: 'uid_1' },
|
||||
{ type: 'dashboard_by_uid', value: 'uid_2' },
|
||||
{ type: 'dashboard_by_tag', value: 'tag_A' },
|
||||
],
|
||||
});
|
||||
});
|
||||
@ -180,28 +152,13 @@ describe('PlaylistForm', () => {
|
||||
|
||||
interface ExpectCorrectRowArgs {
|
||||
index: number;
|
||||
type: 'id' | 'tag';
|
||||
title: string;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
type: 'dashboard_by_tag' | 'dashboard_by_uid';
|
||||
value: string;
|
||||
}
|
||||
|
||||
function expectCorrectRow({ index, type, title, first = false, last = false }: ExpectCorrectRowArgs) {
|
||||
function expectCorrectRow({ index, type, value }: ExpectCorrectRowArgs) {
|
||||
const row = within(rows()[index]);
|
||||
const cell = `playlist item dashboard by ${type} type ${title}`;
|
||||
const cell = `Playlist item, ${type}, ${value}`;
|
||||
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();
|
||||
}
|
||||
|
@ -1,27 +1,30 @@
|
||||
import React, { FC } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Button, Field, Form, HorizontalGroup, Input, LinkButton } from '@grafana/ui';
|
||||
import { DashboardPickerByID } from 'app/core/components/OptionsUI/DashboardPickerByID';
|
||||
import { DashboardPicker } from 'app/core/components/Select/DashboardPicker';
|
||||
import { TagFilter } from 'app/core/components/TagFilter/TagFilter';
|
||||
|
||||
import { TagFilter } from '../../core/components/TagFilter/TagFilter';
|
||||
import { SearchSrv } from '../../core/services/search_srv';
|
||||
import { getGrafanaSearcher } from '../search/service';
|
||||
|
||||
import { PlaylistTable } from './PlaylistTable';
|
||||
import { Playlist } from './types';
|
||||
import { usePlaylistItems } from './usePlaylistItems';
|
||||
|
||||
interface PlaylistFormProps {
|
||||
interface Props {
|
||||
onSubmit: (playlist: Playlist) => void;
|
||||
playlist: Playlist;
|
||||
}
|
||||
|
||||
const searchSrv = new SearchSrv();
|
||||
|
||||
export const PlaylistForm: FC<PlaylistFormProps> = ({ onSubmit, playlist }) => {
|
||||
export const PlaylistForm = ({ onSubmit, playlist }: Props) => {
|
||||
const { name, interval, items: propItems } = playlist;
|
||||
const { items, addById, addByTag, deleteItem, moveDown, moveUp } = usePlaylistItems(propItems);
|
||||
const tagOptions = useMemo(() => {
|
||||
return () => getGrafanaSearcher().tags({ kind: ['dashboard'] });
|
||||
}, []);
|
||||
|
||||
const { items, addById, addByTag, deleteItem, moveItem } = usePlaylistItems(propItems);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form onSubmit={(list: Playlist) => onSubmit({ ...list, items })} validateOn={'onBlur'}>
|
||||
@ -48,13 +51,13 @@ export const PlaylistForm: FC<PlaylistFormProps> = ({ onSubmit, playlist }) => {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<PlaylistTable items={items} onMoveUp={moveUp} onMoveDown={moveDown} onDelete={deleteItem} />
|
||||
<PlaylistTable items={items} deleteItem={deleteItem} moveItem={moveItem} />
|
||||
|
||||
<div className="gf-form-group">
|
||||
<h3 className="page-headering">Add dashboards</h3>
|
||||
|
||||
<Field label="Add by title">
|
||||
<DashboardPickerByID onChange={addById} id="dashboard-picker" isClearable />
|
||||
<DashboardPicker id="dashboard-picker" onChange={addById} key={items.length} />
|
||||
</Field>
|
||||
|
||||
<Field label="Add by tag">
|
||||
@ -62,9 +65,9 @@ export const PlaylistForm: FC<PlaylistFormProps> = ({ onSubmit, playlist }) => {
|
||||
isClearable
|
||||
tags={[]}
|
||||
hideValues
|
||||
tagOptions={searchSrv.getDashboardTags}
|
||||
tagOptions={tagOptions}
|
||||
onChange={addByTag}
|
||||
placeholder={''}
|
||||
placeholder="Select a tag"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
@ -2,26 +2,19 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
|
||||
import { backendSrv } from '../../core/services/backend_srv';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
import { PlaylistNewPage } from './PlaylistNewPage';
|
||||
import { Playlist } from './types';
|
||||
|
||||
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'),
|
||||
getBackendSrv: () => backendSrv,
|
||||
}));
|
||||
|
||||
jest.mock('../../core/components/TagFilter/TagFilter', () => ({
|
||||
jest.mock('app/core/components/TagFilter/TagFilter', () => ({
|
||||
TagFilter: () => {
|
||||
return <>mocked-tag-filter</>;
|
||||
},
|
||||
@ -38,6 +31,10 @@ function getTestContext({ name, interval, items }: Partial<Playlist> = {}) {
|
||||
}
|
||||
|
||||
describe('PlaylistNewPage', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('when mounted', () => {
|
||||
it('then header should be correct', () => {
|
||||
getTestContext();
|
||||
@ -51,13 +48,14 @@ describe('PlaylistNewPage', () => {
|
||||
const { backendSrvMock } = getTestContext();
|
||||
|
||||
expect(locationService.getLocation().pathname).toEqual('/');
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /playlist name/i }), 'A Name');
|
||||
|
||||
await userEvent.type(screen.getByRole('textbox', { name: selectors.pages.PlaylistForm.name }), 'A new name');
|
||||
fireEvent.submit(screen.getByRole('button', { name: /save/i }));
|
||||
await waitFor(() => expect(backendSrvMock).toHaveBeenCalledTimes(1));
|
||||
expect(backendSrvMock).toHaveBeenCalledWith('/api/playlists', {
|
||||
name: 'A Name',
|
||||
name: 'A new name',
|
||||
interval: '5m',
|
||||
items: [{ title: 'First item', type: 'dashboard_by_id', order: 1, value: '1' }],
|
||||
items: [],
|
||||
});
|
||||
expect(locationService.getLocation().pathname).toEqual('/playlists');
|
||||
});
|
||||
|
@ -1,18 +1,18 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { PlaylistForm } from './PlaylistForm';
|
||||
import { createPlaylist } from './api';
|
||||
import { createPlaylist, getDefaultPlaylist } from './api';
|
||||
import { getPlaylistStyles } from './styles';
|
||||
import { Playlist } from './types';
|
||||
import { usePlaylist } from './usePlaylist';
|
||||
|
||||
export const PlaylistNewPage = () => {
|
||||
const styles = useStyles2(getPlaylistStyles);
|
||||
const { playlist, loading } = usePlaylist();
|
||||
const [playlist] = useState<Playlist>(getDefaultPlaylist());
|
||||
|
||||
const onSubmit = async (playlist: Playlist) => {
|
||||
await createPlaylist(playlist);
|
||||
locationService.push('/playlists');
|
||||
@ -20,7 +20,7 @@ export const PlaylistNewPage = () => {
|
||||
|
||||
return (
|
||||
<Page navId="dashboards/playlists">
|
||||
<Page.Contents isLoading={loading}>
|
||||
<Page.Contents>
|
||||
<h3 className={styles.subHeading}>New Playlist</h3>
|
||||
|
||||
<p className={styles.description}>
|
||||
|
@ -25,6 +25,10 @@ function getTestContext() {
|
||||
}
|
||||
|
||||
describe('PlaylistPage', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('when mounted without a playlist', () => {
|
||||
it('page should load', () => {
|
||||
fnMock.mockResolvedValue([]);
|
||||
@ -60,9 +64,9 @@ describe('PlaylistPage', () => {
|
||||
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' },
|
||||
{ title: 'First item', type: 'dashboard_by_uid', value: '1' },
|
||||
{ title: 'Middle item', type: 'dashboard_by_uid', value: '2' },
|
||||
{ title: 'Last item', type: 'dashboard_by_tag', value: 'Last item' },
|
||||
],
|
||||
uid: 'playlist-0',
|
||||
},
|
||||
|
@ -1,41 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { ConfirmModal } from '@grafana/ui';
|
||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import PageActionBar from 'app/core/components/PageActionBar/PageActionBar';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
|
||||
import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
|
||||
|
||||
import { EmptyQueryListBanner } from './EmptyQueryListBanner';
|
||||
import { PlaylistPageList } from './PlaylistPageList';
|
||||
import { StartModal } from './StartModal';
|
||||
import { deletePlaylist, getAllPlaylist } from './api';
|
||||
import { PlaylistDTO } from './types';
|
||||
import { deletePlaylist, getAllPlaylist, searchPlaylists } from './api';
|
||||
import { Playlist } from './types';
|
||||
|
||||
export const PlaylistPage = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
const [startPlaylist, setStartPlaylist] = useState<PlaylistDTO | undefined>();
|
||||
const [playlistToDelete, setPlaylistToDelete] = useState<PlaylistDTO | undefined>();
|
||||
const [forcePlaylistsFetch, setForcePlaylistsFetch] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const allPlaylists = useAsync(() => getAllPlaylist(), [forcePlaylistsFetch]);
|
||||
const playlists = useMemo(() => searchPlaylists(allPlaylists.value ?? [], searchQuery), [searchQuery, allPlaylists]);
|
||||
|
||||
const [playlists, setPlaylists] = useState<PlaylistDTO[]>([]);
|
||||
|
||||
useDebounce(
|
||||
async () => {
|
||||
const playlists = await getAllPlaylist(searchQuery);
|
||||
if (!hasFetched) {
|
||||
setHasFetched(true);
|
||||
}
|
||||
setPlaylists(playlists);
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
},
|
||||
350,
|
||||
[forcePlaylistsFetch, searchQuery]
|
||||
);
|
||||
const [startPlaylist, setStartPlaylist] = useState<Playlist | undefined>();
|
||||
const [playlistToDelete, setPlaylistToDelete] = useState<Playlist | undefined>();
|
||||
|
||||
const hasPlaylists = playlists && playlists.length > 0;
|
||||
const onDismissDelete = () => setPlaylistToDelete(undefined);
|
||||
@ -63,11 +48,11 @@ export const PlaylistPage = () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const showSearch = playlists.length > 0 || searchQuery.length > 0 || debouncedSearchQuery.length > 0;
|
||||
const showSearch = playlists.length > 0 || searchQuery.length > 0;
|
||||
|
||||
return (
|
||||
<Page navId="dashboards/playlists">
|
||||
<Page.Contents isLoading={!hasFetched}>
|
||||
<Page.Contents isLoading={allPlaylists.loading}>
|
||||
{showSearch && (
|
||||
<PageActionBar
|
||||
searchQuery={searchQuery}
|
||||
|
@ -4,23 +4,22 @@ import React from 'react';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, Card, LinkButton, ModalsController, useStyles2 } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
|
||||
import { DashNavButton } from '../dashboard/components/DashNav/DashNavButton';
|
||||
import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton';
|
||||
|
||||
import { ShareModal } from './ShareModal';
|
||||
import { PlaylistDTO } from './types';
|
||||
import { Playlist } from './types';
|
||||
|
||||
interface Props {
|
||||
setStartPlaylist: (playlistItem: PlaylistDTO) => void;
|
||||
setPlaylistToDelete: (playlistItem: PlaylistDTO) => void;
|
||||
playlists: PlaylistDTO[] | undefined;
|
||||
setStartPlaylist: (playlistItem: Playlist) => void;
|
||||
setPlaylistToDelete: (playlistItem: Playlist) => void;
|
||||
playlists: Playlist[] | undefined;
|
||||
}
|
||||
|
||||
export const PlaylistPageList = ({ playlists, setStartPlaylist, setPlaylistToDelete }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
{playlists!.map((playlist: PlaylistDTO) => (
|
||||
{playlists!.map((playlist: Playlist) => (
|
||||
<li className={styles.listItem} key={playlist.uid}>
|
||||
<Card>
|
||||
<Card.Heading>
|
||||
@ -52,7 +51,7 @@ export const PlaylistPageList = ({ playlists, setStartPlaylist, setPlaylistToDel
|
||||
</LinkButton>
|
||||
<Button
|
||||
disabled={false}
|
||||
onClick={() => setPlaylistToDelete({ id: playlist.id, uid: playlist.uid, name: playlist.name })}
|
||||
onClick={() => setPlaylistToDelete(playlist)}
|
||||
icon="trash-alt"
|
||||
variant="destructive"
|
||||
>
|
||||
|
@ -4,30 +4,39 @@ import configureMockStore from 'redux-mock-store';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { setStore } from 'app/store/store';
|
||||
|
||||
import { DashboardQueryResult } from '../search/service';
|
||||
|
||||
import { PlaylistSrv } from './PlaylistSrv';
|
||||
import { Playlist, PlaylistItem } from './types';
|
||||
|
||||
const getMock = jest.fn();
|
||||
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
const original = jest.requireActual('@grafana/runtime');
|
||||
return {
|
||||
...original,
|
||||
getBackendSrv: () => ({
|
||||
get: getMock,
|
||||
}),
|
||||
};
|
||||
});
|
||||
jest.mock('./api', () => ({
|
||||
getPlaylist: jest.fn().mockReturnValue({
|
||||
interval: '1s',
|
||||
uid: 'xyz',
|
||||
items: [
|
||||
{ type: 'dashboard_by_uid', value: 'aaa' },
|
||||
{ type: 'dashboard_by_uid', value: 'bbb' },
|
||||
],
|
||||
} as Playlist),
|
||||
loadDashboards: (items: PlaylistItem[]) => {
|
||||
return Promise.resolve(
|
||||
items.map((v) => ({
|
||||
...v, // same item with dashboard URLs filled in
|
||||
dashboards: [{ url: `/url/to/${v.value}` } as unknown as DashboardQueryResult],
|
||||
}))
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const mockStore = configureMockStore();
|
||||
|
||||
setStore(
|
||||
// eslint-disable-next-line
|
||||
mockStore({
|
||||
location: {},
|
||||
}) as any
|
||||
);
|
||||
|
||||
const dashboards = [{ url: '/dash1' }, { url: '/dash2' }];
|
||||
|
||||
function createPlaylistSrv(): PlaylistSrv {
|
||||
locationService.push('/playlists/foo');
|
||||
return new PlaylistSrv();
|
||||
@ -41,6 +50,8 @@ const mockWindowLocation = (): [jest.MockInstance<any, any>, () => void] => {
|
||||
// https://github.com/facebook/jest/issues/5124#issuecomment-446659510
|
||||
//@ts-ignore
|
||||
delete window.location;
|
||||
|
||||
// eslint-disable-next-line
|
||||
window.location = {} as any;
|
||||
|
||||
// Only mocking href as that is all this test needs, but otherwise there is lots of things missing, so keep that
|
||||
@ -63,18 +74,6 @@ describe('PlaylistSrv', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getMock.mockImplementation(
|
||||
jest.fn((url) => {
|
||||
switch (url) {
|
||||
case '/api/playlists/foo':
|
||||
return Promise.resolve({ interval: '1s' });
|
||||
case '/api/playlists/foo/dashboards':
|
||||
return Promise.resolve(dashboards);
|
||||
default:
|
||||
throw new Error(`Unexpected url=${url}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
srv = createPlaylistSrv();
|
||||
[hrefMock, unmockLocation] = mockWindowLocation();
|
||||
@ -129,9 +128,13 @@ describe('PlaylistSrv', () => {
|
||||
it('storeUpdated should not stop playlist when navigating to next dashboard', async () => {
|
||||
await srv.start('foo');
|
||||
|
||||
// eslint-disable-next-line
|
||||
expect((srv as any).validPlaylistUrl).toBe('/url/to/aaa');
|
||||
|
||||
srv.next();
|
||||
|
||||
expect((srv as any).validPlaylistUrl).toBe('/dash2');
|
||||
// eslint-disable-next-line
|
||||
expect((srv as any).validPlaylistUrl).toBe('/url/to/bbb');
|
||||
expect(srv.isPlaying).toBe(true);
|
||||
});
|
||||
});
|
||||
|
@ -1,10 +1,10 @@
|
||||
// Libraries
|
||||
import { Location } from 'history';
|
||||
import { pickBy } from 'lodash';
|
||||
|
||||
// Utils
|
||||
import { locationUtil, urlUtil, rangeUtil } from '@grafana/data';
|
||||
import { getBackendSrv, locationService } from '@grafana/runtime';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
|
||||
import { getPlaylist, loadDashboards } from './api';
|
||||
|
||||
export const queryParamsToPreserve: { [key: string]: boolean } = {
|
||||
kiosk: true,
|
||||
@ -13,8 +13,8 @@ export const queryParamsToPreserve: { [key: string]: boolean } = {
|
||||
};
|
||||
|
||||
export class PlaylistSrv {
|
||||
private nextTimeoutId: any;
|
||||
private declare dashboards: Array<{ url: string }>;
|
||||
private nextTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
private urls: string[] = []; // the URLs we need to load
|
||||
private index = 0;
|
||||
private declare interval: number;
|
||||
private declare startUrl: string;
|
||||
@ -31,7 +31,7 @@ export class PlaylistSrv {
|
||||
next() {
|
||||
clearTimeout(this.nextTimeoutId);
|
||||
|
||||
const playedAllDashboards = this.index > this.dashboards.length - 1;
|
||||
const playedAllDashboards = this.index > this.urls.length - 1;
|
||||
if (playedAllDashboards) {
|
||||
this.numberOfLoops++;
|
||||
|
||||
@ -44,10 +44,10 @@ export class PlaylistSrv {
|
||||
this.index = 0;
|
||||
}
|
||||
|
||||
const dash = this.dashboards[this.index];
|
||||
const url = this.urls[this.index];
|
||||
const queryParams = locationService.getSearchObject();
|
||||
const filteredParams = pickBy(queryParams, (value: any, key: string) => queryParamsToPreserve[key]);
|
||||
const nextDashboardUrl = locationUtil.stripBaseFromUrl(dash.url);
|
||||
const filteredParams = pickBy(queryParams, (value: unknown, key: string) => queryParamsToPreserve[key]);
|
||||
const nextDashboardUrl = locationUtil.stripBaseFromUrl(url);
|
||||
|
||||
this.index++;
|
||||
this.validPlaylistUrl = nextDashboardUrl;
|
||||
@ -68,7 +68,7 @@ export class PlaylistSrv {
|
||||
}
|
||||
}
|
||||
|
||||
start(playlistUid: string) {
|
||||
async start(playlistUid: string) {
|
||||
this.stop();
|
||||
|
||||
this.startUrl = window.location.href;
|
||||
@ -78,17 +78,31 @@ export class PlaylistSrv {
|
||||
// setup location tracking
|
||||
this.locationListenerUnsub = locationService.getHistory().listen(this.locationUpdated);
|
||||
|
||||
return getBackendSrv()
|
||||
.get(`/api/playlists/${playlistUid}`)
|
||||
.then((playlist: any) => {
|
||||
return getBackendSrv()
|
||||
.get(`/api/playlists/${playlistUid}/dashboards`)
|
||||
.then((dashboards: any) => {
|
||||
this.dashboards = dashboards;
|
||||
this.interval = rangeUtil.intervalToMs(playlist.interval);
|
||||
this.next();
|
||||
});
|
||||
});
|
||||
const urls: string[] = [];
|
||||
let playlist = await getPlaylist(playlistUid);
|
||||
if (!playlist.items?.length) {
|
||||
// alert
|
||||
return;
|
||||
}
|
||||
this.interval = rangeUtil.intervalToMs(playlist.interval);
|
||||
|
||||
const items = await loadDashboards(playlist.items);
|
||||
for (const item of items) {
|
||||
if (item.dashboards) {
|
||||
for (const dash of item.dashboards) {
|
||||
urls.push(dash.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!urls.length) {
|
||||
// alert... not found, etc
|
||||
return;
|
||||
}
|
||||
this.urls = urls;
|
||||
this.isPlaying = true;
|
||||
this.next();
|
||||
return;
|
||||
}
|
||||
|
||||
stop() {
|
||||
|
@ -1,14 +1,11 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import { GrafanaRouteComponentProps } from '../../core/navigation/types';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
|
||||
import { playlistSrv } from './PlaylistSrv';
|
||||
|
||||
interface Props extends GrafanaRouteComponentProps<{ uid: string }> {}
|
||||
|
||||
export const PlaylistStartPage: FC<Props> = ({ match }) => {
|
||||
// This is a react page that just redirects to new URLs
|
||||
export default function PlaylistStartPage({ match }: Props) {
|
||||
playlistSrv.start(match.params.uid);
|
||||
return null;
|
||||
};
|
||||
|
||||
export default PlaylistStartPage;
|
||||
}
|
||||
|
@ -1,25 +1,38 @@
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import { PlaylistTableRows } from './PlaylistTableRows';
|
||||
import { PlaylistItem } from './types';
|
||||
|
||||
interface PlaylistTableProps {
|
||||
interface Props {
|
||||
items: PlaylistItem[];
|
||||
onMoveUp: (item: PlaylistItem) => void;
|
||||
onMoveDown: (item: PlaylistItem) => void;
|
||||
onDelete: (item: PlaylistItem) => void;
|
||||
deleteItem: (idx: number) => void;
|
||||
moveItem: (src: number, dst: number) => void;
|
||||
}
|
||||
|
||||
export const PlaylistTable: FC<PlaylistTableProps> = ({ items, onMoveUp, onMoveDown, onDelete }) => {
|
||||
export const PlaylistTable = ({ items, deleteItem, moveItem }: Props) => {
|
||||
const onDragEnd = (d: DropResult) => {
|
||||
if (d.destination) {
|
||||
moveItem(d.source.index, d.destination?.index);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="playlist-list" direction="vertical">
|
||||
{(provided) => {
|
||||
return (
|
||||
<div ref={provided.innerRef} {...provided.droppableProps}>
|
||||
<PlaylistTableRows items={items} onDelete={deleteItem} />
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,102 +0,0 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { FC, MouseEvent } from 'react';
|
||||
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Icon, IconButton, useStyles } from '@grafana/ui';
|
||||
|
||||
import { TagBadge } from '../../core/components/TagFilter/TagBadge';
|
||||
|
||||
import { PlaylistItem } from './types';
|
||||
|
||||
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;
|
||||
`,
|
||||
};
|
||||
}
|
@ -1,43 +1,124 @@
|
||||
import React, { FC } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import pluralize from 'pluralize';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Icon, IconButton, useStyles2, Spinner, IconName } from '@grafana/ui';
|
||||
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
|
||||
|
||||
import { PlaylistTableRow } from './PlaylistTableRow';
|
||||
import { PlaylistItem } from './types';
|
||||
|
||||
interface PlaylistTableRowsProps {
|
||||
interface Props {
|
||||
items: PlaylistItem[];
|
||||
onMoveUp: (item: PlaylistItem) => void;
|
||||
onMoveDown: (item: PlaylistItem) => void;
|
||||
onDelete: (item: PlaylistItem) => void;
|
||||
onDelete: (idx: number) => void;
|
||||
}
|
||||
|
||||
export const PlaylistTableRows: FC<PlaylistTableRowsProps> = ({ items, onMoveUp, onMoveDown, onDelete }) => {
|
||||
if (items.length === 0) {
|
||||
export const PlaylistTableRows = ({ items, onDelete }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
if (!items?.length) {
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<em>Playlist is empty. Add dashboards below.</em>
|
||||
</td>
|
||||
</tr>
|
||||
<div>
|
||||
<em>Playlist is empty. Add dashboards below.</em>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderItem = (item: PlaylistItem) => {
|
||||
let icon: IconName = item.type === 'dashboard_by_tag' ? 'apps' : 'tag-alt';
|
||||
const info: ReactNode[] = [];
|
||||
|
||||
const first = item.dashboards?.[0];
|
||||
if (!item.dashboards) {
|
||||
info.push(<Spinner key="spinner" />);
|
||||
} else if (item.type === 'dashboard_by_tag') {
|
||||
info.push(<TagBadge key={item.value} label={item.value} removeIcon={false} count={0} />);
|
||||
if (!first) {
|
||||
icon = 'exclamation-triangle';
|
||||
info.push(<span key="info"> No dashboards found</span>);
|
||||
} else {
|
||||
info.push(<span key="info"> {pluralize('dashboard', item.dashboards.length, true)}</span>);
|
||||
}
|
||||
} else if (first) {
|
||||
info.push(
|
||||
item.dashboards.length > 1 ? (
|
||||
<span key="info">Multiple items found: ${item.value}</span>
|
||||
) : (
|
||||
<span key="info">{first.name ?? item.value}</span>
|
||||
)
|
||||
);
|
||||
} else {
|
||||
icon = 'exclamation-triangle';
|
||||
info.push(<span key="info"> Not found: {item.value}</span>);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Icon name={icon} className={styles.rightMargin} key="icon" />
|
||||
{info}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{items.map((item, index) => (
|
||||
<Draggable key={`${index}/${item.value}`} draggableId={`${index}`} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
className={styles.row}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
role="row"
|
||||
>
|
||||
<div className={styles.actions} role="cell" aria-label={`Playlist item, ${item.type}, ${item.value}`}>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<IconButton
|
||||
name="times"
|
||||
size="md"
|
||||
onClick={() => onDelete(index)}
|
||||
aria-label={selectors.pages.PlaylistForm.itemDelete}
|
||||
type="button"
|
||||
/>
|
||||
<Icon title="Drag and drop to reorder" name="draggabledots" size="md" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
row: css`
|
||||
padding: 6px;
|
||||
background: ${theme.colors.background.secondary};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 3px;
|
||||
|
||||
border: 1px solid ${theme.colors.border.medium};
|
||||
&:hover {
|
||||
border: 1px solid ${theme.colors.border.strong};
|
||||
}
|
||||
`,
|
||||
rightMargin: css`
|
||||
margin-right: 5px;
|
||||
`,
|
||||
actions: css`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
`,
|
||||
settings: css`
|
||||
label: settings;
|
||||
text-align: right;
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
@ -2,17 +2,16 @@ import React, { useState } from 'react';
|
||||
|
||||
import { SelectableValue, UrlQueryMap, urlUtil } from '@grafana/data';
|
||||
import { Checkbox, ClipboardButton, Field, FieldSet, Input, Modal, RadioButtonGroup } from '@grafana/ui';
|
||||
|
||||
import { buildBaseUrl } from '../dashboard/components/ShareModal/utils';
|
||||
import { buildBaseUrl } from 'app/features/dashboard/components/ShareModal/utils';
|
||||
|
||||
import { PlaylistMode } from './types';
|
||||
|
||||
interface ShareModalProps {
|
||||
interface Props {
|
||||
playlistUid: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const ShareModal = ({ playlistUid, onDismiss }: ShareModalProps) => {
|
||||
export const ShareModal = ({ playlistUid, onDismiss }: Props) => {
|
||||
const [mode, setMode] = useState<PlaylistMode>(false);
|
||||
const [autoFit, setAutofit] = useState(false);
|
||||
|
||||
|
@ -1,17 +1,17 @@
|
||||
import React, { FC, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { SelectableValue, UrlQueryMap, urlUtil } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Button, Checkbox, Field, FieldSet, Modal, RadioButtonGroup } from '@grafana/ui';
|
||||
|
||||
import { PlaylistDTO, PlaylistMode } from './types';
|
||||
import { Playlist, PlaylistMode } from './types';
|
||||
|
||||
export interface StartModalProps {
|
||||
playlist: PlaylistDTO;
|
||||
export interface Props {
|
||||
playlist: Playlist;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const StartModal: FC<StartModalProps> = ({ playlist, onDismiss }) => {
|
||||
export const StartModal = ({ playlist, onDismiss }: Props) => {
|
||||
const [mode, setMode] = useState<PlaylistMode>(false);
|
||||
const [autoFit, setAutofit] = useState(false);
|
||||
|
||||
|
@ -1,10 +1,16 @@
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { notifyApp } from '../../core/actions';
|
||||
import { createErrorNotification, createSuccessNotification } from '../../core/copy/appNotification';
|
||||
import { dispatch } from '../../store/store';
|
||||
import { DataQueryRequest, DataFrameView } from '@grafana/data';
|
||||
import { getBackendSrv, config } from '@grafana/runtime';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { getGrafanaDatasource } from 'app/plugins/datasource/grafana/datasource';
|
||||
import { GrafanaQuery, GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
|
||||
import { dispatch } from 'app/store/store';
|
||||
|
||||
import { Playlist, PlaylistDTO } from './types';
|
||||
import { DashboardQueryResult, getGrafanaSearcher, SearchQuery } from '../search/service';
|
||||
|
||||
import { Playlist, PlaylistItem } from './types';
|
||||
|
||||
export async function createPlaylist(playlist: Playlist) {
|
||||
await withErrorHandling(() => getBackendSrv().post('/api/playlists', playlist));
|
||||
@ -18,14 +24,25 @@ export async function deletePlaylist(uid: string) {
|
||||
await withErrorHandling(() => getBackendSrv().delete(`/api/playlists/${uid}`), 'Playlist deleted');
|
||||
}
|
||||
|
||||
/** This returns a playlist where all ids are replaced with UIDs */
|
||||
export async function getPlaylist(uid: string): Promise<Playlist> {
|
||||
const result: Playlist = await getBackendSrv().get(`/api/playlists/${uid}`);
|
||||
return result;
|
||||
const playlist = await getBackendSrv().get<Playlist>(`/api/playlists/${uid}`);
|
||||
if (playlist.items) {
|
||||
for (const item of playlist.items) {
|
||||
if (item.type === 'dashboard_by_id') {
|
||||
item.type = 'dashboard_by_uid';
|
||||
const uids = await getBackendSrv().get<string[]>(`/api/dashboards/ids/${item.value}`);
|
||||
if (uids.length) {
|
||||
item.value = uids[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return playlist;
|
||||
}
|
||||
|
||||
export async function getAllPlaylist(query: string): Promise<PlaylistDTO[]> {
|
||||
const result: PlaylistDTO[] = await getBackendSrv().get('/api/playlists/', { query });
|
||||
return result;
|
||||
export async function getAllPlaylist(): Promise<Playlist[]> {
|
||||
return getBackendSrv().get<Playlist[]>('/api/playlists/');
|
||||
}
|
||||
|
||||
async function withErrorHandling(apiCall: () => Promise<void>, message = 'Playlist saved') {
|
||||
@ -38,3 +55,73 @@ async function withErrorHandling(apiCall: () => Promise<void>, message = 'Playli
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a copy with the dashboards loaded */
|
||||
export async function loadDashboards(items: PlaylistItem[]): Promise<PlaylistItem[]> {
|
||||
let idx = 0;
|
||||
if (!items?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targets: GrafanaQuery[] = [];
|
||||
for (const item of items) {
|
||||
const query: SearchQuery = {
|
||||
query: '*',
|
||||
kind: ['dashboard'],
|
||||
limit: 1000,
|
||||
};
|
||||
|
||||
switch (item.type) {
|
||||
case 'dashboard_by_id':
|
||||
throw new Error('invalid item (with id)');
|
||||
|
||||
case 'dashboard_by_uid':
|
||||
query.uid = [item.value];
|
||||
break;
|
||||
|
||||
case 'dashboard_by_tag':
|
||||
query.tags = [item.value];
|
||||
break;
|
||||
}
|
||||
targets.push({
|
||||
refId: `${idx++}`,
|
||||
queryType: GrafanaQueryType.Search,
|
||||
search: query,
|
||||
});
|
||||
}
|
||||
|
||||
// The SQL based store can only execute individual queries
|
||||
if (!config.featureToggles.panelTitleSearch) {
|
||||
const searcher = getGrafanaSearcher();
|
||||
const res: PlaylistItem[] = [];
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const view = (await searcher.search(targets[i].search!)).view;
|
||||
res.push({ ...items[i], dashboards: view.map((v) => ({ ...v })) });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// The bluge backend can execute multiple queries in a single request
|
||||
const ds = await getGrafanaDatasource();
|
||||
// eslint-disable-next-line
|
||||
const rsp = await lastValueFrom(ds.query({ targets } as unknown as DataQueryRequest<GrafanaQuery>));
|
||||
if (rsp.data.length !== items.length) {
|
||||
throw new Error('unexpected result size');
|
||||
}
|
||||
return items.map((item, idx) => {
|
||||
const view = new DataFrameView<DashboardQueryResult>(rsp.data[idx]);
|
||||
return { ...item, dashboards: view.map((v) => ({ ...v })) };
|
||||
});
|
||||
}
|
||||
|
||||
export function getDefaultPlaylist(): Playlist {
|
||||
return { items: [], interval: '5m', name: '', uid: '' };
|
||||
}
|
||||
|
||||
export function searchPlaylists(playlists: Playlist[], query?: string): Playlist[] {
|
||||
if (!query?.length) {
|
||||
return playlists;
|
||||
}
|
||||
query = query.toLowerCase();
|
||||
return playlists.filter((v) => v.name.toLowerCase().includes(query!));
|
||||
}
|
||||
|
@ -1,9 +1,4 @@
|
||||
export interface PlaylistDTO {
|
||||
id: number;
|
||||
name: string;
|
||||
startUrl?: string;
|
||||
uid: string;
|
||||
}
|
||||
import { DashboardQueryResult } from '../search/service';
|
||||
|
||||
export type PlaylistMode = boolean | 'tv';
|
||||
|
||||
@ -15,17 +10,16 @@ export interface PlayListItemDTO {
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
uid: string;
|
||||
name: string;
|
||||
interval: string;
|
||||
items?: PlaylistItem[];
|
||||
uid: string;
|
||||
}
|
||||
|
||||
export interface PlaylistItem {
|
||||
id?: number;
|
||||
value: string; //tag or id.toString()
|
||||
type: 'dashboard_by_id' | 'dashboard_by_tag';
|
||||
order: number;
|
||||
title: string;
|
||||
playlistId?: number;
|
||||
type: 'dashboard_by_tag' | 'dashboard_by_uid' | 'dashboard_by_id'; // _by_id is deprecated
|
||||
value: string; // tag or uid
|
||||
|
||||
// Loaded in the frontend
|
||||
dashboards?: DashboardQueryResult[];
|
||||
}
|
||||
|
@ -1,24 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getPlaylist } from './api';
|
||||
import { Playlist } from './types';
|
||||
|
||||
export function usePlaylist(playlistUid?: string) {
|
||||
const [playlist, setPlaylist] = useState<Playlist>({ items: [], interval: '5m', name: '', uid: '' });
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
const initPlaylist = async () => {
|
||||
if (!playlistUid) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const list = await getPlaylist(playlistUid);
|
||||
setPlaylist(list);
|
||||
setLoading(false);
|
||||
};
|
||||
initPlaylist();
|
||||
}, [playlistUid]);
|
||||
|
||||
return { playlist, loading };
|
||||
}
|
@ -1,26 +1,37 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { DashboardPickerItem } from 'app/core/components/OptionsUI/DashboardPickerByID';
|
||||
import { DashboardPickerDTO } from 'app/core/components/Select/DashboardPicker';
|
||||
|
||||
import { loadDashboards } from './api';
|
||||
import { PlaylistItem } from './types';
|
||||
|
||||
export function usePlaylistItems(playlistItems?: PlaylistItem[]) {
|
||||
const [items, setItems] = useState<PlaylistItem[]>(playlistItems ?? []);
|
||||
|
||||
// Attach dashboards if any were missing
|
||||
useAsync(async () => {
|
||||
for (const item of items) {
|
||||
if (!item.dashboards) {
|
||||
setItems(await loadDashboards(items));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
const addById = useCallback(
|
||||
(dashboard?: DashboardPickerItem) => {
|
||||
if (!dashboard || items.find((item) => item.id === dashboard.id)) {
|
||||
(dashboard?: DashboardPickerDTO) => {
|
||||
if (!dashboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItem: PlaylistItem = {
|
||||
id: dashboard.id,
|
||||
title: dashboard.label as string,
|
||||
type: 'dashboard_by_id',
|
||||
value: dashboard.id.toString(10),
|
||||
order: items.length + 1,
|
||||
};
|
||||
setItems([...items, newItem]);
|
||||
setItems([
|
||||
...items,
|
||||
{
|
||||
type: 'dashboard_by_uid',
|
||||
value: dashboard.uid,
|
||||
},
|
||||
]);
|
||||
},
|
||||
[items]
|
||||
);
|
||||
@ -33,51 +44,35 @@ export function usePlaylistItems(playlistItems?: PlaylistItem[]) {
|
||||
}
|
||||
|
||||
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);
|
||||
const moveItem = useCallback(
|
||||
(src: number, dst: number) => {
|
||||
if (src === dst || !items[src]) {
|
||||
return; // nothing to do
|
||||
}
|
||||
setItems(newItems);
|
||||
const update = Array.from(items);
|
||||
const [removed] = update.splice(src, 1);
|
||||
update.splice(dst, 0, removed);
|
||||
setItems(update);
|
||||
},
|
||||
[items]
|
||||
);
|
||||
|
||||
const moveUp = useCallback(
|
||||
(item: PlaylistItem) => {
|
||||
movePlaylistItem(item, -1);
|
||||
},
|
||||
[movePlaylistItem]
|
||||
);
|
||||
|
||||
const moveDown = useCallback(
|
||||
(item: PlaylistItem) => {
|
||||
movePlaylistItem(item, 1);
|
||||
},
|
||||
[movePlaylistItem]
|
||||
);
|
||||
|
||||
const deleteItem = useCallback(
|
||||
(item: PlaylistItem) => {
|
||||
setItems(items.filter((i) => i !== item));
|
||||
(index: number) => {
|
||||
const copy = items.slice();
|
||||
copy.splice(index, 1);
|
||||
setItems(copy);
|
||||
},
|
||||
[items]
|
||||
);
|
||||
|
||||
return { items, addById, addByTag, deleteItem, moveDown, moveUp };
|
||||
return { items, addById, addByTag, deleteItem, moveItem };
|
||||
}
|
||||
|
@ -15,7 +15,6 @@ export interface SearchQuery {
|
||||
kind?: string[];
|
||||
panel_type?: string;
|
||||
uid?: string[];
|
||||
id?: number[];
|
||||
facet?: FacetField[];
|
||||
explain?: boolean;
|
||||
withAllowedActions?: boolean;
|
||||
|
Loading…
Reference in New Issue
Block a user