diff --git a/.betterer.results b/.betterer.results index 7dff19d1230..31d1f23e1c8 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2636,6 +2636,11 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], + "public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] + ], "public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], @@ -4000,11 +4005,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/features/explore/extensions/AddToDashboard/AddToDashboardForm.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] - ], "public/app/features/explore/extensions/ConfirmNavigationModal.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], diff --git a/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.test.tsx b/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.test.tsx new file mode 100644 index 00000000000..8aecfaa7ddf --- /dev/null +++ b/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.test.tsx @@ -0,0 +1,261 @@ +import { act, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from 'test/test-utils'; + +import { selectors } from '@grafana/e2e-selectors'; +import { locationService, setEchoSrv } from '@grafana/runtime'; +import { defaultDashboard } from '@grafana/schema'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { contextSrv } from 'app/core/services/context_srv'; +import { Echo } from 'app/core/services/echo/Echo'; +import store from 'app/core/store'; +import { DashboardSearchItemType } from 'app/features/search/types'; + +import { AddToDashboardForm, Props } from './AddToDashboardForm'; + +async function setup(overrides: Partial = {}) { + const props: Props = { + buildPanel: () => ({ id: 1, type: 'table', options: { showHeader: false } }), + onClose: jest.fn(), + options: undefined, + ...overrides, + }; + + const res = render(); + await act(() => Promise.resolve()); + return res; +} + +jest.mock('app/core/services/context_srv'); + +const mocks = { + contextSrv: jest.mocked(contextSrv), +}; + +describe('AddToDashboardButton', () => { + beforeAll(() => { + setEchoSrv(new Echo()); + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(backendSrv, 'search').mockResolvedValue([]); + mocks.contextSrv.hasPermission.mockImplementation(() => true); + locationService.push('/'); + }); + + describe('navigation', () => { + it('Navigates to dashboard when clicking on "Open"', async () => { + // @ts-expect-error global.open should return a Window, but is not implemented in js-dom. + const openSpy = jest.spyOn(global, 'open').mockReturnValue(true); + + await setup(); + + await userEvent.click(screen.getByRole('button', { name: /open dashboard$/i })); + + expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument(); + + expect(locationService.getLocation().pathname).toBe('/dashboard/new'); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('Navigates to dashboard in a new tab when clicking on "Open in a new tab"', async () => { + // @ts-expect-error global.open should return a Window, but is not implemented in js-dom. + const openSpy = jest.spyOn(global, 'open').mockReturnValue(true); + + await setup(); + + await userEvent.click(screen.getByRole('button', { name: /open in new tab/i })); + + expect(openSpy).toHaveBeenCalledWith(expect.anything(), '_blank'); + expect(locationService.getLocation().pathname).toBe('/'); + }); + }); + + describe('Add to new dashboard', () => { + describe('Navigate to correct dashboard when saving', () => { + it('Navigates to the new dashboard', async () => { + await setup(); + + await userEvent.click(screen.getByRole('button', { name: /open dashboard$/i })); + + expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument(); + expect(locationService.getLocation().pathname).toBe('/dashboard/new'); + }); + }); + }); + + describe('Add to existing dashboard', () => { + it('Renders the dashboard picker when switching to "Existing Dashboard"', async () => { + await setup(); + + expect(screen.queryByRole('combobox', { name: /dashboard/ })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i })); + expect(screen.getByRole('combobox', { name: /dashboard/ })).toBeInTheDocument(); + }); + + it('Does not submit if no dashboard is selected', async () => { + locationService.push = jest.fn(); + + await setup(); + + await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i })); + await userEvent.click(screen.getByRole('button', { name: /open dashboard$/i })); + + locationService.push = jest.fn(); + expect(locationService.push).not.toHaveBeenCalled(); + }); + + describe('Navigate to correct dashboard when saving', () => { + it('Opens the selected dashboard in a new tab', async () => { + // @ts-expect-error global.open should return a Window, but is not implemented in js-dom. + const openSpy = jest.spyOn(global, 'open').mockReturnValue(true); + + jest.spyOn(backendSrv, 'getDashboardByUid').mockResolvedValue({ + dashboard: { ...defaultDashboard, templating: { list: [] }, title: 'Dashboard Title', uid: 'someUid' }, + meta: {}, + }); + + jest.spyOn(backendSrv, 'search').mockResolvedValue([ + { + uid: 'someUid', + isStarred: false, + title: 'Dashboard Title', + tags: [], + type: DashboardSearchItemType.DashDB, + uri: 'someUri', + url: 'someUrl', + }, + ]); + + await setup(); + + await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i })); + await userEvent.click(screen.getByRole('combobox', { name: /dashboard/i })); + + await waitFor(async () => { + await screen.findByTestId(selectors.components.Select.option); + }); + + await userEvent.click(screen.getByTestId(selectors.components.Select.option)); + await userEvent.click(screen.getByRole('button', { name: /open in new tab/i })); + + await waitFor(async () => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + expect(openSpy).toBeCalledWith('d/someUid', '_blank'); + }); + + it('Navigates to the selected dashboard', async () => { + jest.spyOn(backendSrv, 'search').mockResolvedValue([ + { + uid: 'someUid', + isStarred: false, + title: 'Dashboard Title', + tags: [], + type: DashboardSearchItemType.DashDB, + uri: 'someUri', + url: 'someUrl', + }, + ]); + + await setup(); + + await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i })); + await userEvent.click(screen.getByRole('combobox', { name: /dashboard/i })); + + await waitFor(async () => { + await screen.findByTestId(selectors.components.Select.option); + }); + + await userEvent.click(screen.getByTestId(selectors.components.Select.option)); + await userEvent.click(screen.getByRole('button', { name: /open dashboard$/i })); + + await waitFor(async () => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + expect(locationService.getLocation().pathname).toBe('/d/someUid'); + }); + }); + }); +}); + +describe('Permissions', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('Should only show existing dashboard option with no access to create', async () => { + mocks.contextSrv.hasPermission.mockImplementation((action) => { + if (action === 'dashboards:create') { + return false; + } else { + return true; + } + }); + + await setup(); + + expect(screen.queryByRole('radio')).not.toBeInTheDocument(); + }); + + it('Should only show new dashboard option with no access to write', async () => { + mocks.contextSrv.hasPermission.mockImplementation((action) => { + if (action === 'dashboards:write') { + return false; + } else { + return true; + } + }); + + await setup(); + + expect(screen.queryByRole('radio')).not.toBeInTheDocument(); + }); +}); + +describe('Error handling', () => { + beforeEach(() => { + mocks.contextSrv.hasPermission.mockImplementation(() => true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('Shows an error if opening a new tab fails', async () => { + jest.spyOn(global, 'open').mockReturnValue(null); + const removeDashboardSpy = jest.spyOn(store, 'delete'); + + await setup(); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /open in new tab/i })); + + await waitFor(async () => { + expect(await screen.findByRole('alert')).toBeInTheDocument(); + }); + + expect(removeDashboardSpy).toHaveBeenCalled(); + }); + + it('Shows an error if saving to localStorage fails', async () => { + jest.spyOn(store, 'setObject').mockImplementation(() => { + throw 'SOME ERROR'; + }); + + await setup(); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /open in new tab/i })); + + await waitFor(async () => { + expect(await screen.findByRole('alert')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/explore/extensions/AddToDashboard/AddToDashboardForm.tsx b/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.tsx similarity index 61% rename from public/app/features/explore/extensions/AddToDashboard/AddToDashboardForm.tsx rename to public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.tsx index 847a981a7bc..7c000604565 100644 --- a/public/app/features/explore/extensions/AddToDashboard/AddToDashboardForm.tsx +++ b/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.tsx @@ -1,18 +1,16 @@ import { partial } from 'lodash'; -import { type ReactElement, useEffect, useState } from 'react'; -import { DeepMap, FieldError, FieldErrors, useForm, Controller } from 'react-hook-form'; +import { ReactElement, useEffect, useState } from 'react'; +import { Controller, DeepMap, FieldError, FieldErrors, useForm } from 'react-hook-form'; -import { locationUtil, SelectableValue } from '@grafana/data'; -import { config, locationService, reportInteraction } from '@grafana/runtime'; +import { SelectableValue, TimeRange } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { Panel } from '@grafana/schema'; import { Alert, Button, Field, Modal, RadioButtonGroup } from '@grafana/ui'; import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; -import { contextSrv } from 'app/core/services/context_srv'; -import { removeDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard'; -import { AccessControlAction, useSelector } from 'app/types'; +import { contextSrv } from 'app/core/core'; +import { AccessControlAction } from 'app/types'; -import { getExploreItemSelector } from '../../state/selectors'; - -import { setDashboardInLocalStorage, AddToDashboardError } from './addToDashboard'; +import { addToDashboard, SubmissionError } from './addToDashboard'; enum SaveTarget { NewDashboard = 'new-dashboard', @@ -22,6 +20,7 @@ enum SaveTarget { interface SaveTargetDTO { saveTarget: SaveTarget; } + interface SaveToNewDashboardDTO extends SaveTargetDTO { saveTarget: SaveTarget.NewDashboard; } @@ -33,36 +32,21 @@ interface SaveToExistingDashboard extends SaveTargetDTO { type FormDTO = SaveToNewDashboardDTO | SaveToExistingDashboard; -function assertIsSaveToExistingDashboardError( - errors: FieldErrors -): asserts errors is DeepMap { - // the shape of the errors object is always compatible with the type above, but we need to - // explicitly assert its type so that TS can narrow down FormDTO to SaveToExistingDashboard - // when we use it in the form. -} - -function getDashboardURL(dashboardUid?: string) { - return dashboardUid ? `d/${dashboardUid}` : 'dashboard/new'; -} - -enum GenericError { - UNKNOWN = 'unknown-error', - NAVIGATION = 'navigation-error', -} - -interface SubmissionError { - error: AddToDashboardError | GenericError; - message: string; -} - -interface Props { +export interface Props { onClose: () => void; - exploreId: string; + buildPanel: (options: TOptions) => Panel; + timeRange?: TimeRange; + options: TOptions; + children?: React.ReactNode; } -export function AddToDashboardForm(props: Props): ReactElement { - const { exploreId, onClose } = props; - const exploreItem = useSelector(getExploreItemSelector(exploreId))!; +export function AddToDashboardForm({ + onClose, + buildPanel, + timeRange, + options, + children, +}: Props): ReactElement { const [submissionError, setSubmissionError] = useState(); const { handleSubmit, @@ -77,12 +61,14 @@ export function AddToDashboardForm(props: Props): ReactElement { const canWriteDashboard = contextSrv.hasPermission(AccessControlAction.DashboardsWrite); const saveTargets: Array> = []; + if (canCreateDashboard) { saveTargets.push({ label: 'New dashboard', value: SaveTarget.NewDashboard, }); } + if (canWriteDashboard) { saveTargets.push({ label: 'Existing dashboard', @@ -92,60 +78,24 @@ export function AddToDashboardForm(props: Props): ReactElement { const saveTarget = saveTargets.length > 1 ? watch('saveTarget') : saveTargets[0].value; - const onSubmit = async (openInNewTab: boolean, data: FormDTO) => { + const onSubmit = (openInNewTab: boolean, data: FormDTO) => { setSubmissionError(undefined); + const dashboardUid = data.saveTarget === SaveTarget.ExistingDashboard ? data.dashboardUid : undefined; + const panel = buildPanel(options); reportInteraction('e_2_d_submit', { newTab: openInNewTab, saveTarget: data.saveTarget, - queries: exploreItem.queries.length, + queries: panel.targets, }); - const { from, to } = exploreItem.range.raw; - - try { - await setDashboardInLocalStorage({ - dashboardUid, - datasource: exploreItem.datasourceInstance?.getRef(), - queries: exploreItem.queries, - queryResponse: exploreItem.queryResponse, - panelState: exploreItem?.panelsState, - time: { - from: typeof from === 'string' ? from : from.toISOString(), - to: typeof to === 'string' ? to : to.toISOString(), - }, - }); - } catch (error) { - switch (error) { - case AddToDashboardError.FETCH_DASHBOARD: - setSubmissionError({ error, message: 'Could not fetch dashboard information. Please try again.' }); - break; - case AddToDashboardError.SET_DASHBOARD_LS: - setSubmissionError({ error, message: 'Could not add panel to dashboard. Please try again.' }); - break; - default: - setSubmissionError({ error: GenericError.UNKNOWN, message: 'Something went wrong. Please try again.' }); - } + const error = addToDashboard({ dashboardUid, panel, openInNewTab, timeRange }); + if (error) { + setSubmissionError(error); return; } - const dashboardURL = getDashboardURL(dashboardUid); - if (!openInNewTab) { - onClose(); - locationService.push(locationUtil.stripBaseFromUrl(dashboardURL)); - return; - } - - const didTabOpen = !!global.open(config.appUrl + dashboardURL, '_blank'); - if (!didTabOpen) { - setSubmissionError({ - error: GenericError.NAVIGATION, - message: 'Could not navigate to the selected dashboard. Please try again.', - }); - removeDashboardToFetchFromLocalStorage(); - return; - } onClose(); }; @@ -155,6 +105,9 @@ export function AddToDashboardForm(props: Props): ReactElement { return (
+ {/* For custom form options */} + {children} + {saveTargets.length > 1 && ( ); } + +function assertIsSaveToExistingDashboardError( + errors: FieldErrors +): asserts errors is DeepMap { + // the shape of the errors object is always compatible with the type above, but we need to + // explicitly assert its type so that TS can narrow down FormDTO to SaveToExistingDashboard + // when we use it in the form. +} diff --git a/public/app/features/dashboard-scene/addToDashboard/addPanelsOnLoadBehavior.ts b/public/app/features/dashboard-scene/addToDashboard/addPanelsOnLoadBehavior.ts new file mode 100644 index 00000000000..3847a027e11 --- /dev/null +++ b/public/app/features/dashboard-scene/addToDashboard/addPanelsOnLoadBehavior.ts @@ -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(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); +} diff --git a/public/app/features/dashboard-scene/addToDashboard/addToDashboard.test.ts b/public/app/features/dashboard-scene/addToDashboard/addToDashboard.test.ts new file mode 100644 index 00000000000..3add777f7cd --- /dev/null +++ b/public/app/features/dashboard-scene/addToDashboard/addToDashboard.test.ts @@ -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'); + }); +}); diff --git a/public/app/features/dashboard-scene/addToDashboard/addToDashboard.ts b/public/app/features/dashboard-scene/addToDashboard/addToDashboard.ts new file mode 100644 index 00000000000..8abd8dc59d0 --- /dev/null +++ b/public/app/features/dashboard-scene/addToDashboard/addToDashboard.ts @@ -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'; +} diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx index 54aa8f75151..26e3c0eb6d0 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx @@ -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({}); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index 59b0f8bd8b5..be740e26c9b 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -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(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 ( diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index 25509df7fc3..124bc7b27e8 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -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(); - }); - }); }); }); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 1a04ca0c0d9..3f7ade11335 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -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 { @@ -63,21 +52,7 @@ export class DashboardScenePageStateManager extends StateManagerBase { - const model = localStorageStore.getObject(DASHBOARD_FROM_LS_KEY); - - if (model) { - if (!keepDashboardFromExploreInLocalStorage) { - removeDashboardToFetchFromLocalStorage(); - } - return model; - } - + public async fetchDashboard({ uid, route, urlFolderUid }: LoadDashboardOptions): Promise { const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid; const cachedDashboard = this.getDashboardFromCache(cacheKey); @@ -203,29 +178,20 @@ export class DashboardScenePageStateManager extends StateManagerBase { - const comingFromExplore = Boolean( - localStorageStore.getObject(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); } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx index 5a406406282..b4a8fd20698 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx @@ -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'); diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index 8c35c9d8618..423516fd528 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -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 diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index aa3fee00f82..7d7fcc88e55 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -158,11 +158,6 @@ export class DashboardScene extends SceneObjectBase { */ 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 { } } - 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 { 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 { 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 { 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 { panel.setState({ key: getVizPanelKeyForPanelId(panelId) }); panel.clearParent(); - this.state.body.addPanel(panel); + this.addPanel(panel); store.delete(LS_PANEL_COPY_KEY); } diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index e23afc410d6..f16d3f3b6db 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -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, diff --git a/public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx b/public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx index f6058b74d61..bc8987ff813 100644 --- a/public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx +++ b/public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx @@ -192,13 +192,6 @@ export function HelpWizard({ panel, plugin, onClose }: Props) { > Copy Github comment - diff --git a/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.ts b/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.ts index e9f68ad556d..145cec475ca 100644 --- a/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.ts +++ b/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.ts @@ -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 { - const { snapshot } = this.state; - if (snapshot) { - setDashboardToFetchFromLocalStorage({ meta: {}, dashboard: snapshot }); - global.open(config.appUrl + 'dashboard/new', '_blank'); - } - }; } diff --git a/public/app/features/dashboard/containers/DashboardPageProxy.tsx b/public/app/features/dashboard/containers/DashboardPageProxy.tsx index e45bb442aee..e51a8ce9ad8 100644 --- a/public/app/features/dashboard/containers/DashboardPageProxy.tsx +++ b/public/app/features/dashboard/containers/DashboardPageProxy.tsx @@ -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) { diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 599c4ad2a8b..cf58c391fd3 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -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 { - // When creating new or adding panels to a dashboard from explore we load it from local storage - const model = store.getObject(DASHBOARD_FROM_LS_KEY); - if (model) { - removeDashboardToFetchFromLocalStorage(); - return model; - } - try { switch (args.routeName) { case DashboardRoutes.Home: { @@ -184,7 +185,6 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { // 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 { return; } + addPanelsFromLocalStorage(dashDTO); + // set initializing state dispatch(dashboardInitServices()); @@ -298,12 +300,18 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { }; } -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(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); + } } diff --git a/public/app/features/dashboard/utils/dashboard.ts b/public/app/features/dashboard/utils/dashboard.ts index 5627bb9209c..d11a154feca 100644 --- a/public/app/features/dashboard/utils/dashboard.ts +++ b/public/app/features/dashboard/utils/dashboard.ts @@ -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 { diff --git a/public/app/features/explore/extensions/AddToDashboard/ExploreToDashboardPanel.tsx b/public/app/features/explore/extensions/AddToDashboard/ExploreToDashboardPanel.tsx new file mode 100644 index 00000000000..87dbc64f5ac --- /dev/null +++ b/public/app/features/explore/extensions/AddToDashboard/ExploreToDashboardPanel.tsx @@ -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 ( + + ); +} diff --git a/public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts index 5b6d9c91196..1c7c8912a17 100644 --- a/public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts +++ b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts @@ -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, + }; +} diff --git a/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts index 51c927801c7..db647d4b1eb 100644 --- a/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts +++ b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts @@ -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; diff --git a/public/app/features/explore/extensions/AddToDashboard/index.test.tsx b/public/app/features/explore/extensions/AddToDashboard/index.test.tsx index 6c38ba8b471..79a51c929cb 100644 --- a/public/app/features/explore/extensions/AddToDashboard/index.test.tsx +++ b/public/app/features/explore/extensions/AddToDashboard/index.test.tsx @@ -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({children}); }; -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(); - - 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(); - - 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(); - - 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(); - - 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(); - - await openModal(); - - expect(screen.queryByRole('combobox', { name: /dashboard/ })).not.toBeInTheDocument(); - - await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i })); - expect(screen.getByRole('combobox', { name: /dashboard/ })).toBeInTheDocument(); - }); - - it('Does not submit if no dashboard is selected', async () => { - locationService.push = jest.fn(); - - setup(); - - await openModal(); - - await userEvent.click(screen.getByRole('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(); - - 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(); - - 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(); - 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(); - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - }); - }); }); }); diff --git a/public/app/features/explore/extensions/AddToDashboard/index.tsx b/public/app/features/explore/extensions/AddToDashboard/index.tsx index 4b6b4d7f9ad..65326ca0d13 100644 --- a/public/app/features/explore/extensions/AddToDashboard/index.tsx +++ b/public/app/features/explore/extensions/AddToDashboard/index.tsx @@ -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 && ( - + )} diff --git a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx index 887e446c36d..4ad812d2e5a 100644 --- a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx +++ b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx @@ -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 }) => , + body: ({ onDismiss }) => , }); }, }), diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index 464e4b0ecdf..8b2c6888c3f 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -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'; diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index 731ad1e9d49..c6713fa0865 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -139,3 +139,5 @@ export interface DashboardState { initialDatasource?: DataSourceRef['uid']; initError: DashboardInitError | null; } + +export const DASHBOARD_FROM_LS_KEY = 'DASHBOARD_FROM_LS_KEY'; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 5519b8dc79d..0088bb72839 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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.", "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." }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index ee7d5df34d9..813115daeeb 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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.", "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ģş." },