diff --git a/.betterer.results b/.betterer.results
index 7dff19d1230..31d1f23e1c8 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -2636,6 +2636,11 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
+ "public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.tsx:5381": [
+ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"],
+ [0, 0, 0, "No untranslated strings. Wrap text with ", "1"],
+ [0, 0, 0, "No untranslated strings. Wrap text with ", "2"]
+ ],
"public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with ", "0"]
],
@@ -4000,11 +4005,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
- "public/app/features/explore/extensions/AddToDashboard/AddToDashboardForm.tsx:5381": [
- [0, 0, 0, "No untranslated strings. Wrap text with ", "0"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "1"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "2"]
- ],
"public/app/features/explore/extensions/ConfirmNavigationModal.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with ", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with ", "1"],
diff --git a/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.test.tsx b/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.test.tsx
new file mode 100644
index 00000000000..8aecfaa7ddf
--- /dev/null
+++ b/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.test.tsx
@@ -0,0 +1,261 @@
+import { act, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from 'test/test-utils';
+
+import { selectors } from '@grafana/e2e-selectors';
+import { locationService, setEchoSrv } from '@grafana/runtime';
+import { defaultDashboard } from '@grafana/schema';
+import { backendSrv } from 'app/core/services/backend_srv';
+import { contextSrv } from 'app/core/services/context_srv';
+import { Echo } from 'app/core/services/echo/Echo';
+import store from 'app/core/store';
+import { DashboardSearchItemType } from 'app/features/search/types';
+
+import { AddToDashboardForm, Props } from './AddToDashboardForm';
+
+async function setup(overrides: Partial = {}) {
+ const props: Props = {
+ buildPanel: () => ({ id: 1, type: 'table', options: { showHeader: false } }),
+ onClose: jest.fn(),
+ options: undefined,
+ ...overrides,
+ };
+
+ const res = render();
+ await act(() => Promise.resolve());
+ return res;
+}
+
+jest.mock('app/core/services/context_srv');
+
+const mocks = {
+ contextSrv: jest.mocked(contextSrv),
+};
+
+describe('AddToDashboardButton', () => {
+ beforeAll(() => {
+ setEchoSrv(new Echo());
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.spyOn(backendSrv, 'search').mockResolvedValue([]);
+ mocks.contextSrv.hasPermission.mockImplementation(() => true);
+ locationService.push('/');
+ });
+
+ 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);
+
+ await setup();
+
+ await userEvent.click(screen.getByRole('button', { name: /open dashboard$/i }));
+
+ expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument();
+
+ expect(locationService.getLocation().pathname).toBe('/dashboard/new');
+ 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);
+
+ await setup();
+
+ await userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
+
+ expect(openSpy).toHaveBeenCalledWith(expect.anything(), '_blank');
+ expect(locationService.getLocation().pathname).toBe('/');
+ });
+ });
+
+ describe('Add to new dashboard', () => {
+ describe('Navigate to correct dashboard when saving', () => {
+ it('Navigates to the new dashboard', async () => {
+ await setup();
+
+ await userEvent.click(screen.getByRole('button', { name: /open dashboard$/i }));
+
+ expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument();
+ expect(locationService.getLocation().pathname).toBe('/dashboard/new');
+ });
+ });
+ });
+
+ describe('Add to existing dashboard', () => {
+ it('Renders the dashboard picker when switching to "Existing Dashboard"', async () => {
+ await setup();
+
+ expect(screen.queryByRole('combobox', { name: /dashboard/ })).not.toBeInTheDocument();
+
+ await 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();
+
+ await setup();
+
+ await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i }));
+ await userEvent.click(screen.getByRole('button', { name: /open dashboard$/i }));
+
+ locationService.push = jest.fn();
+ 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: { ...defaultDashboard, templating: { list: [] }, title: 'Dashboard Title', uid: 'someUid' },
+ meta: {},
+ });
+
+ jest.spyOn(backendSrv, 'search').mockResolvedValue([
+ {
+ uid: 'someUid',
+ isStarred: false,
+ title: 'Dashboard Title',
+ tags: [],
+ type: DashboardSearchItemType.DashDB,
+ uri: 'someUri',
+ url: 'someUrl',
+ },
+ ]);
+
+ await setup();
+
+ await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i }));
+ await userEvent.click(screen.getByRole('combobox', { name: /dashboard/i }));
+
+ await waitFor(async () => {
+ await screen.findByTestId(selectors.components.Select.option);
+ });
+
+ await userEvent.click(screen.getByTestId(selectors.components.Select.option));
+ await 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 () => {
+ jest.spyOn(backendSrv, 'search').mockResolvedValue([
+ {
+ uid: 'someUid',
+ isStarred: false,
+ title: 'Dashboard Title',
+ tags: [],
+ type: DashboardSearchItemType.DashDB,
+ uri: 'someUri',
+ url: 'someUrl',
+ },
+ ]);
+
+ await setup();
+
+ await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i }));
+ await userEvent.click(screen.getByRole('combobox', { name: /dashboard/i }));
+
+ await waitFor(async () => {
+ await screen.findByTestId(selectors.components.Select.option);
+ });
+
+ await userEvent.click(screen.getByTestId(selectors.components.Select.option));
+ await userEvent.click(screen.getByRole('button', { name: /open dashboard$/i }));
+
+ await waitFor(async () => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ expect(locationService.getLocation().pathname).toBe('/d/someUid');
+ });
+ });
+ });
+});
+
+describe('Permissions', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('Should only show existing dashboard option with no access to create', async () => {
+ mocks.contextSrv.hasPermission.mockImplementation((action) => {
+ if (action === 'dashboards:create') {
+ return false;
+ } else {
+ return true;
+ }
+ });
+
+ await setup();
+
+ expect(screen.queryByRole('radio')).not.toBeInTheDocument();
+ });
+
+ it('Should only show new dashboard option with no access to write', async () => {
+ mocks.contextSrv.hasPermission.mockImplementation((action) => {
+ if (action === 'dashboards:write') {
+ return false;
+ } else {
+ return true;
+ }
+ });
+
+ await setup();
+
+ expect(screen.queryByRole('radio')).not.toBeInTheDocument();
+ });
+});
+
+describe('Error handling', () => {
+ beforeEach(() => {
+ mocks.contextSrv.hasPermission.mockImplementation(() => true);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('Shows an error if opening a new tab fails', async () => {
+ jest.spyOn(global, 'open').mockReturnValue(null);
+ const removeDashboardSpy = jest.spyOn(store, 'delete');
+
+ await setup();
+
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+
+ await 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(store, 'setObject').mockImplementation(() => {
+ throw 'SOME ERROR';
+ });
+
+ await setup();
+
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
+
+ await waitFor(async () => {
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/public/app/features/explore/extensions/AddToDashboard/AddToDashboardForm.tsx b/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.tsx
similarity index 61%
rename from public/app/features/explore/extensions/AddToDashboard/AddToDashboardForm.tsx
rename to public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.tsx
index 847a981a7bc..7c000604565 100644
--- a/public/app/features/explore/extensions/AddToDashboard/AddToDashboardForm.tsx
+++ b/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.tsx
@@ -1,18 +1,16 @@
import { partial } from 'lodash';
-import { type ReactElement, useEffect, useState } from 'react';
-import { DeepMap, FieldError, FieldErrors, useForm, Controller } from 'react-hook-form';
+import { ReactElement, useEffect, useState } from 'react';
+import { Controller, DeepMap, FieldError, FieldErrors, useForm } from 'react-hook-form';
-import { locationUtil, SelectableValue } from '@grafana/data';
-import { config, locationService, reportInteraction } from '@grafana/runtime';
+import { SelectableValue, TimeRange } from '@grafana/data';
+import { reportInteraction } from '@grafana/runtime';
+import { Panel } from '@grafana/schema';
import { Alert, Button, Field, Modal, RadioButtonGroup } from '@grafana/ui';
import { DashboardPicker } from 'app/core/components/Select/DashboardPicker';
-import { contextSrv } from 'app/core/services/context_srv';
-import { removeDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard';
-import { AccessControlAction, useSelector } from 'app/types';
+import { contextSrv } from 'app/core/core';
+import { AccessControlAction } from 'app/types';
-import { getExploreItemSelector } from '../../state/selectors';
-
-import { setDashboardInLocalStorage, AddToDashboardError } from './addToDashboard';
+import { addToDashboard, SubmissionError } from './addToDashboard';
enum SaveTarget {
NewDashboard = 'new-dashboard',
@@ -22,6 +20,7 @@ enum SaveTarget {
interface SaveTargetDTO {
saveTarget: SaveTarget;
}
+
interface SaveToNewDashboardDTO extends SaveTargetDTO {
saveTarget: SaveTarget.NewDashboard;
}
@@ -33,36 +32,21 @@ interface SaveToExistingDashboard extends SaveTargetDTO {
type FormDTO = SaveToNewDashboardDTO | SaveToExistingDashboard;
-function assertIsSaveToExistingDashboardError(
- errors: FieldErrors
-): 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 {
+export interface Props {
onClose: () => void;
- exploreId: string;
+ buildPanel: (options: TOptions) => Panel;
+ timeRange?: TimeRange;
+ options: TOptions;
+ children?: React.ReactNode;
}
-export function AddToDashboardForm(props: Props): ReactElement {
- const { exploreId, onClose } = props;
- const exploreItem = useSelector(getExploreItemSelector(exploreId))!;
+export function AddToDashboardForm({
+ onClose,
+ buildPanel,
+ timeRange,
+ options,
+ children,
+}: Props): ReactElement {
const [submissionError, setSubmissionError] = useState();
const {
handleSubmit,
@@ -77,12 +61,14 @@ export function AddToDashboardForm(props: Props): ReactElement {
const canWriteDashboard = contextSrv.hasPermission(AccessControlAction.DashboardsWrite);
const saveTargets: Array> = [];
+
if (canCreateDashboard) {
saveTargets.push({
label: 'New dashboard',
value: SaveTarget.NewDashboard,
});
}
+
if (canWriteDashboard) {
saveTargets.push({
label: 'Existing dashboard',
@@ -92,60 +78,24 @@ export function AddToDashboardForm(props: Props): ReactElement {
const saveTarget = saveTargets.length > 1 ? watch('saveTarget') : saveTargets[0].value;
- const onSubmit = async (openInNewTab: boolean, data: FormDTO) => {
+ const onSubmit = (openInNewTab: boolean, data: FormDTO) => {
setSubmissionError(undefined);
+
const dashboardUid = data.saveTarget === SaveTarget.ExistingDashboard ? data.dashboardUid : undefined;
+ const panel = buildPanel(options);
reportInteraction('e_2_d_submit', {
newTab: openInNewTab,
saveTarget: data.saveTarget,
- queries: exploreItem.queries.length,
+ queries: panel.targets,
});
- const { from, to } = exploreItem.range.raw;
-
- try {
- await setDashboardInLocalStorage({
- dashboardUid,
- datasource: exploreItem.datasourceInstance?.getRef(),
- queries: exploreItem.queries,
- queryResponse: exploreItem.queryResponse,
- panelState: exploreItem?.panelsState,
- time: {
- from: typeof from === 'string' ? from : from.toISOString(),
- to: typeof to === 'string' ? to : to.toISOString(),
- },
- });
- } catch (error) {
- switch (error) {
- case AddToDashboardError.FETCH_DASHBOARD:
- setSubmissionError({ error, message: 'Could not fetch dashboard information. Please try again.' });
- break;
- case AddToDashboardError.SET_DASHBOARD_LS:
- setSubmissionError({ error, message: 'Could not add panel to dashboard. Please try again.' });
- break;
- default:
- setSubmissionError({ error: GenericError.UNKNOWN, message: 'Something went wrong. Please try again.' });
- }
+ const error = addToDashboard({ dashboardUid, panel, openInNewTab, timeRange });
+ if (error) {
+ setSubmissionError(error);
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();
};
@@ -155,6 +105,9 @@ export function AddToDashboardForm(props: Props): ReactElement {
return (