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:
+5
-5
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+41
-80
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -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!} />,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -139,3 +139,5 @@ export interface DashboardState {
|
||||
initialDatasource?: DataSourceRef['uid'];
|
||||
initError: DashboardInitError | null;
|
||||
}
|
||||
|
||||
export const DASHBOARD_FROM_LS_KEY = 'DASHBOARD_FROM_LS_KEY';
|
||||
|
||||
@@ -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ģş."
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user