From 7181efd1cf214b47c5e5ab41a79328f0bd15dfc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 12 Apr 2022 13:26:07 +0200 Subject: [PATCH] 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 --- conf/defaults.ini | 3 + docs/sources/explore/_index.md | 8 + .../src/components/Select/Select.tsx | 2 +- .../components/Select/DashboardPicker.tsx | 89 +++ .../components/editors/DashboardPicker.tsx | 57 +- public/app/core/services/backend_srv.ts | 4 +- .../dashboard/state/DashboardModel.ts | 17 + .../features/dashboard/state/initDashboard.ts | 20 +- .../AddToDashboardModal.test.tsx | 137 ----- .../AddToDashboard/AddToDashboardModal.tsx | 253 ++++++--- .../AddToDashboard/addToDashboard.test.ts | 146 +++++ .../explore/AddToDashboard/addToDashboard.ts | 102 +++- .../explore/AddToDashboard/index.test.tsx | 537 ++++++++++-------- .../features/explore/AddToDashboard/index.tsx | 79 +-- .../features/search/components/SearchCard.tsx | 8 +- .../search/components/SearchCardExpanded.tsx | 12 +- public/app/types/dashboard.ts | 6 + 17 files changed, 853 insertions(+), 627 deletions(-) create mode 100644 public/app/core/components/Select/DashboardPicker.tsx delete mode 100644 public/app/features/explore/AddToDashboard/AddToDashboardModal.test.tsx create mode 100644 public/app/features/explore/AddToDashboard/addToDashboard.test.ts diff --git a/conf/defaults.ini b/conf/defaults.ini index e43760be0ef..09696c5f8dc 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1134,6 +1134,9 @@ enable = # The new prometheus visual query builder promQueryBuilder = true +# Experimental Explore to Dashboard workflow +explore2Dashboard = true + # feature1 = true # feature2 = false diff --git a/docs/sources/explore/_index.md b/docs/sources/explore/_index.md index 610fb46f60d..207f5f7b7b1 100644 --- a/docs/sources/explore/_index.md +++ b/docs/sources/explore/_index.md @@ -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. diff --git a/packages/grafana-ui/src/components/Select/Select.tsx b/packages/grafana-ui/src/components/Select/Select.tsx index b6219969017..ad94830f156 100644 --- a/packages/grafana-ui/src/components/Select/Select.tsx +++ b/packages/grafana-ui/src/components/Select/Select.tsx @@ -13,7 +13,7 @@ export function MultiSelect(props: MultiSelectCommonProps) { return ; } -interface AsyncSelectProps extends Omit, 'options'>, SelectAsyncProps { +export interface AsyncSelectProps extends Omit, 'options'>, SelectAsyncProps { // AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options value?: SelectableValue | null; invalid?: boolean; diff --git a/public/app/core/components/Select/DashboardPicker.tsx b/public/app/core/components/Select/DashboardPicker.tsx new file mode 100644 index 00000000000..cc74304e69f --- /dev/null +++ b/public/app/core/components/Select/DashboardPicker.tsx @@ -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, 'value' | 'onChange' | 'loadOptions' | 'menuShouldPortal'> { + value?: DashboardPickerDTO['uid']; + onChange?: (value?: DashboardPickerDTO) => void; +} + +export type DashboardPickerDTO = Pick & + Pick; + +const formatLabel = (folderTitle = 'General', dashboardTitle: string) => `${folderTitle}/${dashboardTitle}`; + +const getDashboards = debounce((query = ''): Promise>> => { + 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>(); + + // 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) => { + setCurrent(sel); + onChange?.(sel?.value); + }, + [onChange, setCurrent] + ); + + return ( + + ); +}; diff --git a/public/app/core/components/editors/DashboardPicker.tsx b/public/app/core/components/editors/DashboardPicker.tsx index f806d44b300..51192d9c0f8 100644 --- a/public/app/core/components/editors/DashboardPicker.tsx +++ b/public/app/core/components/editors/DashboardPicker.tsx @@ -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> = ({ value, onChange, item }) => { - const [current, setCurrent] = useState>(); - - // 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> = ({ + value, + onChange, + item, +}) => { + const { placeholder, isClearable } = item?.settings ?? {}; const onPicked = useCallback( - (sel: SelectableValue) => { - onChange(sel?.value); + (sel?: SelectableValue) => { + onChange(sel?.value?.uid); }, [onChange] ); - const debouncedSearch = debounce(getDashboards, 300); - const { placeholder, isClearable } = item?.settings ?? {}; return ( - + ); }; diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index d26fcbd4905..72e7c92b993 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -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(`/api/dashboards/uid/${uid}`); } getFolderByUid(uid: string) { diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 04776c6cbdc..874127c04d0 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -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 = {}; diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index eef4165468d..654bf8cdab1 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -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 { + // When creating new or adding panels to a dashboard from explore we load it from local storage + const model = store.getObject(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 { }; } -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); +} diff --git a/public/app/features/explore/AddToDashboard/AddToDashboardModal.test.tsx b/public/app/features/explore/AddToDashboard/AddToDashboardModal.test.tsx deleted file mode 100644 index 5cc59ef43e6..00000000000 --- a/public/app/features/explore/AddToDashboard/AddToDashboardModal.test.tsx +++ /dev/null @@ -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( {}} />); - - // there shouldn't be any alert in the modal - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - - const dashboardNameInput = screen.getByRole('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( {}} />); - await waitForSearchFolderResponse(); - - const dashboardNameInput = screen.getByRole('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( {}} />); - - 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( {}} />); - - 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( {}} />); - - 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( {}} />); - - userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); - - expect(await screen.findByRole('alert')).toHaveTextContent('unknown error'); - }); - }); -}); diff --git a/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx b/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx index 4ef879a9829..161f6b57443 100644 --- a/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx +++ b/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx @@ -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> = [ + { + 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 +): asserts errors is DeepMap { + // 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; + exploreId: ExploreId; } -function withRedirect(fn: (redirect: boolean, ...args: T) => {}, redirect: boolean) { - return async (...args: T) => fn(redirect, ...args); -} - -export const AddToDashboardModal = ({ onClose, onSave }: Props) => { - const [submissionError, setSubmissionError] = useState(); +export const AddToDashboardModal = ({ onClose, exploreId }: Props) => { + const exploreItem = useSelector(getExploreItemSelector(exploreId))!; + const [submissionError, setSubmissionError] = useState(); const { - register, handleSubmit, control, - formState: { errors, isSubmitting }, - setError, - } = useForm(); + formState: { errors }, + watch, + } = useForm({ + 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 (
-

Create a new dashboard and add a panel with the explored queries.

+ ( + + + + )} + name="saveTarget" + /> - - - - - - ( - onChange(e.id)} {...field} enableCreateNew inputId="folder" /> - )} - control={control} - name="folderId" - shouldUnregister - rules={{ required: { value: true, message: ERRORS.INVALID_FOLDER } }} - /> - + {saveTarget === SaveTarget.ExistingDashboard && + (() => { + assertIsSaveToExistingDashboardError(errors); + return ( + ( + + onChange(d?.uid)} + /> + + )} + control={control} + name="dashboardUid" + shouldUnregister + rules={{ required: { value: true, message: 'This field is required.' } }} + /> + ); + })()} {submissionError && ( - - {submissionError} + + {submissionError.message} )} - - diff --git a/public/app/features/explore/AddToDashboard/addToDashboard.test.ts b/public/app/features/explore/AddToDashboard/addToDashboard.test.ts new file mode 100644 index 00000000000..b81c7c9ca48 --- /dev/null +++ b/public/app/features/explore/AddToDashboard/addToDashboard.test.ts @@ -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 })]), + }), + }) + ); + } + ); + }); + }); +}); diff --git a/public/app/features/explore/AddToDashboard/addToDashboard.ts b/public/app/features/explore/AddToDashboard/addToDashboard.ts index e27e041cf7d..6128f221de8 100644 --- a/public/app/features/explore/AddToDashboard/addToDashboard.ts +++ b/public/app/features/explore/AddToDashboard/addToDashboard.ts @@ -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 => { - 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'; +} diff --git a/public/app/features/explore/AddToDashboard/index.test.tsx b/public/app/features/explore/AddToDashboard/index.test.tsx index 415980c1249..62db02ba6b4 100644 --- a/public/app/features/explore/AddToDashboard/index.test.tsx +++ b/public/app/features/explore/AddToDashboard/index.test.tsx @@ -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({children}); }; -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, - Parameters - >; - - 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(, []); - it('Opens and closes the modal correctly', async () => { - setup(, [{ 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(, [{ 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(, [{ refId: 'A' }]); + it('Opens and closes the modal correctly', async () => { + setup(); 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await openModal(); + + expect(screen.queryByRole('combobox', { name: /dashboard/ })).not.toBeInTheDocument(); + + userEvent.click(screen.getByRole('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(); + + await openModal(); + + userEvent.click(screen.getByRole('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(); + + 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(); + + 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(, [{ 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(); - 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(); + + 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(, queries); + setup(); - 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(, 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(, 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(, 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(); - 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(, 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(, 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(, 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(, 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', - }) - ); }); }); diff --git a/public/app/features/explore/AddToDashboard/index.tsx b/public/app/features/explore/AddToDashboard/index.tsx index 6cc0f31303f..50d19c3e643 100644 --- a/public/app/features/explore/AddToDashboard/index.tsx +++ b/public/app/features/explore/AddToDashboard/index.tsx @@ -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 => { - 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 - {isOpen && setIsOpen(false)} onSave={handleSave} />} + {isOpen && setIsOpen(false)} exploreId={exploreId} />} ); }; diff --git a/public/app/features/search/components/SearchCard.tsx b/public/app/features/search/components/SearchCard.tsx index 06bdb6c5072..12a2d3e55a8 100644 --- a/public/app/features/search/components/SearchCard.tsx +++ b/public/app/features/search/components/SearchCard.tsx @@ -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(); + const [lastUpdated, setLastUpdated] = useState(null); const [showExpandedView, setShowExpandedView] = useState(false); const timeout = useRef(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); + } } }; diff --git a/public/app/features/search/components/SearchCardExpanded.tsx b/public/app/features/search/components/SearchCardExpanded.tsx index f83b7d807b6..350e2b54347 100644 --- a/public/app/features/search/components/SearchCardExpanded.tsx +++ b/public/app/features/search/components/SearchCardExpanded.tsx @@ -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} -
-
Last updated
- {lastUpdated ?
{lastUpdated}
: } -
+ {lastUpdated !== null && ( +
+
Last updated
+ {lastUpdated ?
{lastUpdated}
: } +
+ )}
diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index aec415207be..614c6061663 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -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 {