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
|
||||
promQueryBuilder = true
|
||||
|
||||
# Experimental Explore to Dashboard workflow
|
||||
explore2Dashboard = true
|
||||
|
||||
# feature1 = true
|
||||
# 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.
|
||||
|
||||
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 />;
|
||||
}
|
||||
|
||||
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;
|
||||
|
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 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} />
|
||||
);
|
||||
};
|
||||
|
@ -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) {
|
||||
|
@ -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 = {};
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 { 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>
|
||||
|
@ -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 { 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';
|
||||
}
|
||||
|
@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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} />
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user