Playlists: Migrate to UIDs and load dashboards in the frontend (#54125)
This commit is contained in:
+1
-12
@@ -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.", "1"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
[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.", "3"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
|
[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"]
|
|
||||||
],
|
],
|
||||||
"public/app/features/plugins/__mocks__/pluginMocks.ts:5381": [
|
"public/app/features/plugins/__mocks__/pluginMocks.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||||
|
|||||||
@@ -218,11 +218,6 @@ export const Pages = {
|
|||||||
PlaylistForm: {
|
PlaylistForm: {
|
||||||
name: 'Playlist name',
|
name: 'Playlist name',
|
||||||
interval: 'Playlist interval',
|
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',
|
itemDelete: 'Delete playlist item',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ func (hs *HTTPServer) populateDashboardsByTag(ctx context.Context, orgID int64,
|
|||||||
return result
|
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) {
|
func (hs *HTTPServer) LoadPlaylistDashboards(ctx context.Context, orgID int64, signedInUser *user.SignedInUser, playlistUID string) (dtos.PlaylistDashboardsSlice, error) {
|
||||||
playlistItems, _ := hs.LoadPlaylistItems(ctx, playlistUID, orgID)
|
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 { SelectableValue, StandardEditorProps } from '@grafana/data';
|
||||||
import { DashboardPicker as BasePicker, DashboardPickerDTO } from 'app/core/components/Select/DashboardPicker';
|
import { DashboardPicker as BasePicker, DashboardPickerDTO } from 'app/core/components/Select/DashboardPicker';
|
||||||
@@ -8,12 +8,10 @@ export interface DashboardPickerOptions {
|
|||||||
isClearable?: boolean;
|
isClearable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Props = StandardEditorProps<string, DashboardPickerOptions, any>;
|
||||||
|
|
||||||
/** This will return the item UID */
|
/** This will return the item UID */
|
||||||
export const DashboardPicker: FC<StandardEditorProps<string, DashboardPickerOptions, any>> = ({
|
export const DashboardPicker = ({ value, onChange, item }: Props) => {
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
item,
|
|
||||||
}) => {
|
|
||||||
const { placeholder, isClearable } = item?.settings ?? {};
|
const { placeholder, isClearable } = item?.settings ?? {};
|
||||||
|
|
||||||
const onPicked = useCallback(
|
const onPicked = useCallback(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export type DashboardPickerDTO = Pick<DashboardDTO['dashboard'], 'uid' | 'title'
|
|||||||
|
|
||||||
const formatLabel = (folderTitle = 'General', dashboardTitle: string) => `${folderTitle}/${dashboardTitle}`;
|
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 backendSrv.search({ type: 'dash-db', query, limit: 100 }).then((result: DashboardSearchItem[]) => {
|
||||||
return result.map((item: DashboardSearchItem) => ({
|
return result.map((item: DashboardSearchItem) => ({
|
||||||
value: {
|
value: {
|
||||||
@@ -30,7 +30,9 @@ const getDashboards = debounce((query = ''): Promise<Array<SelectableValue<Dashb
|
|||||||
label: formatLabel(item?.folderTitle, item.title),
|
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
|
// TODO: this component should provide a way to apply different filters to the search APIs
|
||||||
export const DashboardPicker = ({
|
export const DashboardPicker = ({
|
||||||
@@ -84,6 +86,7 @@ export const DashboardPicker = ({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
noOptionsMessage={noOptionsMessage}
|
noOptionsMessage={noOptionsMessage}
|
||||||
value={current}
|
value={current}
|
||||||
|
defaultOptions={true}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoa
|
|||||||
import { DashboardSrv, getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
import { DashboardSrv, getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||||
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||||
|
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
|
||||||
import { toStateKey } from 'app/features/variables/utils';
|
import { toStateKey } from 'app/features/variables/utils';
|
||||||
import { DashboardDTO, DashboardInitPhase, DashboardRoutes, StoreState, ThunkDispatch, ThunkResult } from 'app/types';
|
import { DashboardDTO, DashboardInitPhase, DashboardRoutes, StoreState, ThunkDispatch, ThunkResult } from 'app/types';
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ async function fetchDashboard(
|
|||||||
case DashboardRoutes.Normal: {
|
case DashboardRoutes.Normal: {
|
||||||
const dashDTO: DashboardDTO = await dashboardLoaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid);
|
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)
|
// check if the current url is correct (might be old slug)
|
||||||
const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url);
|
const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url);
|
||||||
const currentPath = locationService.getLocation().pathname;
|
const currentPath = locationService.getLocation().pathname;
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ const setup = (propOverrides?: object) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('Render', () => {
|
describe('Render', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
it('should render component', () => {
|
it('should render component', () => {
|
||||||
expect(() => setup()).not.toThrow();
|
expect(() => setup()).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jest.mock('@grafana/runtime', () => ({
|
|||||||
getBackendSrv: () => backendSrv,
|
getBackendSrv: () => backendSrv,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../core/components/TagFilter/TagFilter', () => ({
|
jest.mock('app/core/components/TagFilter/TagFilter', () => ({
|
||||||
TagFilter: () => {
|
TagFilter: () => {
|
||||||
return <>mocked-tag-filter</>;
|
return <>mocked-tag-filter</>;
|
||||||
},
|
},
|
||||||
@@ -32,7 +32,7 @@ async function getTestContext({ name, interval, items, uid }: Partial<Playlist>
|
|||||||
getMock.mockResolvedValue({
|
getMock.mockResolvedValue({
|
||||||
name: 'Test Playlist',
|
name: 'Test Playlist',
|
||||||
interval: '5s',
|
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',
|
uid: 'foo',
|
||||||
});
|
});
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
@@ -51,7 +51,7 @@ describe('PlaylistEditPage', () => {
|
|||||||
expect(screen.getByRole('heading', { name: /edit playlist/i })).toBeInTheDocument();
|
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 name/i })).toHaveValue('Test Playlist');
|
||||||
expect(screen.getByRole('textbox', { name: /playlist interval/i })).toHaveValue('5s');
|
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', {
|
expect(putMock).toHaveBeenCalledWith('/api/playlists/foo', {
|
||||||
name: 'A Name',
|
name: 'A Name',
|
||||||
interval: '10s',
|
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');
|
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 { locationService } from '@grafana/runtime';
|
||||||
import { useStyles2 } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
|
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||||
import { GrafanaRouteComponentProps } from '../../core/navigation/types';
|
|
||||||
|
|
||||||
import { PlaylistForm } from './PlaylistForm';
|
import { PlaylistForm } from './PlaylistForm';
|
||||||
import { updatePlaylist } from './api';
|
import { getPlaylist, updatePlaylist } from './api';
|
||||||
import { getPlaylistStyles } from './styles';
|
import { getPlaylistStyles } from './styles';
|
||||||
import { Playlist } from './types';
|
import { Playlist } from './types';
|
||||||
import { usePlaylist } from './usePlaylist';
|
|
||||||
|
|
||||||
export interface RouteParams {
|
export interface RouteParams {
|
||||||
uid: string;
|
uid: string;
|
||||||
@@ -18,9 +17,10 @@ export interface RouteParams {
|
|||||||
|
|
||||||
interface Props extends GrafanaRouteComponentProps<RouteParams> {}
|
interface Props extends GrafanaRouteComponentProps<RouteParams> {}
|
||||||
|
|
||||||
export const PlaylistEditPage: FC<Props> = ({ match }) => {
|
export const PlaylistEditPage = ({ match }: Props) => {
|
||||||
const styles = useStyles2(getPlaylistStyles);
|
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) => {
|
const onSubmit = async (playlist: Playlist) => {
|
||||||
await updatePlaylist(match.params.uid, playlist);
|
await updatePlaylist(match.params.uid, playlist);
|
||||||
locationService.push('/playlists');
|
locationService.push('/playlists');
|
||||||
@@ -28,15 +28,21 @@ export const PlaylistEditPage: FC<Props> = ({ match }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page navId="dashboards/playlists">
|
<Page navId="dashboards/playlists">
|
||||||
<Page.Contents isLoading={loading}>
|
<Page.Contents isLoading={playlist.loading}>
|
||||||
<h3 className={styles.subHeading}>Edit playlist</h3>
|
<h3 className={styles.subHeading}>Edit playlist</h3>
|
||||||
|
|
||||||
<p className={styles.description}>
|
{playlist.error && <div>Error loading playlist: {JSON.stringify(playlist.error)}</div>}
|
||||||
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} />
|
{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.Contents>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import React from 'react';
|
|||||||
import { PlaylistForm } from './PlaylistForm';
|
import { PlaylistForm } from './PlaylistForm';
|
||||||
import { Playlist } from './types';
|
import { Playlist } from './types';
|
||||||
|
|
||||||
jest.mock('../../core/components/TagFilter/TagFilter', () => ({
|
jest.mock('app/core/components/TagFilter/TagFilter', () => ({
|
||||||
TagFilter: () => {
|
TagFilter: () => {
|
||||||
return <>mocked-tag-filter</>;
|
return <>mocked-tag-filter</>;
|
||||||
},
|
},
|
||||||
@@ -24,25 +24,29 @@ const playlist: Playlist = {
|
|||||||
name: 'A test playlist',
|
name: 'A test playlist',
|
||||||
interval: '10m',
|
interval: '10m',
|
||||||
items: [
|
items: [
|
||||||
{ title: 'First item', type: 'dashboard_by_id', order: 1, value: '1' },
|
{ type: 'dashboard_by_uid', value: 'uid_1' },
|
||||||
{ title: 'Middle item', type: 'dashboard_by_id', order: 2, value: '2' },
|
{ type: 'dashboard_by_uid', value: 'uid_2' },
|
||||||
{ title: 'Last item', type: 'dashboard_by_tag', order: 2, value: 'Last item' },
|
{ type: 'dashboard_by_tag', value: 'tag_A' },
|
||||||
],
|
],
|
||||||
uid: 'foo',
|
uid: 'foo',
|
||||||
};
|
};
|
||||||
|
|
||||||
function rows() {
|
function rows() {
|
||||||
return screen.getAllByRole('row', { name: /playlist item row/i });
|
return screen.getAllByRole('row');
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('PlaylistForm', () => {
|
describe('PlaylistForm', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
describe('when mounted without playlist', () => {
|
describe('when mounted without playlist', () => {
|
||||||
it('then it should contain name and interval fields', () => {
|
it('then it should contain name and interval fields', () => {
|
||||||
getTestContext();
|
getTestContext();
|
||||||
|
|
||||||
expect(screen.getByRole('textbox', { name: /playlist name/i })).toBeInTheDocument();
|
expect(screen.getByRole('textbox', { name: /playlist name/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('textbox', { name: /playlist interval/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', () => {
|
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', () => {
|
it('then items row count should be correct', () => {
|
||||||
getTestContext(playlist);
|
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', () => {
|
it('then the first item row should be correct', () => {
|
||||||
getTestContext(playlist);
|
getTestContext(playlist);
|
||||||
|
|
||||||
expectCorrectRow({ index: 0, type: 'id', title: 'first item', first: 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' });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,30 +97,8 @@ describe('PlaylistForm', () => {
|
|||||||
expect(rows()).toHaveLength(3);
|
expect(rows()).toHaveLength(3);
|
||||||
await userEvent.click(within(rows()[2]).getByRole('button', { name: /delete playlist item/i }));
|
await userEvent.click(within(rows()[2]).getByRole('button', { name: /delete playlist item/i }));
|
||||||
expect(rows()).toHaveLength(2);
|
expect(rows()).toHaveLength(2);
|
||||||
expectCorrectRow({ index: 0, type: 'id', title: 'first item', first: true });
|
expectCorrectRow({ index: 0, type: 'dashboard_by_uid', value: 'uid_1' });
|
||||||
expectCorrectRow({ index: 1, type: 'id', title: 'middle item', last: true });
|
expectCorrectRow({ index: 1, type: 'dashboard_by_uid', value: 'uid_2' });
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,9 +112,9 @@ describe('PlaylistForm', () => {
|
|||||||
name: 'A test playlist',
|
name: 'A test playlist',
|
||||||
interval: '10m',
|
interval: '10m',
|
||||||
items: [
|
items: [
|
||||||
{ title: 'First item', type: 'dashboard_by_id', order: 1, value: '1' },
|
{ type: 'dashboard_by_uid', value: 'uid_1' },
|
||||||
{ title: 'Middle item', type: 'dashboard_by_id', order: 2, value: '2' },
|
{ type: 'dashboard_by_uid', value: 'uid_2' },
|
||||||
{ title: 'Last item', type: 'dashboard_by_tag', order: 2, value: 'Last item' },
|
{ type: 'dashboard_by_tag', value: 'tag_A' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -180,28 +152,13 @@ describe('PlaylistForm', () => {
|
|||||||
|
|
||||||
interface ExpectCorrectRowArgs {
|
interface ExpectCorrectRowArgs {
|
||||||
index: number;
|
index: number;
|
||||||
type: 'id' | 'tag';
|
type: 'dashboard_by_tag' | 'dashboard_by_uid';
|
||||||
title: string;
|
value: string;
|
||||||
first?: boolean;
|
|
||||||
last?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectCorrectRow({ index, type, title, first = false, last = false }: ExpectCorrectRowArgs) {
|
function expectCorrectRow({ index, type, value }: ExpectCorrectRowArgs) {
|
||||||
const row = within(rows()[index]);
|
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');
|
const regex = new RegExp(cell, 'i');
|
||||||
expect(row.getByRole('cell', { name: regex })).toBeInTheDocument();
|
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 { selectors } from '@grafana/e2e-selectors';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
import { Button, Field, Form, HorizontalGroup, Input, LinkButton } from '@grafana/ui';
|
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 { getGrafanaSearcher } from '../search/service';
|
||||||
import { SearchSrv } from '../../core/services/search_srv';
|
|
||||||
|
|
||||||
import { PlaylistTable } from './PlaylistTable';
|
import { PlaylistTable } from './PlaylistTable';
|
||||||
import { Playlist } from './types';
|
import { Playlist } from './types';
|
||||||
import { usePlaylistItems } from './usePlaylistItems';
|
import { usePlaylistItems } from './usePlaylistItems';
|
||||||
|
|
||||||
interface PlaylistFormProps {
|
interface Props {
|
||||||
onSubmit: (playlist: Playlist) => void;
|
onSubmit: (playlist: Playlist) => void;
|
||||||
playlist: Playlist;
|
playlist: Playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchSrv = new SearchSrv();
|
export const PlaylistForm = ({ onSubmit, playlist }: Props) => {
|
||||||
|
|
||||||
export const PlaylistForm: FC<PlaylistFormProps> = ({ onSubmit, playlist }) => {
|
|
||||||
const { name, interval, items: propItems } = playlist;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Form onSubmit={(list: Playlist) => onSubmit({ ...list, items })} validateOn={'onBlur'}>
|
<Form onSubmit={(list: Playlist) => onSubmit({ ...list, items })} validateOn={'onBlur'}>
|
||||||
@@ -48,13 +51,13 @@ export const PlaylistForm: FC<PlaylistFormProps> = ({ onSubmit, playlist }) => {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<PlaylistTable items={items} onMoveUp={moveUp} onMoveDown={moveDown} onDelete={deleteItem} />
|
<PlaylistTable items={items} deleteItem={deleteItem} moveItem={moveItem} />
|
||||||
|
|
||||||
<div className="gf-form-group">
|
<div className="gf-form-group">
|
||||||
<h3 className="page-headering">Add dashboards</h3>
|
<h3 className="page-headering">Add dashboards</h3>
|
||||||
|
|
||||||
<Field label="Add by title">
|
<Field label="Add by title">
|
||||||
<DashboardPickerByID onChange={addById} id="dashboard-picker" isClearable />
|
<DashboardPicker id="dashboard-picker" onChange={addById} key={items.length} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Add by tag">
|
<Field label="Add by tag">
|
||||||
@@ -62,9 +65,9 @@ export const PlaylistForm: FC<PlaylistFormProps> = ({ onSubmit, playlist }) => {
|
|||||||
isClearable
|
isClearable
|
||||||
tags={[]}
|
tags={[]}
|
||||||
hideValues
|
hideValues
|
||||||
tagOptions={searchSrv.getDashboardTags}
|
tagOptions={tagOptions}
|
||||||
onChange={addByTag}
|
onChange={addByTag}
|
||||||
placeholder={''}
|
placeholder="Select a tag"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,26 +2,19 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
import { backendSrv } from '../../core/services/backend_srv';
|
|
||||||
|
|
||||||
import { PlaylistNewPage } from './PlaylistNewPage';
|
import { PlaylistNewPage } from './PlaylistNewPage';
|
||||||
import { Playlist } from './types';
|
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.mock('@grafana/runtime', () => ({
|
||||||
...jest.requireActual('@grafana/runtime'),
|
...jest.requireActual('@grafana/runtime'),
|
||||||
getBackendSrv: () => backendSrv,
|
getBackendSrv: () => backendSrv,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../core/components/TagFilter/TagFilter', () => ({
|
jest.mock('app/core/components/TagFilter/TagFilter', () => ({
|
||||||
TagFilter: () => {
|
TagFilter: () => {
|
||||||
return <>mocked-tag-filter</>;
|
return <>mocked-tag-filter</>;
|
||||||
},
|
},
|
||||||
@@ -38,6 +31,10 @@ function getTestContext({ name, interval, items }: Partial<Playlist> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('PlaylistNewPage', () => {
|
describe('PlaylistNewPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
describe('when mounted', () => {
|
describe('when mounted', () => {
|
||||||
it('then header should be correct', () => {
|
it('then header should be correct', () => {
|
||||||
getTestContext();
|
getTestContext();
|
||||||
@@ -51,13 +48,14 @@ describe('PlaylistNewPage', () => {
|
|||||||
const { backendSrvMock } = getTestContext();
|
const { backendSrvMock } = getTestContext();
|
||||||
|
|
||||||
expect(locationService.getLocation().pathname).toEqual('/');
|
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 }));
|
fireEvent.submit(screen.getByRole('button', { name: /save/i }));
|
||||||
await waitFor(() => expect(backendSrvMock).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(backendSrvMock).toHaveBeenCalledTimes(1));
|
||||||
expect(backendSrvMock).toHaveBeenCalledWith('/api/playlists', {
|
expect(backendSrvMock).toHaveBeenCalledWith('/api/playlists', {
|
||||||
name: 'A Name',
|
name: 'A new name',
|
||||||
interval: '5m',
|
interval: '5m',
|
||||||
items: [{ title: 'First item', type: 'dashboard_by_id', order: 1, value: '1' }],
|
items: [],
|
||||||
});
|
});
|
||||||
expect(locationService.getLocation().pathname).toEqual('/playlists');
|
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 { locationService } from '@grafana/runtime';
|
||||||
import { useStyles2 } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
|
|
||||||
import { PlaylistForm } from './PlaylistForm';
|
import { PlaylistForm } from './PlaylistForm';
|
||||||
import { createPlaylist } from './api';
|
import { createPlaylist, getDefaultPlaylist } from './api';
|
||||||
import { getPlaylistStyles } from './styles';
|
import { getPlaylistStyles } from './styles';
|
||||||
import { Playlist } from './types';
|
import { Playlist } from './types';
|
||||||
import { usePlaylist } from './usePlaylist';
|
|
||||||
|
|
||||||
export const PlaylistNewPage = () => {
|
export const PlaylistNewPage = () => {
|
||||||
const styles = useStyles2(getPlaylistStyles);
|
const styles = useStyles2(getPlaylistStyles);
|
||||||
const { playlist, loading } = usePlaylist();
|
const [playlist] = useState<Playlist>(getDefaultPlaylist());
|
||||||
|
|
||||||
const onSubmit = async (playlist: Playlist) => {
|
const onSubmit = async (playlist: Playlist) => {
|
||||||
await createPlaylist(playlist);
|
await createPlaylist(playlist);
|
||||||
locationService.push('/playlists');
|
locationService.push('/playlists');
|
||||||
@@ -20,7 +20,7 @@ export const PlaylistNewPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page navId="dashboards/playlists">
|
<Page navId="dashboards/playlists">
|
||||||
<Page.Contents isLoading={loading}>
|
<Page.Contents>
|
||||||
<h3 className={styles.subHeading}>New Playlist</h3>
|
<h3 className={styles.subHeading}>New Playlist</h3>
|
||||||
|
|
||||||
<p className={styles.description}>
|
<p className={styles.description}>
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ function getTestContext() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('PlaylistPage', () => {
|
describe('PlaylistPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
describe('when mounted without a playlist', () => {
|
describe('when mounted without a playlist', () => {
|
||||||
it('page should load', () => {
|
it('page should load', () => {
|
||||||
fnMock.mockResolvedValue([]);
|
fnMock.mockResolvedValue([]);
|
||||||
@@ -60,9 +64,9 @@ describe('PlaylistPage', () => {
|
|||||||
name: 'A test playlist',
|
name: 'A test playlist',
|
||||||
interval: '10m',
|
interval: '10m',
|
||||||
items: [
|
items: [
|
||||||
{ title: 'First item', type: 'dashboard_by_id', order: 1, value: '1' },
|
{ title: 'First item', type: 'dashboard_by_uid', value: '1' },
|
||||||
{ title: 'Middle item', type: 'dashboard_by_id', order: 2, value: '2' },
|
{ title: 'Middle item', type: 'dashboard_by_uid', value: '2' },
|
||||||
{ title: 'Last item', type: 'dashboard_by_tag', order: 2, value: 'Last item' },
|
{ title: 'Last item', type: 'dashboard_by_tag', value: 'Last item' },
|
||||||
],
|
],
|
||||||
uid: 'playlist-0',
|
uid: 'playlist-0',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,41 +1,26 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useDebounce } from 'react-use';
|
import { useAsync } from 'react-use';
|
||||||
|
|
||||||
import { ConfirmModal } from '@grafana/ui';
|
import { ConfirmModal } from '@grafana/ui';
|
||||||
|
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import PageActionBar from 'app/core/components/PageActionBar/PageActionBar';
|
import PageActionBar from 'app/core/components/PageActionBar/PageActionBar';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
|
|
||||||
import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
|
|
||||||
|
|
||||||
import { EmptyQueryListBanner } from './EmptyQueryListBanner';
|
import { EmptyQueryListBanner } from './EmptyQueryListBanner';
|
||||||
import { PlaylistPageList } from './PlaylistPageList';
|
import { PlaylistPageList } from './PlaylistPageList';
|
||||||
import { StartModal } from './StartModal';
|
import { StartModal } from './StartModal';
|
||||||
import { deletePlaylist, getAllPlaylist } from './api';
|
import { deletePlaylist, getAllPlaylist, searchPlaylists } from './api';
|
||||||
import { PlaylistDTO } from './types';
|
import { Playlist } from './types';
|
||||||
|
|
||||||
export const PlaylistPage = () => {
|
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 [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[]>([]);
|
const [startPlaylist, setStartPlaylist] = useState<Playlist | undefined>();
|
||||||
|
const [playlistToDelete, setPlaylistToDelete] = useState<Playlist | undefined>();
|
||||||
useDebounce(
|
|
||||||
async () => {
|
|
||||||
const playlists = await getAllPlaylist(searchQuery);
|
|
||||||
if (!hasFetched) {
|
|
||||||
setHasFetched(true);
|
|
||||||
}
|
|
||||||
setPlaylists(playlists);
|
|
||||||
setDebouncedSearchQuery(searchQuery);
|
|
||||||
},
|
|
||||||
350,
|
|
||||||
[forcePlaylistsFetch, searchQuery]
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasPlaylists = playlists && playlists.length > 0;
|
const hasPlaylists = playlists && playlists.length > 0;
|
||||||
const onDismissDelete = () => setPlaylistToDelete(undefined);
|
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 (
|
return (
|
||||||
<Page navId="dashboards/playlists">
|
<Page navId="dashboards/playlists">
|
||||||
<Page.Contents isLoading={!hasFetched}>
|
<Page.Contents isLoading={allPlaylists.loading}>
|
||||||
{showSearch && (
|
{showSearch && (
|
||||||
<PageActionBar
|
<PageActionBar
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
|
|||||||
@@ -4,23 +4,22 @@ import React from 'react';
|
|||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Button, Card, LinkButton, ModalsController, useStyles2 } from '@grafana/ui';
|
import { Button, Card, LinkButton, ModalsController, useStyles2 } from '@grafana/ui';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
|
import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton';
|
||||||
import { DashNavButton } from '../dashboard/components/DashNav/DashNavButton';
|
|
||||||
|
|
||||||
import { ShareModal } from './ShareModal';
|
import { ShareModal } from './ShareModal';
|
||||||
import { PlaylistDTO } from './types';
|
import { Playlist } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
setStartPlaylist: (playlistItem: PlaylistDTO) => void;
|
setStartPlaylist: (playlistItem: Playlist) => void;
|
||||||
setPlaylistToDelete: (playlistItem: PlaylistDTO) => void;
|
setPlaylistToDelete: (playlistItem: Playlist) => void;
|
||||||
playlists: PlaylistDTO[] | undefined;
|
playlists: Playlist[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlaylistPageList = ({ playlists, setStartPlaylist, setPlaylistToDelete }: Props) => {
|
export const PlaylistPageList = ({ playlists, setStartPlaylist, setPlaylistToDelete }: Props) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
return (
|
return (
|
||||||
<ul className={styles.list}>
|
<ul className={styles.list}>
|
||||||
{playlists!.map((playlist: PlaylistDTO) => (
|
{playlists!.map((playlist: Playlist) => (
|
||||||
<li className={styles.listItem} key={playlist.uid}>
|
<li className={styles.listItem} key={playlist.uid}>
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Heading>
|
<Card.Heading>
|
||||||
@@ -52,7 +51,7 @@ export const PlaylistPageList = ({ playlists, setStartPlaylist, setPlaylistToDel
|
|||||||
</LinkButton>
|
</LinkButton>
|
||||||
<Button
|
<Button
|
||||||
disabled={false}
|
disabled={false}
|
||||||
onClick={() => setPlaylistToDelete({ id: playlist.id, uid: playlist.uid, name: playlist.name })}
|
onClick={() => setPlaylistToDelete(playlist)}
|
||||||
icon="trash-alt"
|
icon="trash-alt"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,30 +4,39 @@ import configureMockStore from 'redux-mock-store';
|
|||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import { setStore } from 'app/store/store';
|
import { setStore } from 'app/store/store';
|
||||||
|
|
||||||
|
import { DashboardQueryResult } from '../search/service';
|
||||||
|
|
||||||
import { PlaylistSrv } from './PlaylistSrv';
|
import { PlaylistSrv } from './PlaylistSrv';
|
||||||
|
import { Playlist, PlaylistItem } from './types';
|
||||||
|
|
||||||
const getMock = jest.fn();
|
jest.mock('./api', () => ({
|
||||||
|
getPlaylist: jest.fn().mockReturnValue({
|
||||||
jest.mock('@grafana/runtime', () => {
|
interval: '1s',
|
||||||
const original = jest.requireActual('@grafana/runtime');
|
uid: 'xyz',
|
||||||
return {
|
items: [
|
||||||
...original,
|
{ type: 'dashboard_by_uid', value: 'aaa' },
|
||||||
getBackendSrv: () => ({
|
{ type: 'dashboard_by_uid', value: 'bbb' },
|
||||||
get: getMock,
|
],
|
||||||
}),
|
} 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();
|
const mockStore = configureMockStore();
|
||||||
|
|
||||||
setStore(
|
setStore(
|
||||||
|
// eslint-disable-next-line
|
||||||
mockStore({
|
mockStore({
|
||||||
location: {},
|
location: {},
|
||||||
}) as any
|
}) as any
|
||||||
);
|
);
|
||||||
|
|
||||||
const dashboards = [{ url: '/dash1' }, { url: '/dash2' }];
|
|
||||||
|
|
||||||
function createPlaylistSrv(): PlaylistSrv {
|
function createPlaylistSrv(): PlaylistSrv {
|
||||||
locationService.push('/playlists/foo');
|
locationService.push('/playlists/foo');
|
||||||
return new PlaylistSrv();
|
return new PlaylistSrv();
|
||||||
@@ -41,6 +50,8 @@ const mockWindowLocation = (): [jest.MockInstance<any, any>, () => void] => {
|
|||||||
// https://github.com/facebook/jest/issues/5124#issuecomment-446659510
|
// https://github.com/facebook/jest/issues/5124#issuecomment-446659510
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
delete window.location;
|
delete window.location;
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
window.location = {} as any;
|
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
|
// 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(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
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();
|
srv = createPlaylistSrv();
|
||||||
[hrefMock, unmockLocation] = mockWindowLocation();
|
[hrefMock, unmockLocation] = mockWindowLocation();
|
||||||
@@ -129,9 +128,13 @@ describe('PlaylistSrv', () => {
|
|||||||
it('storeUpdated should not stop playlist when navigating to next dashboard', async () => {
|
it('storeUpdated should not stop playlist when navigating to next dashboard', async () => {
|
||||||
await srv.start('foo');
|
await srv.start('foo');
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
expect((srv as any).validPlaylistUrl).toBe('/url/to/aaa');
|
||||||
|
|
||||||
srv.next();
|
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);
|
expect(srv.isPlaying).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// Libraries
|
|
||||||
import { Location } from 'history';
|
import { Location } from 'history';
|
||||||
import { pickBy } from 'lodash';
|
import { pickBy } from 'lodash';
|
||||||
|
|
||||||
// Utils
|
|
||||||
import { locationUtil, urlUtil, rangeUtil } from '@grafana/data';
|
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 } = {
|
export const queryParamsToPreserve: { [key: string]: boolean } = {
|
||||||
kiosk: true,
|
kiosk: true,
|
||||||
@@ -13,8 +13,8 @@ export const queryParamsToPreserve: { [key: string]: boolean } = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class PlaylistSrv {
|
export class PlaylistSrv {
|
||||||
private nextTimeoutId: any;
|
private nextTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
private declare dashboards: Array<{ url: string }>;
|
private urls: string[] = []; // the URLs we need to load
|
||||||
private index = 0;
|
private index = 0;
|
||||||
private declare interval: number;
|
private declare interval: number;
|
||||||
private declare startUrl: string;
|
private declare startUrl: string;
|
||||||
@@ -31,7 +31,7 @@ export class PlaylistSrv {
|
|||||||
next() {
|
next() {
|
||||||
clearTimeout(this.nextTimeoutId);
|
clearTimeout(this.nextTimeoutId);
|
||||||
|
|
||||||
const playedAllDashboards = this.index > this.dashboards.length - 1;
|
const playedAllDashboards = this.index > this.urls.length - 1;
|
||||||
if (playedAllDashboards) {
|
if (playedAllDashboards) {
|
||||||
this.numberOfLoops++;
|
this.numberOfLoops++;
|
||||||
|
|
||||||
@@ -44,10 +44,10 @@ export class PlaylistSrv {
|
|||||||
this.index = 0;
|
this.index = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dash = this.dashboards[this.index];
|
const url = this.urls[this.index];
|
||||||
const queryParams = locationService.getSearchObject();
|
const queryParams = locationService.getSearchObject();
|
||||||
const filteredParams = pickBy(queryParams, (value: any, key: string) => queryParamsToPreserve[key]);
|
const filteredParams = pickBy(queryParams, (value: unknown, key: string) => queryParamsToPreserve[key]);
|
||||||
const nextDashboardUrl = locationUtil.stripBaseFromUrl(dash.url);
|
const nextDashboardUrl = locationUtil.stripBaseFromUrl(url);
|
||||||
|
|
||||||
this.index++;
|
this.index++;
|
||||||
this.validPlaylistUrl = nextDashboardUrl;
|
this.validPlaylistUrl = nextDashboardUrl;
|
||||||
@@ -68,7 +68,7 @@ export class PlaylistSrv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
start(playlistUid: string) {
|
async start(playlistUid: string) {
|
||||||
this.stop();
|
this.stop();
|
||||||
|
|
||||||
this.startUrl = window.location.href;
|
this.startUrl = window.location.href;
|
||||||
@@ -78,17 +78,31 @@ export class PlaylistSrv {
|
|||||||
// setup location tracking
|
// setup location tracking
|
||||||
this.locationListenerUnsub = locationService.getHistory().listen(this.locationUpdated);
|
this.locationListenerUnsub = locationService.getHistory().listen(this.locationUpdated);
|
||||||
|
|
||||||
return getBackendSrv()
|
const urls: string[] = [];
|
||||||
.get(`/api/playlists/${playlistUid}`)
|
let playlist = await getPlaylist(playlistUid);
|
||||||
.then((playlist: any) => {
|
if (!playlist.items?.length) {
|
||||||
return getBackendSrv()
|
// alert
|
||||||
.get(`/api/playlists/${playlistUid}/dashboards`)
|
return;
|
||||||
.then((dashboards: any) => {
|
}
|
||||||
this.dashboards = dashboards;
|
this.interval = rangeUtil.intervalToMs(playlist.interval);
|
||||||
this.interval = rangeUtil.intervalToMs(playlist.interval);
|
|
||||||
this.next();
|
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() {
|
stop() {
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { FC } from 'react';
|
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||||
|
|
||||||
import { GrafanaRouteComponentProps } from '../../core/navigation/types';
|
|
||||||
|
|
||||||
import { playlistSrv } from './PlaylistSrv';
|
import { playlistSrv } from './PlaylistSrv';
|
||||||
|
|
||||||
interface Props extends GrafanaRouteComponentProps<{ uid: string }> {}
|
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);
|
playlistSrv.start(match.params.uid);
|
||||||
return null;
|
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 { PlaylistTableRows } from './PlaylistTableRows';
|
||||||
import { PlaylistItem } from './types';
|
import { PlaylistItem } from './types';
|
||||||
|
|
||||||
interface PlaylistTableProps {
|
interface Props {
|
||||||
items: PlaylistItem[];
|
items: PlaylistItem[];
|
||||||
onMoveUp: (item: PlaylistItem) => void;
|
deleteItem: (idx: number) => void;
|
||||||
onMoveDown: (item: PlaylistItem) => void;
|
moveItem: (src: number, dst: number) => void;
|
||||||
onDelete: (item: PlaylistItem) => 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 (
|
return (
|
||||||
<div className="gf-form-group">
|
<div className="gf-form-group">
|
||||||
<h3 className="page-headering">Dashboards</h3>
|
<h3 className="page-headering">Dashboards</h3>
|
||||||
|
|
||||||
<table className="filter-table">
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<tbody>
|
<Droppable droppableId="playlist-list" direction="vertical">
|
||||||
<PlaylistTableRows items={items} onMoveUp={onMoveUp} onMoveDown={onMoveDown} onDelete={onDelete} />
|
{(provided) => {
|
||||||
</tbody>
|
return (
|
||||||
</table>
|
<div ref={provided.innerRef} {...provided.droppableProps}>
|
||||||
|
<PlaylistTableRows items={items} onDelete={deleteItem} />
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
</div>
|
</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';
|
import { PlaylistItem } from './types';
|
||||||
|
|
||||||
interface PlaylistTableRowsProps {
|
interface Props {
|
||||||
items: PlaylistItem[];
|
items: PlaylistItem[];
|
||||||
onMoveUp: (item: PlaylistItem) => void;
|
onDelete: (idx: number) => void;
|
||||||
onMoveDown: (item: PlaylistItem) => void;
|
|
||||||
onDelete: (item: PlaylistItem) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlaylistTableRows: FC<PlaylistTableRowsProps> = ({ items, onMoveUp, onMoveDown, onDelete }) => {
|
export const PlaylistTableRows = ({ items, onDelete }: Props) => {
|
||||||
if (items.length === 0) {
|
const styles = useStyles2(getStyles);
|
||||||
|
if (!items?.length) {
|
||||||
return (
|
return (
|
||||||
<tr>
|
<div>
|
||||||
<td>
|
<em>Playlist is empty. Add dashboards below.</em>
|
||||||
<em>Playlist is empty. Add dashboards below.</em>
|
</div>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => (
|
||||||
const first = index === 0;
|
<Draggable key={`${index}/${item.value}`} draggableId={`${index}`} index={index}>
|
||||||
const last = index === items.length - 1;
|
{(provided) => (
|
||||||
return (
|
<div
|
||||||
<PlaylistTableRow
|
className={styles.row}
|
||||||
first={first}
|
ref={provided.innerRef}
|
||||||
last={last}
|
{...provided.draggableProps}
|
||||||
item={item}
|
{...provided.dragHandleProps}
|
||||||
onDelete={onDelete}
|
role="row"
|
||||||
onMoveDown={onMoveDown}
|
>
|
||||||
onMoveUp={onMoveUp}
|
<div className={styles.actions} role="cell" aria-label={`Playlist item, ${item.type}, ${item.value}`}>
|
||||||
key={item.title}
|
{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 { SelectableValue, UrlQueryMap, urlUtil } from '@grafana/data';
|
||||||
import { Checkbox, ClipboardButton, Field, FieldSet, Input, Modal, RadioButtonGroup } from '@grafana/ui';
|
import { Checkbox, ClipboardButton, Field, FieldSet, Input, Modal, RadioButtonGroup } from '@grafana/ui';
|
||||||
|
import { buildBaseUrl } from 'app/features/dashboard/components/ShareModal/utils';
|
||||||
import { buildBaseUrl } from '../dashboard/components/ShareModal/utils';
|
|
||||||
|
|
||||||
import { PlaylistMode } from './types';
|
import { PlaylistMode } from './types';
|
||||||
|
|
||||||
interface ShareModalProps {
|
interface Props {
|
||||||
playlistUid: string;
|
playlistUid: string;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShareModal = ({ playlistUid, onDismiss }: ShareModalProps) => {
|
export const ShareModal = ({ playlistUid, onDismiss }: Props) => {
|
||||||
const [mode, setMode] = useState<PlaylistMode>(false);
|
const [mode, setMode] = useState<PlaylistMode>(false);
|
||||||
const [autoFit, setAutofit] = useState(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 { SelectableValue, UrlQueryMap, urlUtil } from '@grafana/data';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import { Button, Checkbox, Field, FieldSet, Modal, RadioButtonGroup } from '@grafana/ui';
|
import { Button, Checkbox, Field, FieldSet, Modal, RadioButtonGroup } from '@grafana/ui';
|
||||||
|
|
||||||
import { PlaylistDTO, PlaylistMode } from './types';
|
import { Playlist, PlaylistMode } from './types';
|
||||||
|
|
||||||
export interface StartModalProps {
|
export interface Props {
|
||||||
playlist: PlaylistDTO;
|
playlist: Playlist;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StartModal: FC<StartModalProps> = ({ playlist, onDismiss }) => {
|
export const StartModal = ({ playlist, onDismiss }: Props) => {
|
||||||
const [mode, setMode] = useState<PlaylistMode>(false);
|
const [mode, setMode] = useState<PlaylistMode>(false);
|
||||||
const [autoFit, setAutofit] = useState(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 { DataQueryRequest, DataFrameView } from '@grafana/data';
|
||||||
import { createErrorNotification, createSuccessNotification } from '../../core/copy/appNotification';
|
import { getBackendSrv, config } from '@grafana/runtime';
|
||||||
import { dispatch } from '../../store/store';
|
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) {
|
export async function createPlaylist(playlist: Playlist) {
|
||||||
await withErrorHandling(() => getBackendSrv().post('/api/playlists', 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');
|
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> {
|
export async function getPlaylist(uid: string): Promise<Playlist> {
|
||||||
const result: Playlist = await getBackendSrv().get(`/api/playlists/${uid}`);
|
const playlist = await getBackendSrv().get<Playlist>(`/api/playlists/${uid}`);
|
||||||
return result;
|
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[]> {
|
export async function getAllPlaylist(): Promise<Playlist[]> {
|
||||||
const result: PlaylistDTO[] = await getBackendSrv().get('/api/playlists/', { query });
|
return getBackendSrv().get<Playlist[]>('/api/playlists/');
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withErrorHandling(apiCall: () => Promise<void>, message = 'Playlist saved') {
|
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 {
|
import { DashboardQueryResult } from '../search/service';
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
startUrl?: string;
|
|
||||||
uid: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PlaylistMode = boolean | 'tv';
|
export type PlaylistMode = boolean | 'tv';
|
||||||
|
|
||||||
@@ -15,17 +10,16 @@ export interface PlayListItemDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Playlist {
|
export interface Playlist {
|
||||||
|
uid: string;
|
||||||
name: string;
|
name: string;
|
||||||
interval: string;
|
interval: string;
|
||||||
items?: PlaylistItem[];
|
items?: PlaylistItem[];
|
||||||
uid: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaylistItem {
|
export interface PlaylistItem {
|
||||||
id?: number;
|
type: 'dashboard_by_tag' | 'dashboard_by_uid' | 'dashboard_by_id'; // _by_id is deprecated
|
||||||
value: string; //tag or id.toString()
|
value: string; // tag or uid
|
||||||
type: 'dashboard_by_id' | 'dashboard_by_tag';
|
|
||||||
order: number;
|
// Loaded in the frontend
|
||||||
title: string;
|
dashboards?: DashboardQueryResult[];
|
||||||
playlistId?: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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';
|
import { PlaylistItem } from './types';
|
||||||
|
|
||||||
export function usePlaylistItems(playlistItems?: PlaylistItem[]) {
|
export function usePlaylistItems(playlistItems?: PlaylistItem[]) {
|
||||||
const [items, setItems] = useState<PlaylistItem[]>(playlistItems ?? []);
|
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(
|
const addById = useCallback(
|
||||||
(dashboard?: DashboardPickerItem) => {
|
(dashboard?: DashboardPickerDTO) => {
|
||||||
if (!dashboard || items.find((item) => item.id === dashboard.id)) {
|
if (!dashboard) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newItem: PlaylistItem = {
|
setItems([
|
||||||
id: dashboard.id,
|
...items,
|
||||||
title: dashboard.label as string,
|
{
|
||||||
type: 'dashboard_by_id',
|
type: 'dashboard_by_uid',
|
||||||
value: dashboard.id.toString(10),
|
value: dashboard.uid,
|
||||||
order: items.length + 1,
|
},
|
||||||
};
|
]);
|
||||||
setItems([...items, newItem]);
|
|
||||||
},
|
},
|
||||||
[items]
|
[items]
|
||||||
);
|
);
|
||||||
@@ -33,51 +44,35 @@ export function usePlaylistItems(playlistItems?: PlaylistItem[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newItem: PlaylistItem = {
|
const newItem: PlaylistItem = {
|
||||||
title: tag,
|
|
||||||
type: 'dashboard_by_tag',
|
type: 'dashboard_by_tag',
|
||||||
value: tag,
|
value: tag,
|
||||||
order: items.length + 1,
|
|
||||||
};
|
};
|
||||||
setItems([...items, newItem]);
|
setItems([...items, newItem]);
|
||||||
},
|
},
|
||||||
[items]
|
[items]
|
||||||
);
|
);
|
||||||
|
|
||||||
const movePlaylistItem = useCallback(
|
const moveItem = useCallback(
|
||||||
(item: PlaylistItem, offset: number) => {
|
(src: number, dst: number) => {
|
||||||
const newItems = [...items];
|
if (src === dst || !items[src]) {
|
||||||
const currentPosition = newItems.indexOf(item);
|
return; // nothing to do
|
||||||
const newPosition = currentPosition + offset;
|
|
||||||
|
|
||||||
if (newPosition >= 0 && newPosition < newItems.length) {
|
|
||||||
newItems.splice(currentPosition, 1);
|
|
||||||
newItems.splice(newPosition, 0, item);
|
|
||||||
}
|
}
|
||||||
setItems(newItems);
|
const update = Array.from(items);
|
||||||
|
const [removed] = update.splice(src, 1);
|
||||||
|
update.splice(dst, 0, removed);
|
||||||
|
setItems(update);
|
||||||
},
|
},
|
||||||
[items]
|
[items]
|
||||||
);
|
);
|
||||||
|
|
||||||
const moveUp = useCallback(
|
|
||||||
(item: PlaylistItem) => {
|
|
||||||
movePlaylistItem(item, -1);
|
|
||||||
},
|
|
||||||
[movePlaylistItem]
|
|
||||||
);
|
|
||||||
|
|
||||||
const moveDown = useCallback(
|
|
||||||
(item: PlaylistItem) => {
|
|
||||||
movePlaylistItem(item, 1);
|
|
||||||
},
|
|
||||||
[movePlaylistItem]
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteItem = useCallback(
|
const deleteItem = useCallback(
|
||||||
(item: PlaylistItem) => {
|
(index: number) => {
|
||||||
setItems(items.filter((i) => i !== item));
|
const copy = items.slice();
|
||||||
|
copy.splice(index, 1);
|
||||||
|
setItems(copy);
|
||||||
},
|
},
|
||||||
[items]
|
[items]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { items, addById, addByTag, deleteItem, moveDown, moveUp };
|
return { items, addById, addByTag, deleteItem, moveItem };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export interface SearchQuery {
|
|||||||
kind?: string[];
|
kind?: string[];
|
||||||
panel_type?: string;
|
panel_type?: string;
|
||||||
uid?: string[];
|
uid?: string[];
|
||||||
id?: number[];
|
|
||||||
facet?: FacetField[];
|
facet?: FacetField[];
|
||||||
explain?: boolean;
|
explain?: boolean;
|
||||||
withAllowedActions?: boolean;
|
withAllowedActions?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user