diff --git a/.betterer.results b/.betterer.results index a61180ce407..2c80cf6efe4 100644 --- a/.betterer.results +++ b/.betterer.results @@ -230,7 +230,7 @@ exports[`no enzyme tests`] = { "public/app/features/explore/ErrorContainer.test.tsx:2082593062": [ [2, 19, 13, "RegExp match", "2409514259"] ], - "public/app/features/explore/Explore.test.tsx:1509039437": [ + "public/app/features/explore/Explore.test.tsx:2684077338": [ [11, 19, 13, "RegExp match", "2409514259"] ], "public/app/features/explore/ExploreDrawer.test.tsx:2094071178": [ diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 358f0ad1e8b..ed1d5606a8e 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -46,6 +46,7 @@ var ( ErrDashboardTitleEmpty = DashboardErr{ Reason: "Dashboard title cannot be empty", StatusCode: 400, + Status: "empty-name", } ErrDashboardFolderCannotHaveParent = DashboardErr{ Reason: "A Dashboard Folder cannot be added to another folder", @@ -70,6 +71,7 @@ var ( ErrDashboardWithSameNameAsFolder = DashboardErr{ Reason: "Dashboard name cannot be the same as folder", StatusCode: 400, + Status: "name-match", } ErrDashboardFolderNameExists = DashboardErr{ Reason: "A folder with that name already exists", diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index 89e5e1ce937..7fa5084d850 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -25,6 +25,15 @@ export interface SaveDashboardOptions { refresh?: string; } +interface SaveDashboardResponse { + id: number; + slug: string; + status: string; + uid: string; + url: string; + version: number; +} + export class DashboardSrv { dashboard?: DashboardModel; @@ -67,7 +76,7 @@ export class DashboardSrv { requestOptions?: Pick ) { return lastValueFrom( - getBackendSrv().fetch({ + getBackendSrv().fetch({ url: '/api/dashboards/db/', method: 'POST', data: { diff --git a/public/app/features/explore/AddToDashboard/AddToDashboardModal.test.tsx b/public/app/features/explore/AddToDashboard/AddToDashboardModal.test.tsx new file mode 100644 index 00000000000..8f532383672 --- /dev/null +++ b/public/app/features/explore/AddToDashboard/AddToDashboardModal.test.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AddToDashboardModal } from './AddToDashboardModal'; +import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; +import * as dashboardApi from 'app/features/manage-dashboards/state/actions'; + +const createFolder = (title: string, id: number): DashboardSearchHit => ({ + title, + id, + isStarred: false, + type: DashboardSearchItemType.DashFolder, + items: [], + url: '', + uri: '', + tags: [], +}); + +describe('Add to Dashboard Modal', () => { + const searchFoldersResponse = Promise.resolve([createFolder('Folder 1', 1), createFolder('Folder 2', 2)]); + + const waitForSearchFolderResponse = async () => { + return act(async () => { + // FolderPicker asynchronously sets its internal state based on search results, causing warnings when testing. + // Given we are not aware of the component implementation to wait on certain element to appear or disappear (for example a loading indicator), + // we wait for the mocked promise we know it internally uses. + // This is less than ideal as we are relying on implementation details, but is a reasonable solution for this test's scope + await searchFoldersResponse; + }); + }; + + beforeEach(() => { + jest.spyOn(dashboardApi, 'searchFolders').mockReturnValue(searchFoldersResponse); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Save to new dashboard', () => { + it('Does not submit if the form is invalid', async () => { + const saveMock = jest.fn(); + + render( {}} />); + + // there shouldn't be any alert in the modal + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + const dashboardNameInput = screen.getByRole('textbox', { name: /dashboard name/i }); + + // dashboard name is required + userEvent.clear(dashboardNameInput); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + // The error message should appear + await screen.findByRole('alert'); + + // Create dashboard API is not invoked + expect(saveMock).not.toHaveBeenCalled(); + }); + + it('Correctly submits if the form is valid', async () => { + const saveMock = jest.fn(); + + render( {}} />); + await waitForSearchFolderResponse(); + + const dashboardNameInput = screen.getByRole('textbox', { name: /dashboard name/i }); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /save and keep exploring/i })).toBeEnabled(); + }); + + expect(saveMock).toHaveBeenCalledWith( + { + dashboardName: dashboardNameInput.value, + queries: [], + visualization: 'table', + folderId: 1, + }, + expect.anything() + ); + }); + }); + + describe('Handling API errors', () => { + it('Correctly handles name-exist API Error', async () => { + // name-exists is triggered when trying to create a dashboard in a folder that already has a dashboard with the same name + const saveMock = jest.fn().mockResolvedValue({ status: 'name-exists', message: 'name exists' }); + + render( {}} />); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent('name exists'); + }); + + it('Correctly handles empty name API Error', async () => { + // empty-name is triggered when trying to create a dashboard having an empty name. + // FE validation usually avoids this use case, but can be triggered by using only whitespaces in + // dashboard name field + const saveMock = jest.fn().mockResolvedValue({ status: 'empty-name', message: 'empty name' }); + + render( {}} />); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent('empty name'); + }); + + it('Correctly handles name match API Error', async () => { + // name-match, triggered when trying to create a dashboard in a folder that has the same name. + // it doesn't seem to ever be triggered, but matches the error in + // https://github.com/grafana/grafana/blob/44f1e381cbc7a5e236b543bc6bd06b00e3152d7f/pkg/models/dashboards.go#L71 + const saveMock = jest.fn().mockResolvedValue({ status: 'name-match', message: 'name match' }); + + render( {}} />); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent('name match'); + }); + + it('Correctly handles unknown API Errors', async () => { + const saveMock = jest.fn().mockResolvedValue({ status: 'unknown-error', message: 'unknown error' }); + + render( {}} />); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent('unknown error'); + }); + }); +}); diff --git a/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx b/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx new file mode 100644 index 00000000000..b358f3a8299 --- /dev/null +++ b/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx @@ -0,0 +1,132 @@ +import React, { useState } from 'react'; +import { DataQuery } from '@grafana/data'; +import { Alert, Button, Field, Input, InputControl, Modal } from '@grafana/ui'; +import { FolderPicker } from 'app/core/components/Select/FolderPicker'; +import { useForm } from 'react-hook-form'; +import { SaveToNewDashboardDTO } from './addToDashboard'; + +export interface ErrorResponse { + status: string; + message?: string; +} + +type FormDTO = SaveToNewDashboardDTO; + +interface Props { + onClose: () => void; + queries: DataQuery[]; + visualization: string; + onSave: (data: FormDTO, redirect: boolean) => Promise; +} + +function withRedirect(fn: (redirect: boolean, ...args: T) => {}, redirect: boolean) { + return async (...args: T) => fn(redirect, ...args); +} + +export const AddToDashboardModal = ({ onClose, queries, visualization, onSave }: Props) => { + const [submissionError, setSubmissionError] = useState(); + const { + register, + handleSubmit, + control, + formState: { errors, isSubmitting }, + setError, + } = useForm({ defaultValues: { queries, visualization } }); + + const onSubmit = async (withRedirect: boolean, data: FormDTO) => { + setSubmissionError(undefined); + const error = await onSave(data, withRedirect); + + if (error) { + switch (error.status) { + case 'name-exists': + case 'empty-name': + case 'name-match': + // error.message should always be defined here + setError('dashboardName', { message: error.message ?? 'This field is invalid' }); + break; + default: + setSubmissionError( + error.message ?? 'An unknown error occurred while saving the dashboard. Please try again.' + ); + } + } + }; + + return ( + +
+ + + +

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

+ + + + + + + ( + onChange(e.id)} {...field} enableCreateNew inputId="folder" /> + )} + control={control} + name="folderId" + shouldUnregister + rules={{ required: { value: true, message: 'Select a valid folder to save your dashboard in' } }} + /> + + + {submissionError && ( + + {submissionError} + + )} + + + + + + +
+
+ ); +}; diff --git a/public/app/features/explore/AddToDashboard/addToDashboard.ts b/public/app/features/explore/AddToDashboard/addToDashboard.ts new file mode 100644 index 00000000000..4fcec9cc79f --- /dev/null +++ b/public/app/features/explore/AddToDashboard/addToDashboard.ts @@ -0,0 +1,22 @@ +import { DataQuery } from '@grafana/data'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; + +export interface SaveToNewDashboardDTO { + dashboardName: string; + folderId: number; + queries: DataQuery[]; + visualization: string; +} + +const createDashboard = (dashboardName: string, folderId: number, queries: DataQuery[], visualization: string) => { + const dashboard = getDashboardSrv().create({ title: dashboardName }, { folderId }); + + dashboard.addPanel({ targets: queries, type: visualization, title: 'New Panel' }); + + return getDashboardSrv().saveDashboard({ dashboard, folderId }, { showErrorAlert: false, showSuccessAlert: false }); +}; + +export const addToDashboard = async (data: SaveToNewDashboardDTO): Promise => { + const res = await createDashboard(data.dashboardName, data.folderId, data.queries, data.visualization); + return res.data.url; +}; diff --git a/public/app/features/explore/AddToDashboard/index.test.tsx b/public/app/features/explore/AddToDashboard/index.test.tsx new file mode 100644 index 00000000000..9fa7e8667b2 --- /dev/null +++ b/public/app/features/explore/AddToDashboard/index.test.tsx @@ -0,0 +1,262 @@ +import React from 'react'; +import { act, render, screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { ExploreId, ExplorePanelData, ExploreState } from 'app/types'; +import { Provider } from 'react-redux'; +import { configureStore } from 'app/store/configureStore'; +import userEvent from '@testing-library/user-event'; +import { DataQuery, MutableDataFrame } from '@grafana/data'; +import { createEmptyQueryResponse } from '../state/utils'; +import { locationService } from '@grafana/runtime'; +import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; +import * as api from './addToDashboard'; +import * as dashboardApi from 'app/features/manage-dashboards/state/actions'; +import { AddToDashboard } from '.'; + +const setup = ( + children: JSX.Element, + queries: DataQuery[] = [], + queryResponse: ExplorePanelData = createEmptyQueryResponse() +) => { + const store = configureStore({ explore: { left: { queries, queryResponse } } as ExploreState }); + + return render({children}); +}; + +const createFolder = (title: string, id: number): DashboardSearchHit => ({ + title, + id, + isStarred: false, + type: DashboardSearchItemType.DashFolder, + items: [], + url: '', + uri: '', + tags: [], +}); + +const openModal = async () => { + userEvent.click(screen.getByRole('button', { name: /add to dashboard/i })); + + expect(await screen.findByRole('dialog', { name: 'Add panel to dashboard' })).toBeInTheDocument(); +}; + +describe('Add to Dashboard Button', () => { + const searchFoldersResponse = Promise.resolve([createFolder('Folder 1', 1), createFolder('Folder 2', 2)]); + const redirectURL = '/some/redirect/url'; + let addToDashboardMock: jest.SpyInstance< + ReturnType, + Parameters + >; + + const waitForSearchFolderResponse = async () => { + return act(async () => { + await searchFoldersResponse; + }); + }; + + beforeEach(() => { + jest.spyOn(dashboardApi, 'searchFolders').mockReturnValue(searchFoldersResponse); + addToDashboardMock = jest.spyOn(api, 'addToDashboard').mockResolvedValue('/some/redirect/url'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('Opens and closes the modal correctly', async () => { + setup(, [{ refId: 'A' }]); + + await openModal(); + + userEvent.click(screen.getByRole('button', { name: /cancel/i })); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + describe('navigation', () => { + it('Navigates to dashboard when clicking on "Save and go to dashboard"', async () => { + locationService.push = jest.fn(); + + setup(, [{ refId: 'A' }]); + + await openModal(); + + userEvent.click(screen.getByRole('button', { name: /save and go to dashboard/i })); + + await waitForSearchFolderResponse(); + + expect(locationService.push).toHaveBeenCalledWith(redirectURL); + }); + + it('Does NOT navigate to dashboard when clicking on "Save and keep exploring"', async () => { + locationService.push = jest.fn(); + + setup(, [{ refId: 'A' }]); + + await openModal(); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + await waitForSearchFolderResponse(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + expect(locationService.push).not.toHaveBeenCalled(); + }); + }); + + it('All queries are correctly passed through', async () => { + const queries: DataQuery[] = [{ refId: 'A' }, { refId: 'B', hide: true }]; + setup(, queries); + + await openModal(); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' })); + + expect(addToDashboardMock).toHaveBeenCalledWith( + expect.objectContaining({ + queries: queries, + }) + ); + }); + + it('Defaults to table if no response is available', async () => { + const queries: DataQuery[] = [{ refId: 'A' }]; + setup(, queries, createEmptyQueryResponse()); + + await openModal(); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' })); + + expect(addToDashboardMock).toHaveBeenCalledWith( + expect.objectContaining({ + visualization: 'table', + }) + ); + }); + + it('Defaults to table if no query is active', async () => { + const queries: DataQuery[] = [{ refId: 'A', hide: true }]; + setup(, queries); + + await openModal(); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' })); + + expect(addToDashboardMock).toHaveBeenCalledWith( + expect.objectContaining({ + visualization: 'table', + }) + ); + }); + + it('Filters out hidden queries when selecting visualization', async () => { + const queries: DataQuery[] = [{ refId: 'A', hide: true }, { refId: 'B' }]; + setup(, queries, { + ...createEmptyQueryResponse(), + graphFrames: [new MutableDataFrame({ refId: 'B', fields: [] })], + logsFrames: [new MutableDataFrame({ refId: 'A', fields: [] })], + }); + + await openModal(); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' })); + + // Query A comes before B, but it's hidden. visualization will be picked according to frames generated by B + expect(addToDashboardMock).toHaveBeenCalledWith( + expect.objectContaining({ + queries: queries, + visualization: 'timeseries', + }) + ); + }); + + it('Sets visualization to logs if there are log frames', async () => { + const queries: DataQuery[] = [{ refId: 'A' }]; + setup(, queries, { + ...createEmptyQueryResponse(), + logsFrames: [new MutableDataFrame({ refId: 'A', fields: [] })], + }); + + await openModal(); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' })); + + // Query A comes before B, but it's hidden. visualization will be picked according to frames generated by B + expect(addToDashboardMock).toHaveBeenCalledWith( + expect.objectContaining({ + visualization: 'logs', + }) + ); + }); + + it('Sets visualization to timeseries if there are graph frames', async () => { + const queries: DataQuery[] = [{ refId: 'A' }]; + setup(, queries, { + ...createEmptyQueryResponse(), + graphFrames: [new MutableDataFrame({ refId: 'A', fields: [] })], + }); + + await openModal(); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' })); + + expect(addToDashboardMock).toHaveBeenCalledWith( + expect.objectContaining({ + visualization: 'timeseries', + }) + ); + }); + + it('Sets visualization to nodeGraph if there are node graph frames', async () => { + const queries: DataQuery[] = [{ refId: 'A' }]; + setup(, queries, { + ...createEmptyQueryResponse(), + nodeGraphFrames: [new MutableDataFrame({ refId: 'A', fields: [] })], + }); + + await openModal(); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' })); + + expect(addToDashboardMock).toHaveBeenCalledWith( + expect.objectContaining({ + visualization: 'nodeGraph', + }) + ); + }); + + // trace view is not supported in dashboards, defaulting to table + it('Sets visualization to table if there are trace frames', async () => { + const queries: DataQuery[] = [{ refId: 'A' }]; + setup(, queries, { + ...createEmptyQueryResponse(), + traceFrames: [new MutableDataFrame({ refId: 'A', fields: [] })], + }); + + await openModal(); + + userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + + await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' })); + + expect(addToDashboardMock).toHaveBeenCalledWith( + expect.objectContaining({ + visualization: 'table', + }) + ); + }); +}); diff --git a/public/app/features/explore/AddToDashboard/index.tsx b/public/app/features/explore/AddToDashboard/index.tsx new file mode 100644 index 00000000000..e3b6d6430e5 --- /dev/null +++ b/public/app/features/explore/AddToDashboard/index.tsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; +import { DataFrame, DataQuery } from '@grafana/data'; +import { ExploreId, StoreState } from 'app/types'; +import { useSelector, useDispatch } from 'react-redux'; +import { getExploreItemSelector } from '../state/selectors'; +import { addToDashboard, SaveToNewDashboardDTO } from './addToDashboard'; +import { locationService } from '@grafana/runtime'; +import { notifyApp } from 'app/core/actions'; +import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { ToolbarButton } from '@grafana/ui'; +import { AddToDashboardModal, ErrorResponse } from './AddToDashboardModal'; + +const isVisible = (query: DataQuery) => !query.hide; +const hasRefId = (refId: DataFrame['refId']) => (frame: DataFrame) => frame.refId === refId; + +const getMainVisualization = ( + queries: DataQuery[], + graphFrames?: DataFrame[], + logsFrames?: DataFrame[], + nodeGraphFrames?: DataFrame[] +) => { + for (const { refId } of queries.filter(isVisible)) { + // traceview is not supported in dashboards, skipping it for now. + const hasQueryRefId = hasRefId(refId); + if (graphFrames?.some(hasQueryRefId)) { + return 'timeseries'; + } + if (logsFrames?.some(hasQueryRefId)) { + return 'logs'; + } + if (nodeGraphFrames?.some(hasQueryRefId)) { + return 'nodeGraph'; + } + } + + // falling back to table + return 'table'; +}; + +interface Props { + exploreId: ExploreId; +} + +export const AddToDashboard = ({ exploreId }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const dispatch = useDispatch(); + const selectExploreItem = getExploreItemSelector(exploreId); + + const { queries, mainVisualization } = useSelector((state: StoreState) => { + const queries = selectExploreItem(state)?.queries || []; + const { graphFrames, logsFrames, nodeGraphFrames } = selectExploreItem(state)?.queryResponse || {}; + + return { queries, mainVisualization: getMainVisualization(queries, graphFrames, logsFrames, nodeGraphFrames) }; + }); + + const handleSave = async (data: SaveToNewDashboardDTO, redirect: boolean): Promise => { + try { + const redirectURL = await addToDashboard(data); + + if (redirect) { + locationService.push(redirectURL); + } else { + dispatch(notifyApp(createSuccessNotification(`Panel saved to ${data.dashboardName}`))); + setIsOpen(false); + } + return; + } catch (e) { + return { message: e.data?.message, status: e.data?.status ?? 'unknown-error' }; + } + }; + + return ( + <> + setIsOpen(true)} + aria-label="Add to dashboard" + disabled={queries.length === 0} + > + Add to Dashboard + + + {isOpen && ( + setIsOpen(false)} + queries={queries} + visualization={mainVisualization} + onSave={handleSave} + /> + )} + + ); +}; diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 58b8adefd45..ed682341c4c 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -75,6 +75,14 @@ const dummyProps: Props = { to: 'now', }, }, + graphFrames: [], + logsFrames: [], + tableFrames: [], + traceFrames: [], + nodeGraphFrames: [], + graphResult: null, + logsResult: null, + tableResult: null, }, addQueryRow: jest.fn(), theme: createTheme(), diff --git a/public/app/features/explore/ExploreQueryInspector.test.tsx b/public/app/features/explore/ExploreQueryInspector.test.tsx index f439608e31f..f1c39254492 100644 --- a/public/app/features/explore/ExploreQueryInspector.test.tsx +++ b/public/app/features/explore/ExploreQueryInspector.test.tsx @@ -38,6 +38,14 @@ const setup = (propOverrides = {}) => { state: LoadingState.Done, series: [], timeRange: {} as TimeRange, + graphFrames: [], + logsFrames: [], + tableFrames: [], + traceFrames: [], + nodeGraphFrames: [], + graphResult: null, + logsResult: null, + tableResult: null, }, runQueries: jest.fn(), ...propOverrides, diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index e11baa8cfee..3eea018fd92 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -1,6 +1,6 @@ import React, { PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { ExploreId, ExploreItemState } from 'app/types/explore'; +import { ExploreId } from 'app/types/explore'; import { PageToolbar, SetInterval, ToolbarButton, ToolbarButtonRow } from '@grafana/ui'; import { DataSourceInstanceSettings, RawTimeRange } from '@grafana/data'; import { DataSourcePicker } from '@grafana/runtime'; @@ -18,6 +18,7 @@ import { LiveTailControls } from './useLiveTailControls'; import { cancelQueries, runQueries } from './state/query'; import { isSplit } from './state/selectors'; import { DashNavButton } from '../dashboard/components/DashNav/DashNavButton'; +import { AddToDashboard } from './AddToDashboard'; interface OwnProps { exploreId: ExploreId; @@ -127,6 +128,8 @@ class UnConnectedExploreToolbar extends PureComponent { /> )} + + { const mapStateToProps = (state: StoreState, { exploreId }: OwnProps) => { const { syncedTimes } = state.explore; - const exploreItem: ExploreItemState = state.explore[exploreId]!; + const exploreItem = state.explore[exploreId]!; const { datasourceInstance, datasourceMissing, range, refreshInterval, loading, isLive, isPaused, containerWidth } = exploreItem; diff --git a/public/app/features/explore/ResponseErrorContainer.test.tsx b/public/app/features/explore/ResponseErrorContainer.test.tsx index 32a61bac57d..75982c68a2f 100644 --- a/public/app/features/explore/ResponseErrorContainer.test.tsx +++ b/public/app/features/explore/ResponseErrorContainer.test.tsx @@ -50,6 +50,14 @@ function setup(error: DataQueryError) { series: [], state: LoadingState.Error, error, + graphFrames: [], + logsFrames: [], + tableFrames: [], + traceFrames: [], + nodeGraphFrames: [], + graphResult: null, + logsResult: null, + tableResult: null, }; render( diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 730d81177a7..1dc93d3f76f 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -197,7 +197,7 @@ export const scanStopAction = createAction('explore/scanStop'); export interface AddResultsToCachePayload { exploreId: ExploreId; cacheKey: string; - queryResponse: PanelData; + queryResponse: ExplorePanelData; } export const addResultsToCacheAction = createAction('explore/addResultsToCache'); diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index 49825d2a8d8..a75c193d2c7 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -8,7 +8,7 @@ import { LoadingState, PanelData, } from '@grafana/data'; - +import { ExplorePanelData } from 'app/types'; import { ExploreGraphStyle, ExploreItemState } from 'app/types/explore'; import { getDatasourceSrv } from '../../plugins/datasource_srv'; import store from '../../../core/store'; @@ -67,10 +67,18 @@ export const makeExplorePaneState = (): ExploreItemState => ({ panelsState: {}, }); -export const createEmptyQueryResponse = (): PanelData => ({ +export const createEmptyQueryResponse = (): ExplorePanelData => ({ state: LoadingState.NotStarted, series: [], timeRange: getDefaultTimeRange(), + graphFrames: [], + logsFrames: [], + traceFrames: [], + nodeGraphFrames: [], + tableFrames: [], + graphResult: null, + logsResult: null, + tableResult: null, }); export async function loadAndInitDatasource( diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 9bc5a2a5c3d..52bbef90223 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -145,7 +145,7 @@ export interface ExploreItemState { querySubscription?: Unsubscribable; - queryResponse: PanelData; + queryResponse: ExplorePanelData; showLogs?: boolean; showMetrics?: boolean; @@ -158,7 +158,7 @@ export interface ExploreItemState { * In logs navigation, we do pagination and we don't want our users to unnecessarily run the same queries that they've run just moments before. * We are currently caching last 5 query responses. */ - cache: Array<{ key: string; value: PanelData }>; + cache: Array<{ key: string; value: ExplorePanelData }>; // properties below should be more generic if we add more providers // see also: DataSourceWithLogsVolumeSupport