Playlists: Migrate to UIDs and load dashboards in the frontend (#54125)

This commit is contained in:
Ryan McKinley 2022-09-05 20:40:01 -07:00 committed by GitHub
parent 3ea9ece16e
commit ac93cf1db2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 469 additions and 470 deletions

View File

@ -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"],

View File

@ -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',
},
};

View File

@ -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)

View File

@ -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(

View File

@ -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}
/>
);

View File

@ -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;

View File

@ -60,6 +60,10 @@ const setup = (propOverrides?: object) => {
};
describe('Render', () => {
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});
it('should render component', () => {
expect(() => setup()).not.toThrow();
});

View File

@ -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');
});

View File

@ -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>
);

View File

@ -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();
}

View File

@ -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>

View File

@ -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');
});

View File

@ -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}>

View File

@ -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',
},

View File

@ -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}

View File

@ -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"
>

View File

@ -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);
});
});

View File

@ -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() {

View File

@ -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;
}

View File

@ -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>
);
};

View File

@ -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;
`,
};
}

View File

@ -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">&nbsp; No dashboards found</span>);
} else {
info.push(<span key="info">&nbsp; {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">&nbsp; 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;
`,
};
}

View File

@ -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);

View File

@ -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);

View File

@ -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!));
}

View File

@ -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[];
}

View File

@ -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 };
}

View File

@ -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 };
}

View File

@ -15,7 +15,6 @@ export interface SearchQuery {
kind?: string[];
panel_type?: string;
uid?: string[];
id?: number[];
facet?: FacetField[];
explain?: boolean;
withAllowedActions?: boolean;