mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Explore: Allow users to save Explore queries to dashboards (#47083)
* Select: Expose AsyncSelectProps interface * DashboardPicker: Add a generic DashboardPicker component * Dashboard Service: improve types * Explore: allow saving explore state in a new panel in an existing dashboard * Handle saving provisioned dashboards error * Improve test coverage * simplify test setup * Strip base path from url when redirecting to a dashboard * Keep existing variables when saving to an existing dashboard * group assertions in test * SearchCard: handle undefined in meta.updated * Change required error message * Add to dashboard alternative * Add to existing is working * Add to dashboard form * remove default add-panel when creating a dashboard from explore * types cleanup * remove unneeded BE change * simplify selector * Add explore2Dashboard feature toggle * add tests * Small refactor & add tests * small DashboardPicker improvements * use partial from lodash * Better error handling * improve tests & disable button when there are no queries * rename addPanelToDashboard function * remove localStorage item if opening tab fails * UI touchups & tracking * Fix tests & remove close reporting * remove echologger debug * fix adding a panel to an existing dashboard * Enable explore2Dashboard by default and add docs * Ensure each panel in dashboards has a valid ID * force CI restart Co-authored-by: Elfo404 <me@giordanoricci.com>
This commit is contained in:
parent
d95468a4bb
commit
7181efd1cf
@ -1134,6 +1134,9 @@ enable =
|
|||||||
# The new prometheus visual query builder
|
# The new prometheus visual query builder
|
||||||
promQueryBuilder = true
|
promQueryBuilder = true
|
||||||
|
|
||||||
|
# Experimental Explore to Dashboard workflow
|
||||||
|
explore2Dashboard = true
|
||||||
|
|
||||||
# feature1 = true
|
# feature1 = true
|
||||||
# feature2 = false
|
# feature2 = false
|
||||||
|
|
||||||
|
@ -68,3 +68,11 @@ After you've navigated to Explore, you should notice a "Back" button in the Expl
|
|||||||
> **Note:** Available in Grafana 7.3 and later versions.
|
> **Note:** Available in Grafana 7.3 and later versions.
|
||||||
|
|
||||||
The Share shortened link capability allows you to create smaller and simpler URLs of the format /goto/:uid instead of using longer URLs with query parameters. To create a shortened link to the executed query, click the **Share** option in the Explore toolbar. A shortened link that is never used will automatically get deleted after seven (7) days.
|
The Share shortened link capability allows you to create smaller and simpler URLs of the format /goto/:uid instead of using longer URLs with query parameters. To create a shortened link to the executed query, click the **Share** option in the Explore toolbar. A shortened link that is never used will automatically get deleted after seven (7) days.
|
||||||
|
|
||||||
|
## Available feature toggles
|
||||||
|
|
||||||
|
### explore2Dashboard
|
||||||
|
|
||||||
|
> **Note:** Available in Grafana 8.5.0 and later versions.
|
||||||
|
|
||||||
|
Enabled by default, allows users to create panels in dashboards from within Explore.
|
||||||
|
@ -13,7 +13,7 @@ export function MultiSelect<T>(props: MultiSelectCommonProps<T>) {
|
|||||||
return <SelectBase {...props} isMulti />;
|
return <SelectBase {...props} isMulti />;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, SelectAsyncProps<T> {
|
export interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, SelectAsyncProps<T> {
|
||||||
// AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options
|
// AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options
|
||||||
value?: SelectableValue<T> | null;
|
value?: SelectableValue<T> | null;
|
||||||
invalid?: boolean;
|
invalid?: boolean;
|
||||||
|
89
public/app/core/components/Select/DashboardPicker.tsx
Normal file
89
public/app/core/components/Select/DashboardPicker.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import debounce from 'debounce-promise';
|
||||||
|
import { SelectableValue } from '@grafana/data';
|
||||||
|
import { DashboardSearchHit } from 'app/features/search/types';
|
||||||
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
|
import { AsyncSelectProps, AsyncSelect } from '@grafana/ui';
|
||||||
|
import { DashboardDTO } from 'app/types';
|
||||||
|
|
||||||
|
interface Props
|
||||||
|
extends Omit<AsyncSelectProps<DashboardPickerDTO>, 'value' | 'onChange' | 'loadOptions' | 'menuShouldPortal'> {
|
||||||
|
value?: DashboardPickerDTO['uid'];
|
||||||
|
onChange?: (value?: DashboardPickerDTO) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardPickerDTO = Pick<DashboardDTO['dashboard'], 'uid' | 'title'> &
|
||||||
|
Pick<DashboardDTO['meta'], 'folderUid' | 'folderTitle'>;
|
||||||
|
|
||||||
|
const formatLabel = (folderTitle = 'General', dashboardTitle: string) => `${folderTitle}/${dashboardTitle}`;
|
||||||
|
|
||||||
|
const getDashboards = debounce((query = ''): Promise<Array<SelectableValue<DashboardPickerDTO>>> => {
|
||||||
|
return backendSrv.search({ type: 'dash-db', query, limit: 100 }).then((result: DashboardSearchHit[]) => {
|
||||||
|
return result.map((item: DashboardSearchHit) => ({
|
||||||
|
value: {
|
||||||
|
// dashboards uid here is always defined as this endpoint does not return the default home dashboard
|
||||||
|
uid: item.uid!,
|
||||||
|
title: item.title,
|
||||||
|
folderTitle: item.folderTitle,
|
||||||
|
folderUid: item.folderUid,
|
||||||
|
},
|
||||||
|
label: formatLabel(item?.folderTitle, item.title),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// TODO: this component should provide a way to apply different filters to the search APIs
|
||||||
|
export const DashboardPicker = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Select dashboard',
|
||||||
|
noOptionsMessage = 'No dashboards found',
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
|
const [current, setCurrent] = useState<SelectableValue<DashboardPickerDTO>>();
|
||||||
|
|
||||||
|
// This is required because the async select does not match the raw uid value
|
||||||
|
// We can not use a simple Select because the dashboard search should not return *everything*
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value || value === current?.value?.uid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// value was manually changed from outside or we are rendering for the first time.
|
||||||
|
// We need to fetch dashboard information.
|
||||||
|
const res = await backendSrv.getDashboardByUid(value);
|
||||||
|
setCurrent({
|
||||||
|
value: {
|
||||||
|
uid: res.dashboard.uid,
|
||||||
|
title: res.dashboard.title,
|
||||||
|
folderTitle: res.meta.folderTitle,
|
||||||
|
folderUid: res.meta.folderUid,
|
||||||
|
},
|
||||||
|
label: formatLabel(res.meta?.folderTitle, res.dashboard.title),
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
// we don't need to rerun this effect every time `current` changes
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const onPicked = useCallback(
|
||||||
|
(sel: SelectableValue<DashboardPickerDTO>) => {
|
||||||
|
setCurrent(sel);
|
||||||
|
onChange?.(sel?.value);
|
||||||
|
},
|
||||||
|
[onChange, setCurrent]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncSelect
|
||||||
|
menuShouldPortal
|
||||||
|
loadOptions={getDashboards}
|
||||||
|
onChange={onPicked}
|
||||||
|
placeholder={placeholder}
|
||||||
|
noOptionsMessage={noOptionsMessage}
|
||||||
|
value={current}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -1,63 +1,28 @@
|
|||||||
import React, { FC, useCallback, useState } from 'react';
|
import React, { FC, useCallback } from 'react';
|
||||||
import debounce from 'debounce-promise';
|
|
||||||
import { SelectableValue, StandardEditorProps } from '@grafana/data';
|
import { SelectableValue, StandardEditorProps } from '@grafana/data';
|
||||||
import { DashboardSearchHit } from 'app/features/search/types';
|
import { DashboardPicker as BasePicker, DashboardPickerDTO } from 'app/core/components/Select/DashboardPicker';
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
|
||||||
import { AsyncSelect } from '@grafana/ui';
|
|
||||||
import { useAsync } from 'react-use';
|
|
||||||
|
|
||||||
export interface DashboardPickerOptions {
|
export interface DashboardPickerOptions {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
isClearable?: boolean;
|
isClearable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDashboards = (query = '') => {
|
|
||||||
return backendSrv.search({ type: 'dash-db', query, limit: 100 }).then((result: DashboardSearchHit[]) => {
|
|
||||||
return result.map((item: DashboardSearchHit) => ({
|
|
||||||
value: item.uid,
|
|
||||||
label: `${item?.folderTitle ?? 'General'}/${item.title}`,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/** This will return the item UID */
|
/** This will return the item UID */
|
||||||
export const DashboardPicker: FC<StandardEditorProps<string, any, any>> = ({ value, onChange, item }) => {
|
export const DashboardPicker: FC<StandardEditorProps<string, DashboardPickerOptions, any>> = ({
|
||||||
const [current, setCurrent] = useState<SelectableValue<string>>();
|
value,
|
||||||
|
onChange,
|
||||||
// This is required because the async select does not match the raw uid value
|
item,
|
||||||
// We can not use a simple Select because the dashboard search should not return *everything*
|
}) => {
|
||||||
useAsync(async () => {
|
const { placeholder, isClearable } = item?.settings ?? {};
|
||||||
if (!value) {
|
|
||||||
setCurrent(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await backendSrv.getDashboardByUid(value);
|
|
||||||
setCurrent({
|
|
||||||
value: res.dashboard.uid,
|
|
||||||
label: `${res.meta?.folderTitle ?? 'General'}/${res.dashboard.title}`,
|
|
||||||
});
|
|
||||||
return undefined;
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const onPicked = useCallback(
|
const onPicked = useCallback(
|
||||||
(sel: SelectableValue<string>) => {
|
(sel?: SelectableValue<DashboardPickerDTO>) => {
|
||||||
onChange(sel?.value);
|
onChange(sel?.value?.uid);
|
||||||
},
|
},
|
||||||
[onChange]
|
[onChange]
|
||||||
);
|
);
|
||||||
const debouncedSearch = debounce(getDashboards, 300);
|
|
||||||
const { placeholder, isClearable } = item?.settings ?? {};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AsyncSelect
|
<BasePicker isClearable={isClearable} defaultOptions onChange={onPicked} placeholder={placeholder} value={value} />
|
||||||
menuShouldPortal
|
|
||||||
isClearable={isClearable}
|
|
||||||
defaultOptions={true}
|
|
||||||
loadOptions={debouncedSearch}
|
|
||||||
onChange={onPicked}
|
|
||||||
placeholder={placeholder ?? 'Select dashboard'}
|
|
||||||
noOptionsMessage="No dashboards found"
|
|
||||||
value={current}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -18,7 +18,7 @@ import { AppEvents, DataQueryErrorType } from '@grafana/data';
|
|||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
import { getConfig } from 'app/core/config';
|
import { getConfig } from 'app/core/config';
|
||||||
import { DashboardSearchHit } from 'app/features/search/types';
|
import { DashboardSearchHit } from 'app/features/search/types';
|
||||||
import { FolderDTO } from 'app/types';
|
import { DashboardDTO, FolderDTO } from 'app/types';
|
||||||
import { ContextSrv, contextSrv } from './context_srv';
|
import { ContextSrv, contextSrv } from './context_srv';
|
||||||
import {
|
import {
|
||||||
isContentTypeApplicationJson,
|
isContentTypeApplicationJson,
|
||||||
@ -423,7 +423,7 @@ export class BackendSrv implements BackendService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDashboardByUid(uid: string) {
|
getDashboardByUid(uid: string) {
|
||||||
return this.get(`/api/dashboards/uid/${uid}`);
|
return this.get<DashboardDTO>(`/api/dashboards/uid/${uid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getFolderByUid(uid: string) {
|
getFolderByUid(uid: string) {
|
||||||
|
@ -171,6 +171,7 @@ export class DashboardModel implements TimeModel {
|
|||||||
this.links = data.links ?? [];
|
this.links = data.links ?? [];
|
||||||
this.gnetId = data.gnetId || null;
|
this.gnetId = data.gnetId || null;
|
||||||
this.panels = map(data.panels ?? [], (panelData: any) => new PanelModel(panelData));
|
this.panels = map(data.panels ?? [], (panelData: any) => new PanelModel(panelData));
|
||||||
|
this.ensurePanelsHaveIds();
|
||||||
this.formatDate = this.formatDate.bind(this);
|
this.formatDate = this.formatDate.bind(this);
|
||||||
|
|
||||||
this.resetOriginalVariables(true);
|
this.resetOriginalVariables(true);
|
||||||
@ -452,6 +453,22 @@ export class DashboardModel implements TimeModel {
|
|||||||
this.panelsAffectedByVariableChange = null;
|
this.panelsAffectedByVariableChange = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensurePanelsHaveIds() {
|
||||||
|
for (const panel of this.panels) {
|
||||||
|
if (!panel.id) {
|
||||||
|
panel.id = this.getNextPanelId();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panel.panels) {
|
||||||
|
for (const rowPanel of panel.panels) {
|
||||||
|
if (!rowPanel.id) {
|
||||||
|
rowPanel.id = this.getNextPanelId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ensureListExist(data: any) {
|
private ensureListExist(data: any) {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
data = {};
|
data = {};
|
||||||
|
@ -19,6 +19,7 @@ import { config, locationService } from '@grafana/runtime';
|
|||||||
import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner';
|
import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner';
|
||||||
import { getIfExistsLastKey } from '../../variables/state/selectors';
|
import { getIfExistsLastKey } from '../../variables/state/selectors';
|
||||||
import { toStateKey } from 'app/features/variables/utils';
|
import { toStateKey } from 'app/features/variables/utils';
|
||||||
|
import store from 'app/core/store';
|
||||||
|
|
||||||
export interface InitDashboardArgs {
|
export interface InitDashboardArgs {
|
||||||
urlUid?: string;
|
urlUid?: string;
|
||||||
@ -34,6 +35,13 @@ async function fetchDashboard(
|
|||||||
dispatch: ThunkDispatch,
|
dispatch: ThunkDispatch,
|
||||||
getState: () => StoreState
|
getState: () => StoreState
|
||||||
): Promise<DashboardDTO | null> {
|
): Promise<DashboardDTO | null> {
|
||||||
|
// When creating new or adding panels to a dashboard from explore we load it from local storage
|
||||||
|
const model = store.getObject<DashboardDTO>(DASHBOARD_FROM_LS_KEY);
|
||||||
|
if (model) {
|
||||||
|
removeDashboardToFetchFromLocalStorage();
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (args.routeName) {
|
switch (args.routeName) {
|
||||||
case DashboardRoutes.Home: {
|
case DashboardRoutes.Home: {
|
||||||
@ -200,7 +208,7 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNewDashboardModelData(urlFolderId?: string | null): any {
|
export function getNewDashboardModelData(urlFolderId?: string | null): any {
|
||||||
const data = {
|
const data = {
|
||||||
meta: {
|
meta: {
|
||||||
canStar: false,
|
canStar: false,
|
||||||
@ -226,3 +234,13 @@ function getNewDashboardModelData(urlFolderId?: string | null): any {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DASHBOARD_FROM_LS_KEY = 'DASHBOARD_FROM_LS_KEY';
|
||||||
|
|
||||||
|
export function setDashboardToFetchFromLocalStorage(model: DashboardDTO) {
|
||||||
|
store.setObject(DASHBOARD_FROM_LS_KEY, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeDashboardToFetchFromLocalStorage() {
|
||||||
|
store.delete(DASHBOARD_FROM_LS_KEY);
|
||||||
|
}
|
||||||
|
@ -1,137 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { AddToDashboardModal } from './AddToDashboardModal';
|
|
||||||
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
|
|
||||||
import * as dashboardApi from 'app/features/manage-dashboards/state/actions';
|
|
||||||
|
|
||||||
const createFolder = (title: string, id: number): DashboardSearchHit => ({
|
|
||||||
title,
|
|
||||||
id,
|
|
||||||
isStarred: false,
|
|
||||||
type: DashboardSearchItemType.DashFolder,
|
|
||||||
items: [],
|
|
||||||
url: '',
|
|
||||||
uri: '',
|
|
||||||
tags: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Add to Dashboard Modal', () => {
|
|
||||||
const searchFoldersResponse = Promise.resolve([createFolder('Folder 1', 1), createFolder('Folder 2', 2)]);
|
|
||||||
|
|
||||||
const waitForSearchFolderResponse = async () => {
|
|
||||||
return act(async () => {
|
|
||||||
// FolderPicker asynchronously sets its internal state based on search results, causing warnings when testing.
|
|
||||||
// Given we are not aware of the component implementation to wait on certain element to appear or disappear (for example a loading indicator),
|
|
||||||
// we wait for the mocked promise we know it internally uses.
|
|
||||||
// This is less than ideal as we are relying on implementation details, but is a reasonable solution for this test's scope
|
|
||||||
await searchFoldersResponse;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.spyOn(dashboardApi, 'searchFolders').mockReturnValue(searchFoldersResponse);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Save to new dashboard', () => {
|
|
||||||
it('Does not submit if the form is invalid', async () => {
|
|
||||||
const saveMock = jest.fn();
|
|
||||||
|
|
||||||
render(<AddToDashboardModal onSave={saveMock} onClose={() => {}} />);
|
|
||||||
|
|
||||||
// there shouldn't be any alert in the modal
|
|
||||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
const dashboardNameInput = screen.getByRole<HTMLInputElement>('textbox', { name: /dashboard name/i });
|
|
||||||
|
|
||||||
// dashboard name is required
|
|
||||||
userEvent.clear(dashboardNameInput);
|
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
|
||||||
|
|
||||||
// The error message should appear
|
|
||||||
await screen.findByRole('alert');
|
|
||||||
|
|
||||||
// Create dashboard API is not invoked
|
|
||||||
expect(saveMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Correctly submits if the form is valid', async () => {
|
|
||||||
const saveMock = jest.fn();
|
|
||||||
|
|
||||||
render(<AddToDashboardModal onSave={saveMock} onClose={() => {}} />);
|
|
||||||
await waitForSearchFolderResponse();
|
|
||||||
|
|
||||||
const dashboardNameInput = screen.getByRole<HTMLInputElement>('textbox', { name: /dashboard name/i });
|
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('button', { name: /save and keep exploring/i })).toBeEnabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(saveMock).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
dashboardName: dashboardNameInput.value,
|
|
||||||
folderId: 1,
|
|
||||||
},
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Handling API errors', () => {
|
|
||||||
it('Correctly handles name-exist API Error', async () => {
|
|
||||||
// name-exists is triggered when trying to create a dashboard in a folder that already has a dashboard with the same name
|
|
||||||
const saveMock = jest.fn().mockResolvedValue({ status: 'name-exists', message: 'name exists' });
|
|
||||||
|
|
||||||
render(<AddToDashboardModal onSave={saveMock} onClose={() => {}} />);
|
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
|
||||||
'A dashboard with the same name already exists in this folder.'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Correctly handles empty name API Error', async () => {
|
|
||||||
// empty-name is triggered when trying to create a dashboard having an empty name.
|
|
||||||
// FE validation usually avoids this use case, but can be triggered by using only whitespaces in
|
|
||||||
// dashboard name field
|
|
||||||
const saveMock = jest.fn().mockResolvedValue({ status: 'empty-name', message: 'empty name' });
|
|
||||||
|
|
||||||
render(<AddToDashboardModal onSave={saveMock} onClose={() => {}} />);
|
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('Dashboard name is required.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Correctly handles name match API Error', async () => {
|
|
||||||
// name-match, triggered when trying to create a dashboard in a folder that has the same name.
|
|
||||||
// it doesn't seem to ever be triggered, but matches the error in
|
|
||||||
// https://github.com/grafana/grafana/blob/44f1e381cbc7a5e236b543bc6bd06b00e3152d7f/pkg/models/dashboards.go#L71
|
|
||||||
const saveMock = jest.fn().mockResolvedValue({ status: 'name-match', message: 'name match' });
|
|
||||||
|
|
||||||
render(<AddToDashboardModal onSave={saveMock} onClose={() => {}} />);
|
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('name match');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Correctly handles unknown API Errors', async () => {
|
|
||||||
const saveMock = jest.fn().mockResolvedValue({ status: 'unknown-error', message: 'unknown error' });
|
|
||||||
|
|
||||||
render(<AddToDashboardModal onSave={saveMock} onClose={() => {}} />);
|
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('unknown error');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,136 +1,201 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Alert, Button, Field, Input, InputControl, Modal } from '@grafana/ui';
|
import { Alert, Button, Field, InputControl, Modal, RadioButtonGroup } from '@grafana/ui';
|
||||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
import { locationUtil, SelectableValue } from '@grafana/data';
|
||||||
import { useForm } from 'react-hook-form';
|
import { setDashboardInLocalStorage, AddToDashboardError } from './addToDashboard';
|
||||||
import { SaveToNewDashboardDTO } from './addToDashboard';
|
import { useSelector } from 'react-redux';
|
||||||
|
import { ExploreId } from 'app/types';
|
||||||
|
import { DashboardPicker } from 'app/core/components/Select/DashboardPicker';
|
||||||
|
import { DeepMap, FieldError, useForm } from 'react-hook-form';
|
||||||
|
import { config, locationService, reportInteraction } from '@grafana/runtime';
|
||||||
|
import { getExploreItemSelector } from '../state/selectors';
|
||||||
|
import { partial } from 'lodash';
|
||||||
|
import { removeDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard';
|
||||||
|
|
||||||
export interface ErrorResponse {
|
enum SaveTarget {
|
||||||
status: string;
|
NewDashboard = 'new-dashboard',
|
||||||
message?: string;
|
ExistingDashboard = 'existing-dashboard',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ERRORS = {
|
const SAVE_TARGETS: Array<SelectableValue<SaveTarget>> = [
|
||||||
NAME_REQUIRED: 'Dashboard name is required.',
|
{
|
||||||
NAME_EXISTS: 'A dashboard with the same name already exists in this folder.',
|
label: 'New Dashboard',
|
||||||
INVALID_FIELD: 'This field is invalid.',
|
value: SaveTarget.NewDashboard,
|
||||||
UNKNOWN_ERROR: 'An unknown error occurred while saving the dashboard. Please try again.',
|
},
|
||||||
INVALID_FOLDER: 'Select a valid folder to save your dashboard in.',
|
{
|
||||||
};
|
label: 'Existing Dashboard',
|
||||||
|
value: SaveTarget.ExistingDashboard,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
type FormDTO = SaveToNewDashboardDTO;
|
interface SaveTargetDTO {
|
||||||
|
saveTarget: SaveTarget;
|
||||||
|
}
|
||||||
|
interface SaveToNewDashboardDTO extends SaveTargetDTO {
|
||||||
|
saveTarget: SaveTarget.NewDashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveToExistingDashboard extends SaveTargetDTO {
|
||||||
|
saveTarget: SaveTarget.ExistingDashboard;
|
||||||
|
dashboardUid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormDTO = SaveToNewDashboardDTO | SaveToExistingDashboard;
|
||||||
|
|
||||||
|
function assertIsSaveToExistingDashboardError(
|
||||||
|
errors: DeepMap<FormDTO, FieldError>
|
||||||
|
): asserts errors is DeepMap<SaveToExistingDashboard, FieldError> {
|
||||||
|
// the shape of the errors object is always compatible with the type above, but we need to
|
||||||
|
// explicitly assert its type so that TS can narrow down FormDTO to SaveToExistingDashboard
|
||||||
|
// when we use it in the form.
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDashboardURL(dashboardUid?: string) {
|
||||||
|
return dashboardUid ? `d/${dashboardUid}` : 'dashboard/new';
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GenericError {
|
||||||
|
UNKNOWN = 'unknown-error',
|
||||||
|
NAVIGATION = 'navigation-error',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubmissionError {
|
||||||
|
error: AddToDashboardError | GenericError;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (data: FormDTO, redirect: boolean) => Promise<void | ErrorResponse>;
|
exploreId: ExploreId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function withRedirect<T extends any[]>(fn: (redirect: boolean, ...args: T) => {}, redirect: boolean) {
|
export const AddToDashboardModal = ({ onClose, exploreId }: Props) => {
|
||||||
return async (...args: T) => fn(redirect, ...args);
|
const exploreItem = useSelector(getExploreItemSelector(exploreId))!;
|
||||||
}
|
const [submissionError, setSubmissionError] = useState<SubmissionError | undefined>();
|
||||||
|
|
||||||
export const AddToDashboardModal = ({ onClose, onSave }: Props) => {
|
|
||||||
const [submissionError, setSubmissionError] = useState<string>();
|
|
||||||
const {
|
const {
|
||||||
register,
|
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors },
|
||||||
setError,
|
watch,
|
||||||
} = useForm<FormDTO>();
|
} = useForm<FormDTO>({
|
||||||
|
defaultValues: { saveTarget: SaveTarget.NewDashboard },
|
||||||
|
});
|
||||||
|
const saveTarget = watch('saveTarget');
|
||||||
|
|
||||||
const onSubmit = async (withRedirect: boolean, data: FormDTO) => {
|
const onSubmit = async (openInNewTab: boolean, data: FormDTO) => {
|
||||||
setSubmissionError(undefined);
|
setSubmissionError(undefined);
|
||||||
const error = await onSave(data, withRedirect);
|
const dashboardUid = data.saveTarget === SaveTarget.ExistingDashboard ? data.dashboardUid : undefined;
|
||||||
|
|
||||||
if (error) {
|
reportInteraction('e2d_submit', {
|
||||||
switch (error.status) {
|
newTab: openInNewTab,
|
||||||
case 'name-match':
|
saveTarget: data.saveTarget,
|
||||||
// error.message should always be defined here
|
queries: exploreItem.queries.length,
|
||||||
setError('dashboardName', { message: error.message ?? ERRORS.INVALID_FIELD });
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setDashboardInLocalStorage({
|
||||||
|
dashboardUid,
|
||||||
|
datasource: exploreItem.datasourceInstance?.getRef(),
|
||||||
|
queries: exploreItem.queries,
|
||||||
|
queryResponse: exploreItem.queryResponse,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
switch (error) {
|
||||||
|
case AddToDashboardError.FETCH_DASHBOARD:
|
||||||
|
setSubmissionError({ error, message: 'Could not fetch dashboard information. Please try again.' });
|
||||||
break;
|
break;
|
||||||
case 'empty-name':
|
case AddToDashboardError.SET_DASHBOARD_LS:
|
||||||
setError('dashboardName', { message: ERRORS.NAME_REQUIRED });
|
setSubmissionError({ error, message: 'Could not add panel to dashboard. Please try again.' });
|
||||||
break;
|
|
||||||
case 'name-exists':
|
|
||||||
setError('dashboardName', { message: ERRORS.NAME_EXISTS });
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
setSubmissionError(error.message ?? ERRORS.UNKNOWN_ERROR);
|
setSubmissionError({ error: GenericError.UNKNOWN, message: 'Something went wrong. Please try again.' });
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dashboardURL = getDashboardURL(dashboardUid);
|
||||||
|
if (!openInNewTab) {
|
||||||
|
onClose();
|
||||||
|
locationService.push(locationUtil.stripBaseFromUrl(dashboardURL));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const didTabOpen = !!global.open(config.appUrl + dashboardURL, '_blank');
|
||||||
|
if (!didTabOpen) {
|
||||||
|
setSubmissionError({
|
||||||
|
error: GenericError.NAVIGATION,
|
||||||
|
message: 'Could not navigate to the selected dashboard. Please try again.',
|
||||||
|
});
|
||||||
|
removeDashboardToFetchFromLocalStorage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reportInteraction('e2d_open');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Add panel to dashboard" onDismiss={onClose} isOpen>
|
<Modal title="Add panel to dashboard" onDismiss={onClose} isOpen>
|
||||||
<form>
|
<form>
|
||||||
<p>Create a new dashboard and add a panel with the explored queries.</p>
|
<InputControl
|
||||||
|
control={control}
|
||||||
|
render={({ field: { ref, ...field } }) => (
|
||||||
|
<Field label="Target dashboard" description="Start a new dashboard or save the panel in an existing one.">
|
||||||
|
<RadioButtonGroup options={SAVE_TARGETS} {...field} id="e2d-save-target" />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
name="saveTarget"
|
||||||
|
/>
|
||||||
|
|
||||||
<Field
|
{saveTarget === SaveTarget.ExistingDashboard &&
|
||||||
label="Dashboard name"
|
(() => {
|
||||||
description="Choose a name for the new dashboard."
|
assertIsSaveToExistingDashboardError(errors);
|
||||||
error={errors.dashboardName?.message}
|
return (
|
||||||
invalid={!!errors.dashboardName}
|
<InputControl
|
||||||
>
|
render={({ field: { ref, value, onChange, ...field } }) => (
|
||||||
<Input
|
<Field
|
||||||
id="dashboard_name"
|
label="Dashboard"
|
||||||
{...register('dashboardName', {
|
description="Select in which dashboard the panel will be created."
|
||||||
shouldUnregister: true,
|
error={errors.dashboardUid?.message}
|
||||||
required: { value: true, message: ERRORS.NAME_REQUIRED },
|
invalid={!!errors.dashboardUid}
|
||||||
setValueAs(value: string) {
|
>
|
||||||
return value.trim();
|
<DashboardPicker
|
||||||
},
|
{...field}
|
||||||
})}
|
inputId="e2d-dashboard-picker"
|
||||||
// we set default value here instead of in useForm because this input will be unregistered when switching
|
defaultOptions
|
||||||
// to "Existing Dashboard" and default values are not populated with manually registered
|
onChange={(d) => onChange(d?.uid)}
|
||||||
// inputs (ie. when switching back to "New Dashboard")
|
/>
|
||||||
defaultValue="New dashboard (Explore)"
|
</Field>
|
||||||
/>
|
)}
|
||||||
</Field>
|
control={control}
|
||||||
|
name="dashboardUid"
|
||||||
<Field
|
shouldUnregister
|
||||||
label="Folder"
|
rules={{ required: { value: true, message: 'This field is required.' } }}
|
||||||
description="Select where the dashboard will be created."
|
/>
|
||||||
error={errors.folderId?.message}
|
);
|
||||||
invalid={!!errors.folderId}
|
})()}
|
||||||
>
|
|
||||||
<InputControl
|
|
||||||
render={({ field: { ref, onChange, ...field } }) => (
|
|
||||||
<FolderPicker onChange={(e) => onChange(e.id)} {...field} enableCreateNew inputId="folder" />
|
|
||||||
)}
|
|
||||||
control={control}
|
|
||||||
name="folderId"
|
|
||||||
shouldUnregister
|
|
||||||
rules={{ required: { value: true, message: ERRORS.INVALID_FOLDER } }}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{submissionError && (
|
{submissionError && (
|
||||||
<Alert severity="error" title="Unknown error">
|
<Alert severity="error" title="Error adding the panel">
|
||||||
{submissionError}
|
{submissionError.message}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal.ButtonRow>
|
<Modal.ButtonRow>
|
||||||
<Button type="reset" onClick={onClose} fill="outline" variant="secondary" disabled={isSubmitting}>
|
<Button type="reset" onClick={onClose} fill="outline" variant="secondary">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
onClick={handleSubmit(withRedirect(onSubmit, false))}
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="compass"
|
onClick={handleSubmit(partial(onSubmit, true))}
|
||||||
disabled={isSubmitting}
|
icon="external-link-alt"
|
||||||
>
|
>
|
||||||
Save and keep exploring
|
Open in new tab
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="submit" variant="primary" onClick={handleSubmit(partial(onSubmit, false))} icon="apps">
|
||||||
type="submit"
|
Open
|
||||||
onClick={handleSubmit(withRedirect(onSubmit, true))}
|
|
||||||
variant="primary"
|
|
||||||
icon="apps"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Save and go to dashboard
|
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.ButtonRow>
|
</Modal.ButtonRow>
|
||||||
</form>
|
</form>
|
||||||
|
@ -0,0 +1,146 @@
|
|||||||
|
import { DataQuery, MutableDataFrame } from '@grafana/data';
|
||||||
|
import { ExplorePanelData } from 'app/types';
|
||||||
|
import { createEmptyQueryResponse } from '../state/utils';
|
||||||
|
import { setDashboardInLocalStorage } from './addToDashboard';
|
||||||
|
import * as api from 'app/features/dashboard/state/initDashboard';
|
||||||
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
|
|
||||||
|
describe('addPanelToDashboard', () => {
|
||||||
|
let spy: jest.SpyInstance;
|
||||||
|
beforeAll(() => {
|
||||||
|
spy = jest.spyOn(api, 'setDashboardToFetchFromLocalStorage');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Correct datasource ref is used', async () => {
|
||||||
|
await setDashboardInLocalStorage({
|
||||||
|
queries: [],
|
||||||
|
queryResponse: createEmptyQueryResponse(),
|
||||||
|
datasource: { type: 'loki', uid: 'someUid' },
|
||||||
|
});
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
dashboard: expect.objectContaining({
|
||||||
|
panels: expect.arrayContaining([expect.objectContaining({ datasource: { type: 'loki', uid: 'someUid' } })]),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('All queries are correctly passed through', async () => {
|
||||||
|
const queries: DataQuery[] = [{ refId: 'A' }, { refId: 'B', hide: true }];
|
||||||
|
|
||||||
|
await setDashboardInLocalStorage({
|
||||||
|
queries,
|
||||||
|
queryResponse: createEmptyQueryResponse(),
|
||||||
|
});
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
dashboard: expect.objectContaining({
|
||||||
|
panels: expect.arrayContaining([expect.objectContaining({ targets: expect.arrayContaining(queries) })]),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Previous panels should not be removed', async () => {
|
||||||
|
const queries: DataQuery[] = [{ refId: 'A' }];
|
||||||
|
const existingPanel = { prop: 'this should be kept' };
|
||||||
|
jest.spyOn(backendSrv, 'getDashboardByUid').mockResolvedValue({
|
||||||
|
dashboard: {
|
||||||
|
templating: { list: [] },
|
||||||
|
title: 'Previous panels should not be removed',
|
||||||
|
uid: 'someUid',
|
||||||
|
panels: [existingPanel],
|
||||||
|
},
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await setDashboardInLocalStorage({
|
||||||
|
queries,
|
||||||
|
queryResponse: createEmptyQueryResponse(),
|
||||||
|
dashboardUid: 'someUid',
|
||||||
|
datasource: { type: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
dashboard: expect.objectContaining({
|
||||||
|
panels: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ targets: expect.arrayContaining(queries) }),
|
||||||
|
existingPanel,
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Setting visualization type', () => {
|
||||||
|
describe('Defaults to table', () => {
|
||||||
|
const cases: Array<[string, DataQuery[], ExplorePanelData]> = [
|
||||||
|
['If response is empty', [{ refId: 'A' }], createEmptyQueryResponse()],
|
||||||
|
['If no query is active', [{ refId: 'A', hide: true }], createEmptyQueryResponse()],
|
||||||
|
[
|
||||||
|
'If no query is active, even when there is a response from a previous execution',
|
||||||
|
[{ refId: 'A', hide: true }],
|
||||||
|
{ ...createEmptyQueryResponse(), logsFrames: [new MutableDataFrame({ refId: 'A', fields: [] })] },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// trace view is not supported in dashboards, we expect to fallback to table panel
|
||||||
|
'If there are trace frames',
|
||||||
|
[{ refId: 'A' }],
|
||||||
|
{ ...createEmptyQueryResponse(), traceFrames: [new MutableDataFrame({ refId: 'A', fields: [] })] },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(cases)('%s', async (_, queries, queryResponse) => {
|
||||||
|
await setDashboardInLocalStorage({ queries, queryResponse });
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
dashboard: expect.objectContaining({
|
||||||
|
panels: expect.arrayContaining([expect.objectContaining({ type: 'table' })]),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Correctly set visualization based on response', () => {
|
||||||
|
type TestArgs = {
|
||||||
|
framesType: string;
|
||||||
|
expectedPanel: string;
|
||||||
|
};
|
||||||
|
// Note: traceFrames test is "duplicated" in "Defaults to table" tests.
|
||||||
|
// This is intentional as a way to enforce explicit tests for that case whenever in the future we'll
|
||||||
|
// add support for creating traceview panels
|
||||||
|
it.each`
|
||||||
|
framesType | expectedPanel
|
||||||
|
${'logsFrames'} | ${'logs'}
|
||||||
|
${'graphFrames'} | ${'timeseries'}
|
||||||
|
${'nodeGraphFrames'} | ${'nodeGraph'}
|
||||||
|
${'traceFrames'} | ${'table'}
|
||||||
|
`(
|
||||||
|
'Sets visualization to $expectedPanel if there are $frameType frames',
|
||||||
|
async ({ framesType, expectedPanel }: TestArgs) => {
|
||||||
|
const queries = [{ refId: 'A' }];
|
||||||
|
const queryResponse: ExplorePanelData = {
|
||||||
|
...createEmptyQueryResponse(),
|
||||||
|
[framesType]: [new MutableDataFrame({ refId: 'A', fields: [] })],
|
||||||
|
};
|
||||||
|
|
||||||
|
await setDashboardInLocalStorage({ queries, queryResponse });
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
dashboard: expect.objectContaining({
|
||||||
|
panels: expect.arrayContaining([expect.objectContaining({ type: expectedPanel })]),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,37 +1,81 @@
|
|||||||
import { DataQuery, DataSourceRef } from '@grafana/data';
|
import { DataFrame, DataQuery, DataSourceRef } from '@grafana/data';
|
||||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
|
import {
|
||||||
|
getNewDashboardModelData,
|
||||||
|
setDashboardToFetchFromLocalStorage,
|
||||||
|
} from 'app/features/dashboard/state/initDashboard';
|
||||||
|
import { DashboardDTO, ExplorePanelData } from 'app/types';
|
||||||
|
|
||||||
export interface SaveToNewDashboardDTO {
|
export enum AddToDashboardError {
|
||||||
dashboardName: string;
|
FETCH_DASHBOARD = 'fetch-dashboard',
|
||||||
folderId: number;
|
SET_DASHBOARD_LS = 'set-dashboard-ls-error',
|
||||||
}
|
}
|
||||||
interface SaveOptions {
|
|
||||||
|
interface AddPanelToDashboardOptions {
|
||||||
queries: DataQuery[];
|
queries: DataQuery[];
|
||||||
panel: string;
|
queryResponse: ExplorePanelData;
|
||||||
datasource: DataSourceRef;
|
datasource?: DataSourceRef;
|
||||||
|
dashboardUid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createDashboard = (
|
function createDashboard(): DashboardDTO {
|
||||||
dashboardName: string,
|
const dto = getNewDashboardModelData();
|
||||||
folderId: number,
|
|
||||||
queries: DataQuery[],
|
|
||||||
datasource: DataSourceRef,
|
|
||||||
panel: string
|
|
||||||
) => {
|
|
||||||
const dashboard = getDashboardSrv().create({ title: dashboardName }, { folderId });
|
|
||||||
|
|
||||||
dashboard.addPanel({ targets: queries, type: panel, title: 'New Panel', datasource });
|
// getNewDashboardModelData adds by default the "add-panel" panel. We don't want that.
|
||||||
|
dto.dashboard.panels = [];
|
||||||
|
|
||||||
return getDashboardSrv().saveDashboard({ dashboard, folderId }, { showErrorAlert: false, showSuccessAlert: false });
|
return dto;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const addToDashboard = async (data: SaveToNewDashboardDTO, options: SaveOptions): Promise<string> => {
|
export async function setDashboardInLocalStorage(options: AddPanelToDashboardOptions) {
|
||||||
const res = await createDashboard(
|
const panelType = getPanelType(options.queries, options.queryResponse);
|
||||||
data.dashboardName,
|
const panel = {
|
||||||
data.folderId,
|
targets: options.queries,
|
||||||
options.queries,
|
type: panelType,
|
||||||
options.datasource,
|
title: 'New Panel',
|
||||||
options.panel
|
gridPos: { x: 0, y: 0, w: 12, h: 8 },
|
||||||
);
|
datasource: options.datasource,
|
||||||
return res.data.url;
|
};
|
||||||
};
|
|
||||||
|
let dto: DashboardDTO;
|
||||||
|
|
||||||
|
if (options.dashboardUid) {
|
||||||
|
try {
|
||||||
|
dto = await backendSrv.getDashboardByUid(options.dashboardUid);
|
||||||
|
} catch (e) {
|
||||||
|
throw AddToDashboardError.FETCH_DASHBOARD;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dto = createDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.dashboard.panels = [panel, ...(dto.dashboard.panels ?? [])];
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDashboardToFetchFromLocalStorage(dto);
|
||||||
|
} catch {
|
||||||
|
throw AddToDashboardError.SET_DASHBOARD_LS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVisible = (query: DataQuery) => !query.hide;
|
||||||
|
const hasRefId = (refId: DataFrame['refId']) => (frame: DataFrame) => frame.refId === refId;
|
||||||
|
|
||||||
|
function getPanelType(queries: DataQuery[], queryResponse: ExplorePanelData) {
|
||||||
|
for (const { refId } of queries.filter(isVisible)) {
|
||||||
|
// traceview is not supported in dashboards, skipping it for now.
|
||||||
|
const hasQueryRefId = hasRefId(refId);
|
||||||
|
if (queryResponse.graphFrames.some(hasQueryRefId)) {
|
||||||
|
return 'timeseries';
|
||||||
|
}
|
||||||
|
if (queryResponse.logsFrames.some(hasQueryRefId)) {
|
||||||
|
return 'logs';
|
||||||
|
}
|
||||||
|
if (queryResponse.nodeGraphFrames.some(hasQueryRefId)) {
|
||||||
|
return 'nodeGraph';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// falling back to table
|
||||||
|
return 'table';
|
||||||
|
}
|
||||||
|
@ -1,31 +1,25 @@
|
|||||||
import React from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { act, render, screen, waitForElementToBeRemoved } from '@testing-library/react';
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { ExploreId, ExplorePanelData, ExploreState } from 'app/types';
|
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import { configureStore } from 'app/store/configureStore';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { DataQuery, MutableDataFrame } from '@grafana/data';
|
import { configureStore } from 'app/store/configureStore';
|
||||||
import { createEmptyQueryResponse } from '../state/utils';
|
import { ExploreId, ExploreState } from 'app/types';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { Provider } from 'react-redux';
|
||||||
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
|
|
||||||
import * as api from './addToDashboard';
|
|
||||||
import * as dashboardApi from 'app/features/manage-dashboards/state/actions';
|
|
||||||
import { AddToDashboard } from '.';
|
import { AddToDashboard } from '.';
|
||||||
|
import * as api from './addToDashboard';
|
||||||
|
import { locationService, setEchoSrv } from '@grafana/runtime';
|
||||||
|
import * as initDashboard from 'app/features/dashboard/state/initDashboard';
|
||||||
|
import { DataQuery } from '@grafana/data';
|
||||||
|
import { createEmptyQueryResponse } from '../state/utils';
|
||||||
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
|
import { DashboardSearchItemType } from 'app/features/search/types';
|
||||||
|
import { Echo } from 'app/core/services/echo/Echo';
|
||||||
|
|
||||||
const setup = (
|
const setup = (children: ReactNode, queries: DataQuery[] = [{ refId: 'A' }]) => {
|
||||||
children: JSX.Element,
|
|
||||||
queries: DataQuery[] = [],
|
|
||||||
queryResponse: ExplorePanelData = createEmptyQueryResponse()
|
|
||||||
) => {
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
explore: {
|
explore: {
|
||||||
left: {
|
left: {
|
||||||
queries,
|
queries,
|
||||||
queryResponse,
|
queryResponse: createEmptyQueryResponse(),
|
||||||
datasourceInstance: {
|
|
||||||
type: 'loki',
|
|
||||||
uid: 'someuid',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as ExploreState,
|
} as ExploreState,
|
||||||
});
|
});
|
||||||
@ -33,269 +27,336 @@ const setup = (
|
|||||||
return render(<Provider store={store}>{children}</Provider>);
|
return render(<Provider store={store}>{children}</Provider>);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createFolder = (title: string, id: number): DashboardSearchHit => ({
|
|
||||||
title,
|
|
||||||
id,
|
|
||||||
isStarred: false,
|
|
||||||
type: DashboardSearchItemType.DashFolder,
|
|
||||||
items: [],
|
|
||||||
url: '',
|
|
||||||
uri: '',
|
|
||||||
tags: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const openModal = async () => {
|
const openModal = async () => {
|
||||||
userEvent.click(screen.getByRole('button', { name: /add to dashboard/i }));
|
userEvent.click(screen.getByRole('button', { name: /add to dashboard/i }));
|
||||||
|
|
||||||
expect(await screen.findByRole('dialog', { name: 'Add panel to dashboard' })).toBeInTheDocument();
|
expect(await screen.findByRole('dialog', { name: 'Add panel to dashboard' })).toBeInTheDocument();
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Add to Dashboard Button', () => {
|
describe('AddToDashboardButton', () => {
|
||||||
const searchFoldersResponse = Promise.resolve([createFolder('Folder 1', 1), createFolder('Folder 2', 2)]);
|
beforeAll(() => {
|
||||||
const redirectURL = '/some/redirect/url';
|
setEchoSrv(new Echo());
|
||||||
let addToDashboardMock: jest.SpyInstance<
|
|
||||||
ReturnType<typeof api.addToDashboard>,
|
|
||||||
Parameters<typeof api.addToDashboard>
|
|
||||||
>;
|
|
||||||
|
|
||||||
const waitForSearchFolderResponse = async () => {
|
|
||||||
return act(async () => {
|
|
||||||
await searchFoldersResponse;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.spyOn(dashboardApi, 'searchFolders').mockReturnValue(searchFoldersResponse);
|
|
||||||
addToDashboardMock = jest.spyOn(api, 'addToDashboard').mockResolvedValue('/some/redirect/url');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
it('Is disabled if explore pane has no queries', async () => {
|
||||||
jest.restoreAllMocks();
|
setup(<AddToDashboard exploreId={ExploreId.left} />, []);
|
||||||
});
|
|
||||||
|
|
||||||
it('Opens and closes the modal correctly', async () => {
|
const button = await screen.findByRole('button', { name: /add to dashboard/i });
|
||||||
setup(<AddToDashboard exploreId={ExploreId.left} />, [{ refId: 'A' }]);
|
expect(button).toBeDisabled();
|
||||||
|
|
||||||
await openModal();
|
userEvent.click(button);
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
|
||||||
|
|
||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('navigation', () => {
|
describe('Success path', () => {
|
||||||
it('Navigates to dashboard when clicking on "Save and go to dashboard"', async () => {
|
const addToDashboardResponse = Promise.resolve();
|
||||||
locationService.push = jest.fn();
|
|
||||||
|
|
||||||
setup(<AddToDashboard exploreId={ExploreId.left} />, [{ refId: 'A' }]);
|
const waitForAddToDashboardResponse = async () => {
|
||||||
|
return act(async () => {
|
||||||
|
await addToDashboardResponse;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
await openModal();
|
beforeEach(() => {
|
||||||
|
jest.spyOn(api, 'setDashboardInLocalStorage').mockReturnValue(addToDashboardResponse);
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and go to dashboard/i }));
|
|
||||||
|
|
||||||
await waitForSearchFolderResponse();
|
|
||||||
|
|
||||||
expect(locationService.push).toHaveBeenCalledWith(redirectURL);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Does NOT navigate to dashboard when clicking on "Save and keep exploring"', async () => {
|
afterEach(() => {
|
||||||
locationService.push = jest.fn();
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
setup(<AddToDashboard exploreId={ExploreId.left} />, [{ refId: 'A' }]);
|
it('Opens and closes the modal correctly', async () => {
|
||||||
|
setup(<AddToDashboard exploreId={ExploreId.left} />);
|
||||||
|
|
||||||
await openModal();
|
await openModal();
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
userEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||||
|
|
||||||
await waitForSearchFolderResponse();
|
|
||||||
|
|
||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
expect(locationService.push).not.toHaveBeenCalled();
|
describe('navigation', () => {
|
||||||
|
it('Navigates to dashboard when clicking on "Open"', async () => {
|
||||||
|
// @ts-expect-error global.open should return a Window, but is not implemented in js-dom.
|
||||||
|
const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
|
||||||
|
const pushSpy = jest.spyOn(locationService, 'push');
|
||||||
|
|
||||||
|
setup(<AddToDashboard exploreId={ExploreId.left} />);
|
||||||
|
|
||||||
|
await openModal();
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('button', { name: /open$/i }));
|
||||||
|
|
||||||
|
await waitForAddToDashboardResponse();
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(pushSpy).toHaveBeenCalled();
|
||||||
|
expect(openSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Navigates to dashboard in a new tab when clicking on "Open in a new tab"', async () => {
|
||||||
|
// @ts-expect-error global.open should return a Window, but is not implemented in js-dom.
|
||||||
|
const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
|
||||||
|
const pushSpy = jest.spyOn(locationService, 'push');
|
||||||
|
|
||||||
|
setup(<AddToDashboard exploreId={ExploreId.left} />);
|
||||||
|
|
||||||
|
await openModal();
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
|
||||||
|
|
||||||
|
await waitForAddToDashboardResponse();
|
||||||
|
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(expect.anything(), '_blank');
|
||||||
|
expect(pushSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Save to new dashboard', () => {
|
||||||
|
describe('Navigate to correct dashboard when saving', () => {
|
||||||
|
it('Opens the new dashboard in a new tab', async () => {
|
||||||
|
// @ts-expect-error global.open should return a Window, but is not implemented in js-dom.
|
||||||
|
const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
|
||||||
|
|
||||||
|
setup(<AddToDashboard exploreId={ExploreId.left} />);
|
||||||
|
|
||||||
|
await openModal();
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
|
||||||
|
|
||||||
|
await waitForAddToDashboardResponse();
|
||||||
|
|
||||||
|
expect(openSpy).toHaveBeenCalledWith('dashboard/new', '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Navigates to the new dashboard', async () => {
|
||||||
|
const pushSpy = jest.spyOn(locationService, 'push');
|
||||||
|
|
||||||
|
setup(<AddToDashboard exploreId={ExploreId.left} />);
|
||||||
|
|
||||||
|
await openModal();
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('button', { name: /open$/i }));
|
||||||
|
|
||||||
|
await waitForAddToDashboardResponse();
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(pushSpy).toHaveBeenCalledWith('dashboard/new');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Save to existing dashboard', () => {
|
||||||
|
it('Renders the dashboard picker when switching to "Existing Dashboard"', async () => {
|
||||||
|
setup(<AddToDashboard exploreId={ExploreId.left} />);
|
||||||
|
|
||||||
|
await openModal();
|
||||||
|
|
||||||
|
expect(screen.queryByRole('combobox', { name: /dashboard/ })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole<HTMLInputElement>('radio', { name: /existing dashboard/i }));
|
||||||
|
expect(screen.getByRole('combobox', { name: /dashboard/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Does not submit if no dashboard is selected', async () => {
|
||||||
|
locationService.push = jest.fn();
|
||||||
|
|
||||||
|
setup(<AddToDashboard exploreId={ExploreId.left} />);
|
||||||
|
|
||||||
|
await openModal();
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole<HTMLInputElement>('radio', { name: /existing dashboard/i }));
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('button', { name: /open$/i }));
|
||||||
|
await waitForAddToDashboardResponse();
|
||||||
|
|
||||||
|
expect(locationService.push).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Navigate to correct dashboard when saving', () => {
|
||||||
|
it('Opens the selected dashboard in a new tab', async () => {
|
||||||
|
// @ts-expect-error global.open should return a Window, but is not implemented in js-dom.
|
||||||
|
const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
|
||||||
|
|
||||||
|
jest.spyOn(backendSrv, 'getDashboardByUid').mockResolvedValue({
|
||||||
|
dashboard: { templating: { list: [] }, title: 'Dashboard Title', uid: 'someUid' },
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
jest.spyOn(backendSrv, 'search').mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
uid: 'someUid',
|
||||||
|
isStarred: false,
|
||||||
|
items: [],
|
||||||
|
title: 'Dashboard Title',
|
||||||
|
tags: [],
|
||||||
|
type: DashboardSearchItemType.DashDB,
|
||||||
|
uri: 'someUri',
|
||||||
|
url: 'someUrl',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
setup(<AddToDashboard exploreId={ExploreId.left} />);
|
||||||
|
|
||||||
|
await openModal();
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i }));
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('combobox', { name: /dashboard/i }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
await screen.findByLabelText('Select option');
|
||||||
|
});
|
||||||
|
userEvent.click(screen.getByLabelText('Select option'));
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openSpy).toBeCalledWith('d/someUid', '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Navigates to the selected dashboard', async () => {
|
||||||
|
const pushSpy = jest.spyOn(locationService, 'push');
|
||||||
|
|
||||||
|
jest.spyOn(backendSrv, 'getDashboardByUid').mockResolvedValue({
|
||||||
|
dashboard: { templating: { list: [] }, title: 'Dashboard Title', uid: 'someUid' },
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
jest.spyOn(backendSrv, 'search').mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
uid: 'someUid',
|
||||||
|
isStarred: false,
|
||||||
|
items: [],
|
||||||
|
title: 'Dashboard Title',
|
||||||
|
tags: [],
|
||||||
|
type: DashboardSearchItemType.DashDB,
|
||||||
|
uri: 'someUri',
|
||||||
|
url: 'someUrl',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
setup(<AddToDashboard exploreId={ExploreId.left} />);
|
||||||
|
|
||||||
|
await openModal();
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i }));
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('combobox', { name: /dashboard/i }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
await screen.findByLabelText('Select option');
|
||||||
|
});
|
||||||
|
userEvent.click(screen.getByLabelText('Select option'));
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('button', { name: /open$/i }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pushSpy).toBeCalledWith('d/someUid');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Correct datasource ref is useed', async () => {
|
describe('Error handling', () => {
|
||||||
setup(<AddToDashboard exploreId={ExploreId.left} />, [{ refId: 'A' }]);
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
await openModal();
|
it('Shows an error if opening a new tab fails', async () => {
|
||||||
|
jest.spyOn(global, 'open').mockReturnValue(null);
|
||||||
|
const removeDashboardSpy = jest.spyOn(initDashboard, 'removeDashboardToFetchFromLocalStorage');
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
setup(<AddToDashboard exploreId={ExploreId.left} />);
|
||||||
|
|
||||||
await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
|
await openModal();
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||||
|
|
||||||
expect(addToDashboardMock).toHaveBeenCalledWith(
|
userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
await waitFor(async () => {
|
||||||
datasource: {
|
expect(await screen.findByRole('alert')).toBeInTheDocument();
|
||||||
type: 'loki',
|
});
|
||||||
uid: 'someuid',
|
|
||||||
|
expect(removeDashboardSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shows an error if saving to localStorage fails', async () => {
|
||||||
|
jest.spyOn(initDashboard, 'setDashboardToFetchFromLocalStorage').mockImplementation(() => {
|
||||||
|
throw 'SOME ERROR';
|
||||||
|
});
|
||||||
|
|
||||||
|
setup(<AddToDashboard exploreId={ExploreId.left} />);
|
||||||
|
|
||||||
|
await openModal();
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
expect(await screen.findByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shows an error if fetching dashboard fails', async () => {
|
||||||
|
jest.spyOn(backendSrv, 'getDashboardByUid').mockRejectedValue('SOME ERROR');
|
||||||
|
jest.spyOn(backendSrv, 'search').mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
uid: 'someUid',
|
||||||
|
isStarred: false,
|
||||||
|
items: [],
|
||||||
|
title: 'Dashboard Title',
|
||||||
|
tags: [],
|
||||||
|
type: DashboardSearchItemType.DashDB,
|
||||||
|
uri: 'someUri',
|
||||||
|
url: 'someUrl',
|
||||||
},
|
},
|
||||||
})
|
]);
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('All queries are correctly passed through', async () => {
|
setup(<AddToDashboard exploreId={ExploreId.left} />);
|
||||||
const queries: DataQuery[] = [{ refId: 'A' }, { refId: 'B', hide: true }];
|
|
||||||
setup(<AddToDashboard exploreId={ExploreId.left} />, queries);
|
|
||||||
|
|
||||||
await openModal();
|
await openModal();
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i }));
|
||||||
|
|
||||||
await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
|
userEvent.click(screen.getByRole('combobox', { name: /dashboard/i }));
|
||||||
|
|
||||||
expect(addToDashboardMock).toHaveBeenCalledWith(
|
await waitFor(async () => {
|
||||||
expect.anything(),
|
await screen.findByLabelText('Select option');
|
||||||
expect.objectContaining({
|
});
|
||||||
queries: queries,
|
userEvent.click(screen.getByLabelText('Select option'));
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Defaults to table if no response is available', async () => {
|
userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
|
||||||
const queries: DataQuery[] = [{ refId: 'A' }];
|
|
||||||
setup(<AddToDashboard exploreId={ExploreId.left} />, queries, createEmptyQueryResponse());
|
|
||||||
|
|
||||||
await openModal();
|
await waitFor(async () => {
|
||||||
|
expect(await screen.findByRole('alert')).toBeInTheDocument();
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
});
|
||||||
|
|
||||||
await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
|
|
||||||
|
|
||||||
expect(addToDashboardMock).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
panel: 'table',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Defaults to table if no query is active', async () => {
|
|
||||||
const queries: DataQuery[] = [{ refId: 'A', hide: true }];
|
|
||||||
setup(<AddToDashboard exploreId={ExploreId.left} />, queries);
|
|
||||||
|
|
||||||
await openModal();
|
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
|
||||||
|
|
||||||
await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
|
|
||||||
|
|
||||||
expect(addToDashboardMock).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
panel: 'table',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Filters out hidden queries when selecting visualization', async () => {
|
|
||||||
const queries: DataQuery[] = [{ refId: 'A', hide: true }, { refId: 'B' }];
|
|
||||||
setup(<AddToDashboard exploreId={ExploreId.left} />, queries, {
|
|
||||||
...createEmptyQueryResponse(),
|
|
||||||
graphFrames: [new MutableDataFrame({ refId: 'B', fields: [] })],
|
|
||||||
logsFrames: [new MutableDataFrame({ refId: 'A', fields: [] })],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await openModal();
|
it('Shows an error if an unknown error happens', async () => {
|
||||||
|
jest.spyOn(api, 'setDashboardInLocalStorage').mockRejectedValue('SOME ERROR');
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
setup(<AddToDashboard exploreId={ExploreId.left} />);
|
||||||
|
|
||||||
await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
|
await openModal();
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||||
|
|
||||||
// Query A comes before B, but it's hidden. visualization will be picked according to frames generated by B
|
userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
|
||||||
expect(addToDashboardMock).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
queries: queries,
|
|
||||||
panel: 'timeseries',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Sets visualization to logs if there are log frames', async () => {
|
await waitFor(async () => {
|
||||||
const queries: DataQuery[] = [{ refId: 'A' }];
|
expect(await screen.findByRole('alert')).toBeInTheDocument();
|
||||||
setup(<AddToDashboard exploreId={ExploreId.left} />, queries, {
|
});
|
||||||
...createEmptyQueryResponse(),
|
|
||||||
logsFrames: [new MutableDataFrame({ refId: 'A', fields: [] })],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await openModal();
|
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
|
||||||
|
|
||||||
await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
|
|
||||||
|
|
||||||
// Query A comes before B, but it's hidden. visualization will be picked according to frames generated by B
|
|
||||||
expect(addToDashboardMock).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
panel: 'logs',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Sets visualization to timeseries if there are graph frames', async () => {
|
|
||||||
const queries: DataQuery[] = [{ refId: 'A' }];
|
|
||||||
setup(<AddToDashboard exploreId={ExploreId.left} />, queries, {
|
|
||||||
...createEmptyQueryResponse(),
|
|
||||||
graphFrames: [new MutableDataFrame({ refId: 'A', fields: [] })],
|
|
||||||
});
|
|
||||||
|
|
||||||
await openModal();
|
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
|
||||||
|
|
||||||
await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
|
|
||||||
|
|
||||||
expect(addToDashboardMock).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
panel: 'timeseries',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Sets visualization to nodeGraph if there are node graph frames', async () => {
|
|
||||||
const queries: DataQuery[] = [{ refId: 'A' }];
|
|
||||||
setup(<AddToDashboard exploreId={ExploreId.left} />, queries, {
|
|
||||||
...createEmptyQueryResponse(),
|
|
||||||
nodeGraphFrames: [new MutableDataFrame({ refId: 'A', fields: [] })],
|
|
||||||
});
|
|
||||||
|
|
||||||
await openModal();
|
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
|
||||||
|
|
||||||
await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
|
|
||||||
|
|
||||||
expect(addToDashboardMock).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
panel: 'nodeGraph',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// trace view is not supported in dashboards, defaulting to table
|
|
||||||
it('Sets visualization to table if there are trace frames', async () => {
|
|
||||||
const queries: DataQuery[] = [{ refId: 'A' }];
|
|
||||||
setup(<AddToDashboard exploreId={ExploreId.left} />, queries, {
|
|
||||||
...createEmptyQueryResponse(),
|
|
||||||
traceFrames: [new MutableDataFrame({ refId: 'A', fields: [] })],
|
|
||||||
});
|
|
||||||
|
|
||||||
await openModal();
|
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
|
|
||||||
|
|
||||||
await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
|
|
||||||
|
|
||||||
expect(addToDashboardMock).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
panel: 'table',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,41 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { DataFrame, DataQuery } from '@grafana/data';
|
import { ExploreId } from 'app/types';
|
||||||
import { ExploreId, StoreState } from 'app/types';
|
import { AddToDashboardModal } from './AddToDashboardModal';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
|
||||||
import { getExploreItemSelector } from '../state/selectors';
|
|
||||||
import { addToDashboard, SaveToNewDashboardDTO } from './addToDashboard';
|
|
||||||
import { locationService } from '@grafana/runtime';
|
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
|
||||||
import { ToolbarButton } from '@grafana/ui';
|
import { ToolbarButton } from '@grafana/ui';
|
||||||
import { AddToDashboardModal, ErrorResponse } from './AddToDashboardModal';
|
import { useSelector } from 'react-redux';
|
||||||
|
import { getExploreItemSelector } from '../state/selectors';
|
||||||
const isVisible = (query: DataQuery) => !query.hide;
|
|
||||||
const hasRefId = (refId: DataFrame['refId']) => (frame: DataFrame) => frame.refId === refId;
|
|
||||||
|
|
||||||
const getMainPanel = (
|
|
||||||
queries: DataQuery[],
|
|
||||||
graphFrames?: DataFrame[],
|
|
||||||
logsFrames?: DataFrame[],
|
|
||||||
nodeGraphFrames?: DataFrame[]
|
|
||||||
) => {
|
|
||||||
for (const { refId } of queries.filter(isVisible)) {
|
|
||||||
// traceview is not supported in dashboards, skipping it for now.
|
|
||||||
const hasQueryRefId = hasRefId(refId);
|
|
||||||
if (graphFrames?.some(hasQueryRefId)) {
|
|
||||||
return 'timeseries';
|
|
||||||
}
|
|
||||||
if (logsFrames?.some(hasQueryRefId)) {
|
|
||||||
return 'logs';
|
|
||||||
}
|
|
||||||
if (nodeGraphFrames?.some(hasQueryRefId)) {
|
|
||||||
return 'nodeGraph';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// falling back to table
|
|
||||||
return 'table';
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exploreId: ExploreId;
|
exploreId: ExploreId;
|
||||||
@ -43,41 +11,8 @@ interface Props {
|
|||||||
|
|
||||||
export const AddToDashboard = ({ exploreId }: Props) => {
|
export const AddToDashboard = ({ exploreId }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dispatch = useDispatch();
|
|
||||||
const selectExploreItem = getExploreItemSelector(exploreId);
|
const selectExploreItem = getExploreItemSelector(exploreId);
|
||||||
|
const explorePaneHasQueries = !!useSelector(selectExploreItem)?.queries?.length;
|
||||||
const { queries, panel, datasource } = useSelector((state: StoreState) => {
|
|
||||||
const exploreItem = selectExploreItem(state);
|
|
||||||
const queries = exploreItem?.queries || [];
|
|
||||||
const datasource = exploreItem?.datasourceInstance;
|
|
||||||
const { graphFrames, logsFrames, nodeGraphFrames } = exploreItem?.queryResponse || {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
queries,
|
|
||||||
datasource: { type: datasource?.type, uid: datasource?.uid },
|
|
||||||
panel: getMainPanel(queries, graphFrames, logsFrames, nodeGraphFrames),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSave = async (data: SaveToNewDashboardDTO, redirect: boolean): Promise<void | ErrorResponse> => {
|
|
||||||
try {
|
|
||||||
const redirectURL = await addToDashboard(data, {
|
|
||||||
queries,
|
|
||||||
datasource,
|
|
||||||
panel,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (redirect) {
|
|
||||||
locationService.push(redirectURL);
|
|
||||||
} else {
|
|
||||||
dispatch(notifyApp(createSuccessNotification(`Panel saved to ${data.dashboardName}`)));
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
return { message: e.data?.message, status: e.data?.status ?? 'unknown-error' };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -85,12 +20,12 @@ export const AddToDashboard = ({ exploreId }: Props) => {
|
|||||||
icon="apps"
|
icon="apps"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
aria-label="Add to dashboard"
|
aria-label="Add to dashboard"
|
||||||
disabled={queries.length === 0}
|
disabled={!explorePaneHasQueries}
|
||||||
>
|
>
|
||||||
Add to dashboard
|
Add to dashboard
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
||||||
{isOpen && <AddToDashboardModal onClose={() => setIsOpen(false)} onSave={handleSave} />}
|
{isOpen && <AddToDashboardModal onClose={() => setIsOpen(false)} exploreId={exploreId} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -24,7 +24,7 @@ export function getThumbnailURL(uid: string, isLight?: boolean) {
|
|||||||
|
|
||||||
export function SearchCard({ editable, item, onTagSelected, onToggleChecked }: Props) {
|
export function SearchCard({ editable, item, onTagSelected, onToggleChecked }: Props) {
|
||||||
const [hasImage, setHasImage] = useState(true);
|
const [hasImage, setHasImage] = useState(true);
|
||||||
const [lastUpdated, setLastUpdated] = useState<string>();
|
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
||||||
const [showExpandedView, setShowExpandedView] = useState(false);
|
const [showExpandedView, setShowExpandedView] = useState(false);
|
||||||
const timeout = useRef<number | null>(null);
|
const timeout = useRef<number | null>(null);
|
||||||
|
|
||||||
@ -64,7 +64,11 @@ export function SearchCard({ editable, item, onTagSelected, onToggleChecked }: P
|
|||||||
if (item.uid && !lastUpdated) {
|
if (item.uid && !lastUpdated) {
|
||||||
const dashboard = await backendSrv.getDashboardByUid(item.uid);
|
const dashboard = await backendSrv.getDashboardByUid(item.uid);
|
||||||
const { updated } = dashboard.meta;
|
const { updated } = dashboard.meta;
|
||||||
setLastUpdated(new Date(updated).toLocaleString());
|
if (updated) {
|
||||||
|
setLastUpdated(new Date(updated).toLocaleString());
|
||||||
|
} else {
|
||||||
|
setLastUpdated(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ export interface Props {
|
|||||||
imageHeight: number;
|
imageHeight: number;
|
||||||
imageWidth: number;
|
imageWidth: number;
|
||||||
item: DashboardSectionItem;
|
item: DashboardSectionItem;
|
||||||
lastUpdated?: string;
|
lastUpdated?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchCardExpanded({ className, imageHeight, imageWidth, item, lastUpdated }: Props) {
|
export function SearchCardExpanded({ className, imageHeight, imageWidth, item, lastUpdated }: Props) {
|
||||||
@ -48,10 +48,12 @@ export function SearchCardExpanded({ className, imageHeight, imageWidth, item, l
|
|||||||
{folderTitle}
|
{folderTitle}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.updateContainer}>
|
{lastUpdated !== null && (
|
||||||
<div>Last updated</div>
|
<div className={styles.updateContainer}>
|
||||||
{lastUpdated ? <div className={styles.update}>{lastUpdated}</div> : <Spinner />}
|
<div>Last updated</div>
|
||||||
</div>
|
{lastUpdated ? <div className={styles.update}>{lastUpdated}</div> : <Spinner />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<TagList className={styles.tagList} tags={item.tags} />
|
<TagList className={styles.tagList} tags={item.tags} />
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { DashboardAcl } from './acl';
|
import { DashboardAcl } from './acl';
|
||||||
import { DataQuery } from '@grafana/data';
|
import { DataQuery } from '@grafana/data';
|
||||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||||
|
import { VariableModel } from 'app/features/variables/types';
|
||||||
|
|
||||||
export interface DashboardDTO {
|
export interface DashboardDTO {
|
||||||
redirectUri?: string;
|
redirectUri?: string;
|
||||||
@ -51,6 +52,11 @@ export interface AnnotationsPermissions {
|
|||||||
|
|
||||||
export interface DashboardDataDTO {
|
export interface DashboardDataDTO {
|
||||||
title: string;
|
title: string;
|
||||||
|
uid: string;
|
||||||
|
templating: {
|
||||||
|
list: VariableModel[];
|
||||||
|
};
|
||||||
|
panels?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum DashboardRoutes {
|
export enum DashboardRoutes {
|
||||||
|
Loading…
Reference in New Issue
Block a user