AddToDashboard: Refactorings & changes to address a number of problems (#94458)

* Extract shared add to dashboard flow

* Change how we save and add the new panels

* More cleanup

* Began updating tests

* Move and update tests

* set time range

* Fix lint / ts issues

* Fix time history step issue

* fix test

* Update betterer

* remove more tests that are now not needed

* work around cyclic dep

* Update

* Fixes

* fix lint

* remove unused type
This commit is contained in:
Torkel Ödegaard
2024-10-11 15:24:45 +02:00
committed by GitHub
parent 992186c88f
commit 6787e2f108
29 changed files with 614 additions and 822 deletions
+5 -5
View File
@@ -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 <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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 <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/explore/extensions/ConfirmNavigationModal.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
@@ -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<Props> = {}) {
const props: Props = {
buildPanel: () => ({ id: 1, type: 'table', options: { showHeader: false } }),
onClose: jest.fn(),
options: undefined,
...overrides,
};
const res = render(<AddToDashboardForm {...props} />);
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<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();
await setup();
await userEvent.click(screen.getByRole<HTMLInputElement>('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();
});
});
});
@@ -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<FormDTO>
): 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 {
export interface Props<TOptions = undefined> {
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<TOptions = undefined>({
onClose,
buildPanel,
timeRange,
options,
children,
}: Props<TOptions>): ReactElement {
const [submissionError, setSubmissionError] = useState<SubmissionError | undefined>();
const {
handleSubmit,
@@ -77,12 +61,14 @@ export function AddToDashboardForm(props: Props): ReactElement {
const canWriteDashboard = contextSrv.hasPermission(AccessControlAction.DashboardsWrite);
const saveTargets: Array<SelectableValue<SaveTarget>> = [];
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 (
<form>
{/* For custom form options */}
{children}
{saveTargets.length > 1 && (
<Controller
control={control}
@@ -220,3 +173,11 @@ export function AddToDashboardForm(props: Props): ReactElement {
</form>
);
}
function assertIsSaveToExistingDashboardError(
errors: FieldErrors<FormDTO>
): 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.
}
@@ -0,0 +1,35 @@
import { SceneTimeRange } from '@grafana/scenes';
import store from 'app/core/store';
import { DashboardModel } from 'app/features/dashboard/state';
import { DASHBOARD_FROM_LS_KEY, DashboardDTO } from 'app/types';
import { DashboardScene } from '../scene/DashboardScene';
import { buildGridItemForPanel } from '../serialization/transformSaveModelToScene';
export function addPanelsOnLoadBehavior(scene: DashboardScene) {
const dto = store.getObject<DashboardDTO>(DASHBOARD_FROM_LS_KEY);
if (dto) {
console.log('asd', dto);
const model = new DashboardModel(dto.dashboard);
for (const panel of model.panels) {
const gridItem = buildGridItemForPanel(panel);
scene.addPanel(gridItem.state.body);
}
if (dto.dashboard.time) {
const newTimeRange = new SceneTimeRange({ from: dto.dashboard.time.from, to: dto.dashboard.time.to });
const timeRange = scene.state.$timeRange;
if (timeRange) {
timeRange.setState({
value: newTimeRange.state.value,
from: newTimeRange.state.from,
to: newTimeRange.state.to,
});
}
}
}
store.delete(DASHBOARD_FROM_LS_KEY);
}
@@ -0,0 +1,41 @@
import { dateTime } from '@grafana/data';
import store from 'app/core/store';
import { addToDashboard } from './addToDashboard';
describe('addToDashboard', () => {
let spy: jest.SpyInstance;
beforeAll(() => {
spy = jest.spyOn(store, 'setObject');
});
afterEach(() => {
jest.resetAllMocks();
});
it('Should save dashboard with new panel in local storage', () => {
addToDashboard({
panel: {
type: 'table',
gridPos: { x: 0, y: 0, w: 12, h: 12 },
options: { showHeader: true },
},
});
const panel = spy.mock.calls[0][1].dashboard.panels[0];
expect(panel.type).toEqual('table');
expect(panel.options).toEqual({ showHeader: true });
});
it('Correct time range is used', () => {
addToDashboard({
panel: { type: 'table' },
timeRange: { from: dateTime(), to: dateTime(), raw: { from: 'now-5m', to: 'now' } },
});
const dashboard = spy.mock.calls[0][1].dashboard;
expect(dashboard.time.from).toEqual('now-5m');
expect(dashboard.time.to).toEqual('now');
});
});
@@ -0,0 +1,85 @@
import { locationUtil, TimeRange } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { Panel } from '@grafana/schema';
import store from 'app/core/store';
import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/DashboardMigrator';
import { DASHBOARD_FROM_LS_KEY, DashboardDTO } from 'app/types';
export enum GenericError {
UNKNOWN = 'unknown-error',
NAVIGATION = 'navigation-error',
}
export interface SubmissionError {
error: AddToDashboardError | GenericError;
message: string;
}
export enum AddToDashboardError {
FETCH_DASHBOARD = 'fetch-dashboard',
SET_DASHBOARD_LS = 'set-dashboard-ls-error',
}
interface AddPanelToDashboardOptions {
panel: Panel;
dashboardUid?: string;
openInNewTab?: boolean;
timeRange?: TimeRange;
}
export function addToDashboard({
panel,
dashboardUid,
openInNewTab,
timeRange,
}: AddPanelToDashboardOptions): SubmissionError | undefined {
let dto: DashboardDTO = {
meta: {},
dashboard: {
title: '',
uid: '',
panels: [panel],
schemaVersion: DASHBOARD_SCHEMA_VERSION,
},
};
if (timeRange) {
const raw = timeRange.raw;
dto.dashboard.time = {
from: typeof raw.from === 'string' ? raw.from : raw.from.toISOString(),
to: typeof raw.to === 'string' ? raw.to : raw.to.toISOString(),
};
}
try {
store.setObject(DASHBOARD_FROM_LS_KEY, dto);
} catch {
return {
error: AddToDashboardError.SET_DASHBOARD_LS,
message: 'Could not add panel to dashboard. Please try again.',
};
}
const dashboardURL = getDashboardURL(dashboardUid);
if (openInNewTab) {
const didTabOpen = !!global.open(config.appUrl + dashboardURL, '_blank');
if (!didTabOpen) {
store.delete(DASHBOARD_FROM_LS_KEY);
return {
error: GenericError.NAVIGATION,
message: 'Could not navigate to the selected dashboard. Please try again.',
};
}
return;
}
locationService.push(locationUtil.stripBaseFromUrl(dashboardURL));
return;
}
function getDashboardURL(dashboardUid?: string) {
return dashboardUid ? `d/${dashboardUid}` : 'dashboard/new';
}
@@ -20,8 +20,7 @@ import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import store from 'app/core/store';
import { DashboardLoaderSrv, setDashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { DASHBOARD_FROM_LS_KEY } from 'app/features/dashboard/state/initDashboard';
import { DashboardRoutes } from 'app/types';
import { DASHBOARD_FROM_LS_KEY, DashboardRoutes } from 'app/types';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
@@ -267,20 +266,6 @@ describe('DashboardScenePage', () => {
});
});
it('is in edit mode when coming from explore to an existing dashboard', async () => {
store.setObject(DASHBOARD_FROM_LS_KEY, { dashboard: simpleDashboard, meta: { slug: '123' } });
setup();
await waitForDashboardToRender();
const panelAMenu = await screen.findByLabelText('Menu for panel with title Panel A');
expect(panelAMenu).toBeInTheDocument();
await userEvent.click(panelAMenu);
const editMenuItem = await screen.findAllByText('Edit');
expect(editMenuItem).toHaveLength(1);
});
describe('home page', () => {
it('should render the dashboard when the route is home', async () => {
(useParams as jest.Mock).mockReturnValue({});
@@ -1,5 +1,5 @@
// Libraries
import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { usePrevious } from 'react-use';
@@ -9,10 +9,8 @@ import { Alert, Box } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import store from 'app/core/store';
import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from 'app/features/dashboard/containers/types';
import { DASHBOARD_FROM_LS_KEY } from 'app/features/dashboard/state/initDashboard';
import { DashboardDTO, DashboardRoutes } from 'app/types';
import { DashboardRoutes } from 'app/types';
import { DashboardPrompt } from '../saving/DashboardPrompt';
@@ -30,12 +28,6 @@ export function DashboardScenePage({ route, queryParams, history }: Props) {
// After scene migration is complete and we get rid of old dashboard we should refactor dashboardWatcher so this route reload is not need
const routeReloadCounter = (history.location.state as any)?.routeReloadCounter;
// Check if the user is coming from Explore, it's indicated byt the dashboard existence in local storage
const comingFromExplore = useMemo(() => {
return Boolean(store.getObject<DashboardDTO>(DASHBOARD_FROM_LS_KEY));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uid, slug, type]);
useEffect(() => {
if (route.routeName === DashboardRoutes.Normal && type === 'snapshot') {
stateManager.loadSnapshot(slug!);
@@ -44,7 +36,6 @@ export function DashboardScenePage({ route, queryParams, history }: Props) {
uid: uid ?? '',
route: route.routeName as DashboardRoutes,
urlFolderUid: queryParams.folderUid,
keepDashboardFromExploreInLocalStorage: false,
});
}
@@ -53,16 +44,6 @@ export function DashboardScenePage({ route, queryParams, history }: Props) {
};
}, [stateManager, uid, route.routeName, queryParams.folderUid, routeReloadCounter, slug, type]);
// Effect that handles explore->dashboards workflow
useEffect(() => {
// When coming from explore and adding to an existing dashboard, we should enter edit mode
if (dashboard && comingFromExplore) {
if (route.routeName !== DashboardRoutes.New) {
dashboard.onEnterEditMode(comingFromExplore);
}
}
}, [dashboard, comingFromExplore, route.routeName]);
if (!dashboard) {
return (
<Page navId="dashboards/browse" layout={PageLayoutType.Canvas} data-testid={'dashboard-scene-page'}>
@@ -2,8 +2,7 @@ import { advanceBy } from 'jest-date-mock';
import { BackendSrv, setBackendSrv } from '@grafana/runtime';
import store from 'app/core/store';
import { DASHBOARD_FROM_LS_KEY } from 'app/features/dashboard/state/initDashboard';
import { DashboardRoutes } from 'app/types';
import { DASHBOARD_FROM_LS_KEY, DashboardRoutes } from 'app/types';
import { DashboardScene } from '../scene/DashboardScene';
import { setupLoadDashboardMock } from '../utils/test-utils';
@@ -56,17 +55,6 @@ describe('DashboardScenePageStateManager', () => {
expect(loader.state.dashboard).toBeUndefined();
});
it('shoud fetch dashboard from local storage and remove it after if it exists', async () => {
const loader = new DashboardScenePageStateManager({});
const localStorageDashboard = { uid: 'fake-dash' };
store.setObject(DASHBOARD_FROM_LS_KEY, localStorageDashboard);
const result = await loader.fetchDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(result).toEqual(localStorageDashboard);
expect(store.getObject(DASHBOARD_FROM_LS_KEY)).toBeUndefined();
});
it('should initialize the dashboard scene with the loaded dashboard', async () => {
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
@@ -215,36 +203,5 @@ describe('DashboardScenePageStateManager', () => {
expect(loadDashSpy).toHaveBeenCalledTimes(2);
});
});
describe('When coming from explore', () => {
it('shoud fetch dashboard from local storage and keep it there after when asked', async () => {
const loader = new DashboardScenePageStateManager({});
const localStorageDashboard = { uid: 'fake-dash' };
store.setObject(DASHBOARD_FROM_LS_KEY, { dashboard: localStorageDashboard });
const result = await loader.fetchDashboard({
uid: 'fake-dash',
route: DashboardRoutes.Normal,
keepDashboardFromExploreInLocalStorage: true,
});
expect(result).toEqual({ dashboard: localStorageDashboard });
expect(store.getObject(DASHBOARD_FROM_LS_KEY)).toEqual({ dashboard: localStorageDashboard });
});
it('shoud not store dashboard in cache when coming from Explore', async () => {
const loader = new DashboardScenePageStateManager({});
const localStorageDashboard = { uid: 'fake-dash' };
store.setObject(DASHBOARD_FROM_LS_KEY, { dashboard: localStorageDashboard });
await loader.loadDashboard({
uid: 'fake-dash',
route: DashboardRoutes.Normal,
keepDashboardFromExploreInLocalStorage: false,
});
expect(loader.getDashboardFromCache('fake-dash')).toBeNull();
});
});
});
});
@@ -1,16 +1,11 @@
import { locationUtil } from '@grafana/data';
import { config, getBackendSrv, isFetchError, locationService } from '@grafana/runtime';
import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { default as localStorageStore } from 'app/core/store';
import { getMessageFromError } from 'app/core/utils/errors';
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor';
import {
DASHBOARD_FROM_LS_KEY,
removeDashboardToFetchFromLocalStorage,
} from 'app/features/dashboard/state/initDashboard';
import { trackDashboardSceneLoaded } from 'app/features/dashboard/utils/tracking';
import { getSelectedScopesNames } from 'app/features/scopes';
import { DashboardDTO, DashboardRoutes } from 'app/types';
@@ -47,12 +42,6 @@ export interface LoadDashboardOptions {
uid: string;
route: DashboardRoutes;
urlFolderUid?: string;
// A temporary approach not to clean the dashboard from local storage when navigating from Explore to Dashboard
// We currently need it as there are two flows of fetching dashboard. The legacy one (initDashboard), uses the new one(DashboardScenePageStateManager.fetch) where the
// removal of the dashboard from local storage is implemented. So in the old flow we wouldn't be able to early return dashboard from local storage, if we prematurely
// removed it when prefetching the dashboard in DashboardPageProxy.
// This property will be removed when the old flow (initDashboard) is removed.
keepDashboardFromExploreInLocalStorage?: boolean;
}
export class DashboardScenePageStateManager extends StateManagerBase<DashboardScenePageState> {
@@ -63,21 +52,7 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
// To eventualy replace the fetchDashboard function from Dashboard redux state management.
// For now it's a simplistic version to support Home and Normal dashboard routes.
public async fetchDashboard({
uid,
route,
urlFolderUid,
keepDashboardFromExploreInLocalStorage,
}: LoadDashboardOptions): Promise<DashboardDTO | null> {
const model = localStorageStore.getObject<DashboardDTO>(DASHBOARD_FROM_LS_KEY);
if (model) {
if (!keepDashboardFromExploreInLocalStorage) {
removeDashboardToFetchFromLocalStorage();
}
return model;
}
public async fetchDashboard({ uid, route, urlFolderUid }: LoadDashboardOptions): Promise<DashboardDTO | null> {
const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid;
const cachedDashboard = this.getDashboardFromCache(cacheKey);
@@ -203,29 +178,20 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
}
private async loadScene(options: LoadDashboardOptions): Promise<DashboardScene | null> {
const comingFromExplore = Boolean(
localStorageStore.getObject<DashboardDTO>(DASHBOARD_FROM_LS_KEY) &&
options.keepDashboardFromExploreInLocalStorage === false
);
this.setState({ dashboard: undefined, isLoading: true });
const rsp = await this.fetchDashboard(options);
const fromCache = this.getSceneFromCache(options.uid);
// When coming from Explore, skip returnning scene from cache
if (!comingFromExplore) {
if (fromCache && fromCache.state.version === rsp?.dashboard.version) {
return fromCache;
}
if (fromCache && fromCache.state.version === rsp?.dashboard.version) {
return fromCache;
}
if (rsp?.dashboard) {
const scene = transformSaveModelToScene(rsp);
// Cache scene only if not coming from Explore, we don't want to cache temporary dashboard
if (options.uid && !comingFromExplore) {
if (options.uid) {
this.setSceneCache(options.uid, scene);
}
@@ -18,6 +18,7 @@ import {
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { selectors } from '@grafana/e2e-selectors';
import { config, locationService, setPluginExtensionsHook } from '@grafana/runtime';
import { PANEL_EDIT_LAST_USED_DATASOURCE } from 'app/features/dashboard/utils/dashboard';
import { InspectTab } from 'app/features/inspector/types';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types';
@@ -257,6 +258,7 @@ jest.mock('app/core/store', () => ({
get: jest.fn(),
getObject: jest.fn((_a, b) => b),
setObject: jest.fn(),
delete: jest.fn(),
}));
const store = jest.requireMock('app/core/store');
@@ -665,9 +667,14 @@ describe('PanelDataQueriesTab', () => {
it('should load last used data source if no data source specified for a panel', async () => {
store.exists.mockReturnValue(true);
store.getObject.mockReturnValue({
dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f',
datasourceUid: 'gdev-testdata',
store.getObject.mockImplementation((key: string, def: unknown) => {
if (key === PANEL_EDIT_LAST_USED_DATASOURCE) {
return {
dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f',
datasourceUid: 'gdev-testdata',
};
}
return def;
});
const { queriesTab } = await setupScene('panel-5');
@@ -812,47 +812,6 @@ describe('DashboardScene', () => {
});
});
describe('When coming from explore', () => {
// When coming from Explore the first panel in a dashboard is a temporary panel
it('should remove first panel from the grid when discarding changes', () => {
const layout = DefaultGridLayoutManager.fromVizPanels([
new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
$data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }),
}),
new VizPanel({
title: 'Panel B',
key: 'panel-2',
pluginId: 'table',
}),
]);
const scene = new DashboardScene({
title: 'hello',
uid: 'dash-1',
description: 'hello description',
editable: true,
$timeRange: new SceneTimeRange({
timeZone: 'browser',
}),
controls: new DashboardControls({}),
$behaviors: [new behaviors.CursorSync({})],
body: layout,
});
scene.onEnterEditMode(true);
expect(scene.state.isEditing).toBe(true);
expect(layout.state.grid.state.children.length).toBe(2);
scene.exitEditMode({ skipConfirm: true });
const restoredGrid = scene.state.body as DefaultGridLayoutManager;
expect(scene.state.isEditing).toBe(false);
expect(restoredGrid.state.grid.state.children.length).toBe(1);
});
});
describe('When a dashboard contain angular panels', () => {
it('should return true if the dashboard contains angular panels', () => {
// create a scene with angular panels inside
@@ -158,11 +158,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
*/
private _changeTracker: DashboardSceneChangeTracker;
/**
* Flag to indicate if the user came from Explore
*/
private _fromExplore = false;
/**
* A reference to the scopes facade
*/
@@ -244,8 +239,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
}
}
public onEnterEditMode = (fromExplore = false) => {
this._fromExplore = fromExplore;
public onEnterEditMode = () => {
// Save this state
this._initialState = sceneUtils.cloneSceneObjectState(this.state);
this._initialUrlState = locationService.getLocation();
@@ -334,10 +328,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
locationService.replace(locationUtil.stripBaseFromUrl(url));
if (this._fromExplore) {
this.cleanupStateFromExplore();
}
if (restoreInitialState) {
// Restore initial state and disable editing
this.setState({ ...this._initialState, isEditing: false });
@@ -357,18 +347,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
this.state.body.editModeChanged(false);
}
private cleanupStateFromExplore() {
this._fromExplore = false;
// When coming from explore but discarding changes, remove the panel that explore is potentially adding.
if (this._initialSaveModel?.panels) {
this._initialSaveModel.panels = this._initialSaveModel.panels.slice(1);
}
if (this._initialState) {
this._initialState.body.cleanUpStateFromExplore?.();
}
}
public canDiscard() {
return this._initialState !== undefined;
}
@@ -482,6 +460,10 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
this.onEnterEditMode();
}
const panelId = dashboardSceneGraph.getNextPanelId(this);
vizPanel.setState({ key: getVizPanelKeyForPanelId(panelId) });
vizPanel.clearParent();
this.state.body.addPanel(vizPanel);
}
@@ -532,7 +514,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
panel.setState({ key: getVizPanelKeyForPanelId(panelId) });
panel.clearParent();
this.state.body.addPanel(panel);
this.addPanel(panel);
store.delete(LS_PANEL_COPY_KEY);
}
@@ -23,6 +23,7 @@ import {
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { DashboardDTO, DashboardDataDTO } from 'app/types';
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls';
@@ -248,6 +249,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
registerPanelInteractionsReporter,
new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }),
preserveDashboardSceneStateInLocalStorage,
addPanelsOnLoadBehavior,
new DashboardScopesFacade({
reloadOnScopesChange: oldModel.meta.reloadOnScopesChange,
uid: oldModel.uid,
@@ -192,13 +192,6 @@ export function HelpWizard({ panel, plugin, onClose }: Props) {
>
<Trans i18nKey="help-wizard.github-comment">Copy Github comment</Trans>
</ClipboardButton>
<Button
icon="eye"
onClick={service.onPreviewDashboard}
title="Open support snapshot dashboard in a new tab"
>
<Trans i18nKey="help-wizard.preview-snapshot">Preview snapshot</Trans>
</Button>
</Stack>
</Field>
@@ -1,7 +1,6 @@
import saveAs from 'file-saver';
import { dateTimeFormat, formattedValueToString, getValueFormat, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { SceneObject } from '@grafana/scenes';
import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { Randomize } from 'app/features/dashboard-scene/inspect/HelpWizard/randomizer';
@@ -9,7 +8,6 @@ import { createDashboardSceneFromDashboardModel } from 'app/features/dashboard-s
import { getTimeSrv } from '../../services/TimeSrv';
import { DashboardModel, PanelModel } from '../../state';
import { setDashboardToFetchFromLocalStorage } from '../../state/initDashboard';
import { getDebugDashboard, getGithubMarkdown } from './utils';
@@ -136,12 +134,4 @@ export class SupportSnapshotService extends StateManagerBase<SupportSnapshotStat
const { randomize } = this.state;
this.setState({ randomize: { ...randomize, [k]: !randomize[k] } });
};
onPreviewDashboard = () => {
const { snapshot } = this.state;
if (snapshot) {
setDashboardToFetchFromLocalStorage({ meta: {}, dashboard: snapshot });
global.open(config.appUrl + 'dashboard/new', '_blank');
}
};
}
@@ -40,11 +40,7 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
return null;
}
return stateManager.fetchDashboard({
route: props.route.routeName as DashboardRoutes,
uid: params.uid ?? '',
keepDashboardFromExploreInLocalStorage: true,
});
return stateManager.fetchDashboard({ route: props.route.routeName as DashboardRoutes, uid: params.uid ?? '' });
}, [params.uid, props.route.routeName]);
if (!config.featureToggles.dashboardSceneForViewers) {
@@ -19,7 +19,15 @@ import { getFolderByUid } from 'app/features/folders/state/actions';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { toStateKey } from 'app/features/variables/utils';
import { DashboardDTO, DashboardInitPhase, DashboardRoutes, StoreState, ThunkDispatch, ThunkResult } from 'app/types';
import {
DASHBOARD_FROM_LS_KEY,
DashboardDTO,
DashboardInitPhase,
DashboardRoutes,
StoreState,
ThunkDispatch,
ThunkResult,
} from 'app/types';
import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner';
import { initVariablesTransaction } from '../../variables/state/actions';
@@ -50,13 +58,6 @@ 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: {
@@ -184,7 +185,6 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
// fetch dashboard data
const dashDTO = await fetchDashboard(args, dispatch, getState);
const versionBeforeMigration = dashDTO?.dashboard?.version;
// returns null if there was a redirect or error
@@ -192,6 +192,8 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
return;
}
addPanelsFromLocalStorage(dashDTO);
// set initializing state
dispatch(dashboardInitServices());
@@ -298,12 +300,18 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
};
}
export const DASHBOARD_FROM_LS_KEY = 'DASHBOARD_FROM_LS_KEY';
function addPanelsFromLocalStorage(model: DashboardDTO) {
// When creating new or adding panels to a dashboard from explore we load it from local storage
const fromLS = store.getObject<DashboardDTO>(DASHBOARD_FROM_LS_KEY);
if (fromLS) {
if (fromLS.dashboard.panels) {
model.dashboard.panels = fromLS.dashboard.panels.concat(model.dashboard.panels);
}
export function setDashboardToFetchFromLocalStorage(model: DashboardDTO) {
store.setObject(DASHBOARD_FROM_LS_KEY, model);
}
if (fromLS.dashboard.time) {
model.dashboard.time = fromLS.dashboard.time;
}
export function removeDashboardToFetchFromLocalStorage() {
store.delete(DASHBOARD_FROM_LS_KEY);
store.delete(DASHBOARD_FROM_LS_KEY);
}
}
@@ -119,7 +119,7 @@ type LastUsedDatasource =
}
| undefined;
const PANEL_EDIT_LAST_USED_DATASOURCE = 'grafana.dashboards.panelEdit.lastUsedDatasource';
export const PANEL_EDIT_LAST_USED_DATASOURCE = 'grafana.dashboards.panelEdit.lastUsedDatasource';
// Function that returns last used datasource from local storage
export function getLastUsedDatasourceFromStorage(dashboardUid: string): LastUsedDatasource {
@@ -0,0 +1,31 @@
import { type ReactElement } from 'react';
import { AddToDashboardForm } from 'app/features/dashboard-scene/addToDashboard/AddToDashboardForm';
import { useSelector } from 'app/types';
import { getExploreItemSelector } from '../../state/selectors';
import { buildDashboardPanelFromExploreState } from './addToDashboard';
interface Props {
onClose: () => void;
exploreId: string;
}
export function ExploreToDashboardPanel(props: Props): ReactElement {
const { exploreId, onClose } = props;
const exploreItem = useSelector(getExploreItemSelector(exploreId))!;
const buildPanel = () => {
return buildDashboardPanelFromExploreState({
datasource: exploreItem.datasourceInstance?.getRef(),
queries: exploreItem.queries,
queryResponse: exploreItem.queryResponse,
panelState: exploreItem?.panelsState,
});
};
return (
<AddToDashboardForm onClose={onClose} buildPanel={buildPanel} timeRange={exploreItem.range} options={undefined} />
);
}
@@ -1,115 +1,29 @@
import { MutableDataFrame } from '@grafana/data';
import { DataQuery, defaultDashboard } from '@grafana/schema';
import * as api from 'app/features/dashboard/state/initDashboard';
import { getDefaultTimeRange, MutableDataFrame } from '@grafana/data';
import { DataQuery, LoadingState } from '@grafana/schema';
import { ExplorePanelData } from 'app/types';
import { createEmptyQueryResponse } from '../../state/utils';
import { setDashboardInLocalStorage } from './addToDashboard';
let mockDashboard = {} as unknown;
jest.mock('app/features/dashboard/api/dashboard_api', () => ({
getDashboardAPI: () => ({
getDashboardDTO: () => {
return Promise.resolve(mockDashboard);
},
}),
}));
describe('addPanelToDashboard', () => {
let spy: jest.SpyInstance;
beforeAll(() => {
spy = jest.spyOn(api, 'setDashboardToFetchFromLocalStorage');
});
import { buildDashboardPanelFromExploreState } from './addToDashboard';
describe('buildDashboardPanelFromExploreState', () => {
afterEach(() => {
jest.resetAllMocks();
});
it('Correct datasource ref is used', async () => {
await setDashboardInLocalStorage({
it('Correct datasource ref is used', () => {
const result = buildDashboardPanelFromExploreState({
queries: [],
queryResponse: createEmptyQueryResponse(),
datasource: { type: 'loki', uid: 'someUid' },
time: { from: 'now-1h', to: 'now' },
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
dashboard: expect.objectContaining({
panels: expect.arrayContaining([expect.objectContaining({ datasource: { type: 'loki', uid: 'someUid' } })]),
}),
})
);
});
it('Correct time range is used', async () => {
await setDashboardInLocalStorage({
queries: [],
queryResponse: createEmptyQueryResponse(),
datasource: { type: 'loki', uid: 'someUid' },
time: { from: 'now-10h', to: 'now' },
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
dashboard: expect.objectContaining({
time: expect.objectContaining({ from: 'now-10h', to: 'now' }),
}),
})
);
expect(result.datasource).toEqual({ type: 'loki', uid: 'someUid' });
});
it('All queries are correctly passed through', async () => {
it('All queries are correctly passed through', () => {
const queries: DataQuery[] = [{ refId: 'A' }, { refId: 'B', hide: true }];
await setDashboardInLocalStorage({
queries,
queryResponse: createEmptyQueryResponse(),
time: { from: 'now-1h', to: 'now' },
});
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' };
// Set the mocked dashboard
mockDashboard = {
dashboard: {
...defaultDashboard,
templating: { list: [] },
title: 'Previous panels should not be removed',
uid: 'someUid',
panels: [existingPanel],
},
meta: {},
};
await setDashboardInLocalStorage({
queries,
queryResponse: createEmptyQueryResponse(),
dashboardUid: 'someUid',
datasource: { type: '' },
time: { from: 'now-1h', to: 'now' },
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
dashboard: expect.objectContaining({
panels: expect.arrayContaining([
expect.objectContaining({ targets: expect.arrayContaining(queries) }),
existingPanel,
]),
}),
})
);
const result = buildDashboardPanelFromExploreState({ queries, queryResponse: createEmptyQueryResponse() });
expect(result.targets).toEqual(queries);
});
describe('Setting visualization type', () => {
@@ -125,14 +39,8 @@ describe('addPanelToDashboard', () => {
];
it.each(cases)('%s', async (_, queries, queryResponse) => {
await setDashboardInLocalStorage({ queries, queryResponse, time: { from: 'now-1h', to: 'now' } });
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
dashboard: expect.objectContaining({
panels: expect.arrayContaining([expect.objectContaining({ type: 'table' })]),
}),
})
);
const result = buildDashboardPanelFromExploreState({ queries, queryResponse });
expect(result.type).toBe('table');
});
});
@@ -157,14 +65,8 @@ describe('addPanelToDashboard', () => {
[framesType]: [new MutableDataFrame({ refId: 'A', fields: [] })],
};
await setDashboardInLocalStorage({ queries, queryResponse, time: { from: 'now-1h', to: 'now' } });
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
dashboard: expect.objectContaining({
panels: expect.arrayContaining([expect.objectContaining({ type: expectedPanel })]),
}),
})
);
const result = buildDashboardPanelFromExploreState({ queries, queryResponse });
expect(result.type).toBe(expectedPanel);
}
);
@@ -181,15 +83,29 @@ describe('addPanelToDashboard', () => {
],
};
await setDashboardInLocalStorage({ queries, queryResponse, time: { from: 'now-1h', to: 'now' } });
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
dashboard: expect.objectContaining({
panels: expect.arrayContaining([expect.objectContaining({ type: 'someCustomPluginId' })]),
}),
})
);
const result = buildDashboardPanelFromExploreState({ queries, queryResponse });
expect(result.type).toBe('someCustomPluginId');
});
});
});
});
function createEmptyQueryResponse(): ExplorePanelData {
return {
state: LoadingState.NotStarted,
series: [],
timeRange: getDefaultTimeRange(),
graphFrames: [],
logsFrames: [],
traceFrames: [],
nodeGraphFrames: [],
flameGraphFrames: [],
customFrames: [],
tableFrames: [],
rawPrometheusFrames: [],
rawPrometheusResult: null,
graphResult: null,
logsResult: null,
tableResult: null,
};
}
@@ -1,23 +1,14 @@
import { DataFrame, ExplorePanelsState } from '@grafana/data';
import { Dashboard, DataQuery, DataSourceRef } from '@grafana/schema';
import { DataQuery, DataSourceRef, Panel } from '@grafana/schema';
import { DataTransformerConfig } from '@grafana/schema/dist/esm/raw/dashboard/x/dashboard_types.gen';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { setDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard';
import { buildNewDashboardSaveModel } from 'app/features/dashboard-scene/serialization/buildNewDashboardSaveModel';
import { DashboardDTO, ExplorePanelData } from 'app/types';
import { ExplorePanelData } from 'app/types';
export enum AddToDashboardError {
FETCH_DASHBOARD = 'fetch-dashboard',
SET_DASHBOARD_LS = 'set-dashboard-ls-error',
}
interface AddPanelToDashboardOptions {
interface ExploreToDashboardPanelOptions {
queries: DataQuery[];
queryResponse: ExplorePanelData;
datasource?: DataSourceRef;
dashboardUid?: string;
panelState?: ExplorePanelsState;
time: Dashboard['time'];
}
/**
@@ -27,7 +18,10 @@ interface AddPanelToDashboardOptions {
* @param panelType
* @param options
*/
function getLogsTableTransformations(panelType: string, options: AddPanelToDashboardOptions): DataTransformerConfig[] {
function getLogsTableTransformations(
panelType: string,
options: ExploreToDashboardPanelOptions
): DataTransformerConfig[] {
let transformations: DataTransformerConfig[] = [];
if (panelType === 'table' && options.panelState?.logs?.columns) {
// If we have a labels column, we need to extract the fields from it
@@ -64,10 +58,11 @@ function getLogsTableTransformations(panelType: string, options: AddPanelToDashb
return transformations;
}
export async function setDashboardInLocalStorage(options: AddPanelToDashboardOptions) {
export function buildDashboardPanelFromExploreState(options: ExploreToDashboardPanelOptions): Panel {
const panelType = getPanelType(options.queries, options.queryResponse, options?.panelState);
const panel = {
return {
//@ts-ignore
targets: options.queries,
type: panelType,
title: 'New Panel',
@@ -75,28 +70,6 @@ export async function setDashboardInLocalStorage(options: AddPanelToDashboardOpt
datasource: options.datasource,
transformations: getLogsTableTransformations(panelType, options),
};
let dto: DashboardDTO;
if (options.dashboardUid) {
try {
dto = await getDashboardAPI().getDashboardDTO(options.dashboardUid);
} catch (e) {
throw AddToDashboardError.FETCH_DASHBOARD;
}
} else {
dto = await buildNewDashboardSaveModel();
}
dto.dashboard.panels = [panel, ...(dto.dashboard.panels ?? [])];
dto.dashboard.time = options.time;
try {
setDashboardToFetchFromLocalStorage(dto);
} catch {
throw AddToDashboardError.SET_DASHBOARD_LS;
}
}
const isVisible = (query: DataQuery) => !query.hide;
@@ -1,25 +1,25 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { selectors } from '@grafana/e2e-selectors';
import { locationService, setEchoSrv } from '@grafana/runtime';
import { DataQuery, defaultDashboard } from '@grafana/schema';
import { backendSrv } from 'app/core/services/backend_srv';
import { setEchoSrv } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { contextSrv } from 'app/core/services/context_srv';
import { Echo } from 'app/core/services/echo/Echo';
import * as initDashboard from 'app/features/dashboard/state/initDashboard';
import { DashboardSearchItemType } from 'app/features/search/types';
import { configureStore } from 'app/store/configureStore';
import { ExploreState } from 'app/types';
import { createEmptyQueryResponse } from '../../state/utils';
import * as api from './addToDashboard';
import { AddToDashboard } from '.';
jest.mock('app/core/services/context_srv');
const mocks = {
contextSrv: jest.mocked(contextSrv),
};
const setup = (children: ReactNode, queries: DataQuery[] = [{ refId: 'A' }]) => {
const store = configureStore({
explore: {
@@ -40,15 +40,8 @@ const setup = (children: ReactNode, queries: DataQuery[] = [{ refId: 'A' }]) =>
return render(<Provider store={store}>{children}</Provider>);
};
jest.mock('app/core/services/context_srv');
const mocks = {
contextSrv: jest.mocked(contextSrv),
};
const openModal = async (nameOverride?: string) => {
await userEvent.click(screen.getByRole('button', { name: /add to dashboard/i }));
expect(await screen.findByRole('dialog', { name: nameOverride || 'Add panel to dashboard' })).toBeInTheDocument();
};
@@ -57,12 +50,8 @@ describe('AddToDashboardButton', () => {
setEchoSrv(new Echo());
});
/* The Add to dashboard form brings in the DashboardPicker, which will call backendSrv.search as part of its instantiation
If we do not need a list of dashboards for the test, return an empty array. */
beforeEach(() => {
// Mock the search response so we don't get any refused connection errors
// from this test (as the fetch polyfill means this logic would actually try and call the API)
jest.spyOn(backendSrv, 'search').mockResolvedValue([]);
mocks.contextSrv.hasPermission.mockImplementation(() => true);
});
afterEach(() => {
@@ -81,21 +70,9 @@ describe('AddToDashboardButton', () => {
});
describe('Success path', () => {
const addToDashboardResponse = Promise.resolve();
const waitForAddToDashboardResponse = async () => {
return act(async () => {
await addToDashboardResponse;
});
};
beforeEach(() => {
jest.spyOn(api, 'setDashboardInLocalStorage').mockReturnValue(addToDashboardResponse);
mocks.contextSrv.hasPermission.mockImplementation(() => true);
});
afterEach(() => {
jest.restoreAllMocks();
mocks.contextSrv.hasPermission.mockImplementation(() => true);
});
it('Opens and closes the modal correctly', async () => {
@@ -104,321 +81,7 @@ describe('AddToDashboardButton', () => {
await openModal();
await userEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
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={'left'} />);
await openModal();
await userEvent.click(screen.getByRole('button', { name: /open dashboard$/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={'left'} />);
await openModal();
await 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={'left'} />);
await openModal();
await 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={'left'} />);
await openModal();
await userEvent.click(screen.getByRole('button', { name: /open dashboard$/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={'left'} />);
await openModal();
expect(screen.queryByRole('combobox', { name: /dashboard/ })).not.toBeInTheDocument();
await 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={'left'} />);
await openModal();
await userEvent.click(screen.getByRole<HTMLInputElement>('radio', { name: /existing dashboard/i }));
await userEvent.click(screen.getByRole('button', { name: /open dashboard$/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: { ...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',
},
]);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
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 () => {
const pushSpy = jest.spyOn(locationService, 'push');
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',
},
]);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
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(pushSpy).toBeCalledWith('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;
}
});
setup(<AddToDashboard exploreId={'left'} />);
await openModal('Add panel to existing dashboard');
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;
}
});
setup(<AddToDashboard exploreId={'left'} />);
await openModal('Add panel to new dashboard');
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(initDashboard, 'removeDashboardToFetchFromLocalStorage');
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
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(initDashboard, 'setDashboardToFetchFromLocalStorage').mockImplementation(() => {
throw 'SOME ERROR';
});
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
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();
});
});
it('Shows an error if fetching dashboard fails', async () => {
jest.spyOn(backendSrv, 'getDashboardByUid').mockRejectedValue('SOME ERROR');
jest.spyOn(backendSrv, 'search').mockResolvedValue([
{
uid: 'someUid',
isStarred: false,
title: 'Dashboard Title',
tags: [],
type: DashboardSearchItemType.DashDB,
uri: 'someUri',
url: 'someUrl',
},
]);
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
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(await screen.findByRole('alert')).toBeInTheDocument();
});
});
it('Shows an error if an unknown error happens', async () => {
jest.spyOn(api, 'setDashboardInLocalStorage').mockRejectedValue('SOME ERROR');
setup(<AddToDashboard exploreId={'left'} />);
await openModal();
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();
});
});
});
});
@@ -6,7 +6,7 @@ import { useSelector } from 'app/types';
import { getExploreItemSelector } from '../../state/selectors';
import { AddToDashboardForm } from './AddToDashboardForm';
import { ExploreToDashboardPanel } from './ExploreToDashboardPanel';
import { getAddToDashboardTitle } from './getAddToDashboardTitle';
interface Props {
@@ -35,7 +35,7 @@ export const AddToDashboard = ({ exploreId }: Props) => {
{isOpen && (
<Modal title={getAddToDashboardTitle()} onDismiss={onClose} isOpen>
<AddToDashboardForm onClose={onClose} exploreId={exploreId} />
<ExploreToDashboardPanel onClose={onClose} exploreId={exploreId} />
</Modal>
)}
</>
@@ -8,7 +8,7 @@ import { createAddedLinkConfig } from '../../plugins/extensions/utils';
import { changeCorrelationEditorDetails } from '../state/main';
import { runQueries } from '../state/query';
import { AddToDashboardForm } from './AddToDashboard/AddToDashboardForm';
import { ExploreToDashboardPanel } from './AddToDashboard/ExploreToDashboardPanel';
import { getAddToDashboardTitle } from './AddToDashboard/getAddToDashboardTitle';
import { type PluginExtensionExploreContext } from './ToolbarExtensionPoint';
@@ -36,7 +36,7 @@ export function getExploreExtensionConfigs(): PluginExtensionAddedLinkConfig[] {
onClick: (_, { context, openModal }) => {
openModal({
title: getAddToDashboardTitle(),
body: ({ onDismiss }) => <AddToDashboardForm onClose={onDismiss!} exploreId={context?.exploreId!} />,
body: ({ onDismiss }) => <ExploreToDashboardPanel onClose={onDismiss!} exploreId={context?.exploreId!} />,
});
},
}),
+1 -1
View File
@@ -23,7 +23,7 @@ import {
import { config, getDataSourceSrv } from '@grafana/runtime';
import { DataQuery, DataSourceJsonData, DataSourceRef, TimeZone } from '@grafana/schema';
import { getLocalRichHistoryStorage } from 'app/core/history/richHistoryStorageProvider';
import { SortOrder } from 'app/core/utils/richHistory';
import { SortOrder } from 'app/core/utils/richHistoryTypes';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { ExplorePanelData, StoreState } from 'app/types';
import { ExploreItemState, RichHistoryQuery } from 'app/types/explore';
+2
View File
@@ -139,3 +139,5 @@ export interface DashboardState {
initialDatasource?: DataSourceRef['uid'];
initError: DashboardInitError | null;
}
export const DASHBOARD_FROM_LS_KEY = 'DASHBOARD_FROM_LS_KEY';
-1
View File
@@ -1096,7 +1096,6 @@
"help-wizard": {
"download-snapshot": "Download snapshot",
"github-comment": "Copy Github comment",
"preview-snapshot": "Preview snapshot",
"support-bundle": "You can also retrieve a support bundle containing information concerning your Grafana instance and configured datasources in the <1>support bundles section</1>.",
"troubleshooting-help": "To request troubleshooting help, send a snapshot of this panel to Grafana Labs Technical Support. The snapshot contains query response data and panel settings."
},
@@ -1096,7 +1096,6 @@
"help-wizard": {
"download-snapshot": "Đőŵʼnľőäđ şʼnäpşĥőŧ",
"github-comment": "Cőpy Ğįŧĥūþ čőmmęʼnŧ",
"preview-snapshot": "Přęvįęŵ şʼnäpşĥőŧ",
"support-bundle": "Ÿőū čäʼn äľşő řęŧřįęvę ä şūppőřŧ þūʼnđľę čőʼnŧäįʼnįʼnģ įʼnƒőřmäŧįőʼn čőʼnčęřʼnįʼnģ yőūř Ğřäƒäʼnä įʼnşŧäʼnčę äʼnđ čőʼnƒįģūřęđ đäŧäşőūřčęş įʼn ŧĥę <1>şūppőřŧ þūʼnđľęş şęčŧįőʼn</1>.",
"troubleshooting-help": "Ŧő řęqūęşŧ ŧřőūþľęşĥőőŧįʼnģ ĥęľp, şęʼnđ ä şʼnäpşĥőŧ őƒ ŧĥįş päʼnęľ ŧő Ğřäƒäʼnä Ŀäþş Ŧęčĥʼnįčäľ Ŝūppőřŧ. Ŧĥę şʼnäpşĥőŧ čőʼnŧäįʼnş qūęřy řęşpőʼnşę đäŧä äʼnđ päʼnęľ şęŧŧįʼnģş."
},