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:
Torkel Ödegaard 2022-04-12 13:26:07 +02:00 committed by GitHub
parent d95468a4bb
commit 7181efd1cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 853 additions and 627 deletions

View File

@ -1134,6 +1134,9 @@ enable =
# The new prometheus visual query builder
promQueryBuilder = true
# Experimental Explore to Dashboard workflow
explore2Dashboard = true
# feature1 = true
# feature2 = false

View File

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

View File

@ -13,7 +13,7 @@ export function MultiSelect<T>(props: MultiSelectCommonProps<T>) {
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
value?: SelectableValue<T> | null;
invalid?: boolean;

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

View File

@ -1,63 +1,28 @@
import React, { FC, useCallback, useState } from 'react';
import debounce from 'debounce-promise';
import React, { FC, useCallback } from 'react';
import { SelectableValue, StandardEditorProps } from '@grafana/data';
import { DashboardSearchHit } from 'app/features/search/types';
import { backendSrv } from 'app/core/services/backend_srv';
import { AsyncSelect } from '@grafana/ui';
import { useAsync } from 'react-use';
import { DashboardPicker as BasePicker, DashboardPickerDTO } from 'app/core/components/Select/DashboardPicker';
export interface DashboardPickerOptions {
placeholder?: string;
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 */
export const DashboardPicker: FC<StandardEditorProps<string, any, any>> = ({ value, onChange, item }) => {
const [current, setCurrent] = useState<SelectableValue<string>>();
// 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*
useAsync(async () => {
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]);
export const DashboardPicker: FC<StandardEditorProps<string, DashboardPickerOptions, any>> = ({
value,
onChange,
item,
}) => {
const { placeholder, isClearable } = item?.settings ?? {};
const onPicked = useCallback(
(sel: SelectableValue<string>) => {
onChange(sel?.value);
(sel?: SelectableValue<DashboardPickerDTO>) => {
onChange(sel?.value?.uid);
},
[onChange]
);
const debouncedSearch = debounce(getDashboards, 300);
const { placeholder, isClearable } = item?.settings ?? {};
return (
<AsyncSelect
menuShouldPortal
isClearable={isClearable}
defaultOptions={true}
loadOptions={debouncedSearch}
onChange={onPicked}
placeholder={placeholder ?? 'Select dashboard'}
noOptionsMessage="No dashboards found"
value={current}
/>
<BasePicker isClearable={isClearable} defaultOptions onChange={onPicked} placeholder={placeholder} value={value} />
);
};

View File

@ -18,7 +18,7 @@ import { AppEvents, DataQueryErrorType } from '@grafana/data';
import appEvents from 'app/core/app_events';
import { getConfig } from 'app/core/config';
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 {
isContentTypeApplicationJson,
@ -423,7 +423,7 @@ export class BackendSrv implements BackendService {
}
getDashboardByUid(uid: string) {
return this.get(`/api/dashboards/uid/${uid}`);
return this.get<DashboardDTO>(`/api/dashboards/uid/${uid}`);
}
getFolderByUid(uid: string) {

View File

@ -171,6 +171,7 @@ export class DashboardModel implements TimeModel {
this.links = data.links ?? [];
this.gnetId = data.gnetId || null;
this.panels = map(data.panels ?? [], (panelData: any) => new PanelModel(panelData));
this.ensurePanelsHaveIds();
this.formatDate = this.formatDate.bind(this);
this.resetOriginalVariables(true);
@ -452,6 +453,22 @@ export class DashboardModel implements TimeModel {
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) {
if (!data) {
data = {};

View File

@ -19,6 +19,7 @@ import { config, locationService } from '@grafana/runtime';
import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner';
import { getIfExistsLastKey } from '../../variables/state/selectors';
import { toStateKey } from 'app/features/variables/utils';
import store from 'app/core/store';
export interface InitDashboardArgs {
urlUid?: string;
@ -34,6 +35,13 @@ async function fetchDashboard(
dispatch: ThunkDispatch,
getState: () => StoreState
): 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 {
switch (args.routeName) {
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 = {
meta: {
canStar: false,
@ -226,3 +234,13 @@ function getNewDashboardModelData(urlFolderId?: string | null): any {
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);
}

View File

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

View File

@ -1,136 +1,201 @@
import React, { useState } from 'react';
import { Alert, Button, Field, Input, InputControl, Modal } from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { useForm } from 'react-hook-form';
import { SaveToNewDashboardDTO } from './addToDashboard';
import React, { useEffect, useState } from 'react';
import { Alert, Button, Field, InputControl, Modal, RadioButtonGroup } from '@grafana/ui';
import { locationUtil, SelectableValue } from '@grafana/data';
import { setDashboardInLocalStorage, AddToDashboardError } 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 {
status: string;
message?: string;
enum SaveTarget {
NewDashboard = 'new-dashboard',
ExistingDashboard = 'existing-dashboard',
}
const ERRORS = {
NAME_REQUIRED: 'Dashboard name is required.',
NAME_EXISTS: 'A dashboard with the same name already exists in this folder.',
INVALID_FIELD: 'This field is invalid.',
UNKNOWN_ERROR: 'An unknown error occurred while saving the dashboard. Please try again.',
INVALID_FOLDER: 'Select a valid folder to save your dashboard in.',
};
const SAVE_TARGETS: Array<SelectableValue<SaveTarget>> = [
{
label: 'New Dashboard',
value: SaveTarget.NewDashboard,
},
{
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 {
onClose: () => void;
onSave: (data: FormDTO, redirect: boolean) => Promise<void | ErrorResponse>;
exploreId: ExploreId;
}
function withRedirect<T extends any[]>(fn: (redirect: boolean, ...args: T) => {}, redirect: boolean) {
return async (...args: T) => fn(redirect, ...args);
}
export const AddToDashboardModal = ({ onClose, onSave }: Props) => {
const [submissionError, setSubmissionError] = useState<string>();
export const AddToDashboardModal = ({ onClose, exploreId }: Props) => {
const exploreItem = useSelector(getExploreItemSelector(exploreId))!;
const [submissionError, setSubmissionError] = useState<SubmissionError | undefined>();
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
setError,
} = useForm<FormDTO>();
formState: { errors },
watch,
} = 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);
const error = await onSave(data, withRedirect);
const dashboardUid = data.saveTarget === SaveTarget.ExistingDashboard ? data.dashboardUid : undefined;
if (error) {
switch (error.status) {
case 'name-match':
// error.message should always be defined here
setError('dashboardName', { message: error.message ?? ERRORS.INVALID_FIELD });
reportInteraction('e2d_submit', {
newTab: openInNewTab,
saveTarget: data.saveTarget,
queries: exploreItem.queries.length,
});
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;
case 'empty-name':
setError('dashboardName', { message: ERRORS.NAME_REQUIRED });
break;
case 'name-exists':
setError('dashboardName', { message: ERRORS.NAME_EXISTS });
case AddToDashboardError.SET_DASHBOARD_LS:
setSubmissionError({ error, message: 'Could not add panel to dashboard. Please try again.' });
break;
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 (
<Modal title="Add panel to dashboard" onDismiss={onClose} isOpen>
<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
label="Dashboard name"
description="Choose a name for the new dashboard."
error={errors.dashboardName?.message}
invalid={!!errors.dashboardName}
>
<Input
id="dashboard_name"
{...register('dashboardName', {
shouldUnregister: true,
required: { value: true, message: ERRORS.NAME_REQUIRED },
setValueAs(value: string) {
return value.trim();
},
})}
// we set default value here instead of in useForm because this input will be unregistered when switching
// to "Existing Dashboard" and default values are not populated with manually registered
// inputs (ie. when switching back to "New Dashboard")
defaultValue="New dashboard (Explore)"
/>
</Field>
<Field
label="Folder"
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>
{saveTarget === SaveTarget.ExistingDashboard &&
(() => {
assertIsSaveToExistingDashboardError(errors);
return (
<InputControl
render={({ field: { ref, value, onChange, ...field } }) => (
<Field
label="Dashboard"
description="Select in which dashboard the panel will be created."
error={errors.dashboardUid?.message}
invalid={!!errors.dashboardUid}
>
<DashboardPicker
{...field}
inputId="e2d-dashboard-picker"
defaultOptions
onChange={(d) => onChange(d?.uid)}
/>
</Field>
)}
control={control}
name="dashboardUid"
shouldUnregister
rules={{ required: { value: true, message: 'This field is required.' } }}
/>
);
})()}
{submissionError && (
<Alert severity="error" title="Unknown error">
{submissionError}
<Alert severity="error" title="Error adding the panel">
{submissionError.message}
</Alert>
)}
<Modal.ButtonRow>
<Button type="reset" onClick={onClose} fill="outline" variant="secondary" disabled={isSubmitting}>
<Button type="reset" onClick={onClose} fill="outline" variant="secondary">
Cancel
</Button>
<Button
type="submit"
onClick={handleSubmit(withRedirect(onSubmit, false))}
variant="secondary"
icon="compass"
disabled={isSubmitting}
onClick={handleSubmit(partial(onSubmit, true))}
icon="external-link-alt"
>
Save and keep exploring
Open in new tab
</Button>
<Button
type="submit"
onClick={handleSubmit(withRedirect(onSubmit, true))}
variant="primary"
icon="apps"
disabled={isSubmitting}
>
Save and go to dashboard
<Button type="submit" variant="primary" onClick={handleSubmit(partial(onSubmit, false))} icon="apps">
Open
</Button>
</Modal.ButtonRow>
</form>

View File

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

View File

@ -1,37 +1,81 @@
import { DataQuery, DataSourceRef } from '@grafana/data';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DataFrame, DataQuery, DataSourceRef } from '@grafana/data';
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 {
dashboardName: string;
folderId: number;
export enum AddToDashboardError {
FETCH_DASHBOARD = 'fetch-dashboard',
SET_DASHBOARD_LS = 'set-dashboard-ls-error',
}
interface SaveOptions {
interface AddPanelToDashboardOptions {
queries: DataQuery[];
panel: string;
datasource: DataSourceRef;
queryResponse: ExplorePanelData;
datasource?: DataSourceRef;
dashboardUid?: string;
}
const createDashboard = (
dashboardName: string,
folderId: number,
queries: DataQuery[],
datasource: DataSourceRef,
panel: string
) => {
const dashboard = getDashboardSrv().create({ title: dashboardName }, { folderId });
function createDashboard(): DashboardDTO {
const dto = getNewDashboardModelData();
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> => {
const res = await createDashboard(
data.dashboardName,
data.folderId,
options.queries,
options.datasource,
options.panel
);
return res.data.url;
};
export async function setDashboardInLocalStorage(options: AddPanelToDashboardOptions) {
const panelType = getPanelType(options.queries, options.queryResponse);
const panel = {
targets: options.queries,
type: panelType,
title: 'New Panel',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
datasource: options.datasource,
};
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';
}

View File

@ -1,31 +1,25 @@
import React from 'react';
import { act, render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import { ExploreId, ExplorePanelData, ExploreState } from 'app/types';
import { Provider } from 'react-redux';
import { configureStore } from 'app/store/configureStore';
import React, { ReactNode } from 'react';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DataQuery, MutableDataFrame } from '@grafana/data';
import { createEmptyQueryResponse } from '../state/utils';
import { locationService } from '@grafana/runtime';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
import * as api from './addToDashboard';
import * as dashboardApi from 'app/features/manage-dashboards/state/actions';
import { configureStore } from 'app/store/configureStore';
import { ExploreId, ExploreState } from 'app/types';
import { Provider } from 'react-redux';
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 = (
children: JSX.Element,
queries: DataQuery[] = [],
queryResponse: ExplorePanelData = createEmptyQueryResponse()
) => {
const setup = (children: ReactNode, queries: DataQuery[] = [{ refId: 'A' }]) => {
const store = configureStore({
explore: {
left: {
queries,
queryResponse,
datasourceInstance: {
type: 'loki',
uid: 'someuid',
},
queryResponse: createEmptyQueryResponse(),
},
} as ExploreState,
});
@ -33,269 +27,336 @@ const setup = (
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 () => {
userEvent.click(screen.getByRole('button', { name: /add to dashboard/i }));
expect(await screen.findByRole('dialog', { name: 'Add panel to dashboard' })).toBeInTheDocument();
};
describe('Add to Dashboard Button', () => {
const searchFoldersResponse = Promise.resolve([createFolder('Folder 1', 1), createFolder('Folder 2', 2)]);
const redirectURL = '/some/redirect/url';
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');
describe('AddToDashboardButton', () => {
beforeAll(() => {
setEchoSrv(new Echo());
});
afterEach(() => {
jest.restoreAllMocks();
});
it('Is disabled if explore pane has no queries', async () => {
setup(<AddToDashboard exploreId={ExploreId.left} />, []);
it('Opens and closes the modal correctly', async () => {
setup(<AddToDashboard exploreId={ExploreId.left} />, [{ refId: 'A' }]);
const button = await screen.findByRole('button', { name: /add to dashboard/i });
expect(button).toBeDisabled();
await openModal();
userEvent.click(screen.getByRole('button', { name: /cancel/i }));
userEvent.click(button);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
describe('navigation', () => {
it('Navigates to dashboard when clicking on "Save and go to dashboard"', async () => {
locationService.push = jest.fn();
describe('Success path', () => {
const addToDashboardResponse = Promise.resolve();
setup(<AddToDashboard exploreId={ExploreId.left} />, [{ refId: 'A' }]);
const waitForAddToDashboardResponse = async () => {
return act(async () => {
await addToDashboardResponse;
});
};
await openModal();
userEvent.click(screen.getByRole('button', { name: /save and go to dashboard/i }));
await waitForSearchFolderResponse();
expect(locationService.push).toHaveBeenCalledWith(redirectURL);
beforeEach(() => {
jest.spyOn(api, 'setDashboardInLocalStorage').mockReturnValue(addToDashboardResponse);
});
it('Does NOT navigate to dashboard when clicking on "Save and keep exploring"', async () => {
locationService.push = jest.fn();
afterEach(() => {
jest.restoreAllMocks();
});
setup(<AddToDashboard exploreId={ExploreId.left} />, [{ refId: 'A' }]);
it('Opens and closes the modal correctly', async () => {
setup(<AddToDashboard exploreId={ExploreId.left} />);
await openModal();
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
await waitForSearchFolderResponse();
userEvent.click(screen.getByRole('button', { name: /cancel/i }));
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 () => {
setup(<AddToDashboard exploreId={ExploreId.left} />, [{ refId: 'A' }]);
describe('Error handling', () => {
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(
expect.anything(),
expect.objectContaining({
datasource: {
type: 'loki',
uid: 'someuid',
userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
await waitFor(async () => {
expect(await screen.findByRole('alert')).toBeInTheDocument();
});
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 () => {
const queries: DataQuery[] = [{ refId: 'A' }, { refId: 'B', hide: true }];
setup(<AddToDashboard exploreId={ExploreId.left} />, queries);
setup(<AddToDashboard exploreId={ExploreId.left} />);
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(
expect.anything(),
expect.objectContaining({
queries: queries,
})
);
});
await waitFor(async () => {
await screen.findByLabelText('Select option');
});
userEvent.click(screen.getByLabelText('Select option'));
it('Defaults to table if no response is available', async () => {
const queries: DataQuery[] = [{ refId: 'A' }];
setup(<AddToDashboard exploreId={ExploreId.left} />, queries, createEmptyQueryResponse());
userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
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('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 waitFor(async () => {
expect(await screen.findByRole('alert')).toBeInTheDocument();
});
});
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
expect(addToDashboardMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
queries: queries,
panel: 'timeseries',
})
);
});
userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
it('Sets visualization to logs if there are log frames', async () => {
const queries: DataQuery[] = [{ refId: 'A' }];
setup(<AddToDashboard exploreId={ExploreId.left} />, queries, {
...createEmptyQueryResponse(),
logsFrames: [new MutableDataFrame({ refId: 'A', fields: [] })],
await waitFor(async () => {
expect(await screen.findByRole('alert')).toBeInTheDocument();
});
});
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',
})
);
});
});

View File

@ -1,41 +1,9 @@
import React, { useState } from 'react';
import { DataFrame, DataQuery } from '@grafana/data';
import { ExploreId, StoreState } from 'app/types';
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 { ExploreId } from 'app/types';
import { AddToDashboardModal } from './AddToDashboardModal';
import { ToolbarButton } from '@grafana/ui';
import { AddToDashboardModal, ErrorResponse } from './AddToDashboardModal';
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';
};
import { useSelector } from 'react-redux';
import { getExploreItemSelector } from '../state/selectors';
interface Props {
exploreId: ExploreId;
@ -43,41 +11,8 @@ interface Props {
export const AddToDashboard = ({ exploreId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const dispatch = useDispatch();
const selectExploreItem = getExploreItemSelector(exploreId);
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' };
}
};
const explorePaneHasQueries = !!useSelector(selectExploreItem)?.queries?.length;
return (
<>
@ -85,12 +20,12 @@ export const AddToDashboard = ({ exploreId }: Props) => {
icon="apps"
onClick={() => setIsOpen(true)}
aria-label="Add to dashboard"
disabled={queries.length === 0}
disabled={!explorePaneHasQueries}
>
Add to dashboard
</ToolbarButton>
{isOpen && <AddToDashboardModal onClose={() => setIsOpen(false)} onSave={handleSave} />}
{isOpen && <AddToDashboardModal onClose={() => setIsOpen(false)} exploreId={exploreId} />}
</>
);
};

View File

@ -24,7 +24,7 @@ export function getThumbnailURL(uid: string, isLight?: boolean) {
export function SearchCard({ editable, item, onTagSelected, onToggleChecked }: Props) {
const [hasImage, setHasImage] = useState(true);
const [lastUpdated, setLastUpdated] = useState<string>();
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const [showExpandedView, setShowExpandedView] = useState(false);
const timeout = useRef<number | null>(null);
@ -64,7 +64,11 @@ export function SearchCard({ editable, item, onTagSelected, onToggleChecked }: P
if (item.uid && !lastUpdated) {
const dashboard = await backendSrv.getDashboardByUid(item.uid);
const { updated } = dashboard.meta;
setLastUpdated(new Date(updated).toLocaleString());
if (updated) {
setLastUpdated(new Date(updated).toLocaleString());
} else {
setLastUpdated(null);
}
}
};

View File

@ -11,7 +11,7 @@ export interface Props {
imageHeight: number;
imageWidth: number;
item: DashboardSectionItem;
lastUpdated?: string;
lastUpdated?: string | null;
}
export function SearchCardExpanded({ className, imageHeight, imageWidth, item, lastUpdated }: Props) {
@ -48,10 +48,12 @@ export function SearchCardExpanded({ className, imageHeight, imageWidth, item, l
{folderTitle}
</div>
</div>
<div className={styles.updateContainer}>
<div>Last updated</div>
{lastUpdated ? <div className={styles.update}>{lastUpdated}</div> : <Spinner />}
</div>
{lastUpdated !== null && (
<div className={styles.updateContainer}>
<div>Last updated</div>
{lastUpdated ? <div className={styles.update}>{lastUpdated}</div> : <Spinner />}
</div>
)}
</div>
<div>
<TagList className={styles.tagList} tags={item.tags} />

View File

@ -1,6 +1,7 @@
import { DashboardAcl } from './acl';
import { DataQuery } from '@grafana/data';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { VariableModel } from 'app/features/variables/types';
export interface DashboardDTO {
redirectUri?: string;
@ -51,6 +52,11 @@ export interface AnnotationsPermissions {
export interface DashboardDataDTO {
title: string;
uid: string;
templating: {
list: VariableModel[];
};
panels?: any[];
}
export enum DashboardRoutes {