Explore: allow users to save Explore state to a new panel in a new dashboard (#45148)

* Add Button & Form

* Save to new dashboard

* minor adjustements

* move modal to a separate component

* handle dashboard name related errors

* pick visualization based on query results

* lift state

* fix types

* Add Open & Close tests

* Add submit test

* add navigation tests

* add tests for API errors

* remove console log

* create wrapper component for AddToDashboardButton

* remove unused mapped prop

* add wrapper test

* rename isActive to isVisible

* invert control over save & redirect logic

* remove leftover commented code

* cleanup setup parameters

* reorganize code & improve tests

* Remove option to add to existing dashboard

* UI tweaks

* disable button if no queries

* Fix tests

* better accessible tests

* handle submission errors

* improve addToDashboard types

* use dashboardSrv' saveDashboard

* remove leftover test helper

* fix typo

Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>

* Apply suggestions from code review

Co-authored-by: Kristina <kristina.durivage@grafana.com>

Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
Co-authored-by: Kristina <kristina.durivage@grafana.com>
This commit is contained in:
Giordano Ricci 2022-03-03 08:54:06 +00:00 committed by GitHub
parent c4ccfc3bf7
commit 09f48173fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 701 additions and 9 deletions

View File

@ -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": [

View File

@ -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",

View File

@ -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<BackendSrvRequest, 'showErrorAlert' | 'showSuccessAlert'>
) {
return lastValueFrom(
getBackendSrv().fetch({
getBackendSrv().fetch<SaveDashboardResponse>({
url: '/api/dashboards/db/',
method: 'POST',
data: {

View File

@ -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(<AddToDashboardModal queries={[]} visualization="table" onSave={saveMock} onClose={() => {}} />);
// there shouldn't be any alert in the modal
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
const dashboardNameInput = screen.getByRole<HTMLInputElement>('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(<AddToDashboardModal queries={[]} visualization="table" onSave={saveMock} onClose={() => {}} />);
await waitForSearchFolderResponse();
const dashboardNameInput = screen.getByRole<HTMLInputElement>('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(<AddToDashboardModal queries={[]} visualization="table" onSave={saveMock} onClose={() => {}} />);
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(<AddToDashboardModal queries={[]} visualization="table" onSave={saveMock} onClose={() => {}} />);
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(<AddToDashboardModal queries={[]} visualization="table" onSave={saveMock} onClose={() => {}} />);
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(<AddToDashboardModal queries={[]} visualization="table" onSave={saveMock} onClose={() => {}} />);
userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
expect(await screen.findByRole('alert')).toHaveTextContent('unknown error');
});
});
});

View File

@ -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<void | ErrorResponse>;
}
function withRedirect<T extends any[]>(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<string>();
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
setError,
} = useForm<FormDTO>({ 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 (
<Modal title="Add panel to dashboard" onDismiss={onClose} isOpen>
<form>
<input type="hidden" {...register('queries')} />
<input type="hidden" {...register('visualization')} />
<p>Create a new dashboard and add a panel with explored queries.</p>
<Field
label="Dashboard name"
description="Choose the name of the new dashboard"
error={errors.dashboardName?.message}
invalid={!!errors.dashboardName}
>
<Input
id="dashboard_name"
{...register('dashboardName', {
shouldUnregister: true,
required: { value: true, message: 'This field is required' },
})}
// we set default value here instead of in useForm because this input will be unregistered when switching
// to "Existing Dashboard" and default values are not populated with manually registered
// inputs (ie. when switching back to "New Dashboard")
defaultValue="New dashboard (Explore)"
/>
</Field>
<Field
label="Folder"
description="Select where the dashboard will be created"
error={errors.folderId?.message}
invalid={!!errors.folderId}
>
<InputControl
render={({ field: { ref, onChange, ...field } }) => (
<FolderPicker onChange={(e) => 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' } }}
/>
</Field>
{submissionError && (
<Alert severity="error" title="Unknown error">
{submissionError}
</Alert>
)}
<Modal.ButtonRow>
<Button type="reset" onClick={onClose} fill="outline" variant="secondary" disabled={isSubmitting}>
Cancel
</Button>
<Button
type="submit"
onClick={handleSubmit(withRedirect(onSubmit, false))}
variant="secondary"
icon="compass"
disabled={isSubmitting}
>
Save and keep exploring
</Button>
<Button
type="submit"
onClick={handleSubmit(withRedirect(onSubmit, true))}
variant="primary"
icon="plus"
disabled={isSubmitting}
>
Save and go to dashboard
</Button>
</Modal.ButtonRow>
</form>
</Modal>
);
};

View File

@ -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<string> => {
const res = await createDashboard(data.dashboardName, data.folderId, data.queries, data.visualization);
return res.data.url;
};

View File

@ -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(<Provider store={store}>{children}</Provider>);
};
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<typeof api.addToDashboard>,
Parameters<typeof api.addToDashboard>
>;
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(<AddToDashboard exploreId={ExploreId.left} />, [{ 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(<AddToDashboard exploreId={ExploreId.left} />, [{ 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(<AddToDashboard exploreId={ExploreId.left} />, [{ 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(<AddToDashboard exploreId={ExploreId.left} />, 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(<AddToDashboard exploreId={ExploreId.left} />, 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(<AddToDashboard exploreId={ExploreId.left} />, 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(<AddToDashboard exploreId={ExploreId.left} />, 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(<AddToDashboard exploreId={ExploreId.left} />, 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(<AddToDashboard exploreId={ExploreId.left} />, 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(<AddToDashboard exploreId={ExploreId.left} />, 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(<AddToDashboard exploreId={ExploreId.left} />, 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',
})
);
});
});

View File

@ -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<void | ErrorResponse> => {
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 (
<>
<ToolbarButton
icon="apps"
onClick={() => setIsOpen(true)}
aria-label="Add to dashboard"
disabled={queries.length === 0}
>
Add to Dashboard
</ToolbarButton>
{isOpen && (
<AddToDashboardModal
onClose={() => setIsOpen(false)}
queries={queries}
visualization={mainVisualization}
onSave={handleSave}
/>
)}
</>
);
};

View File

@ -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(),

View File

@ -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,

View File

@ -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<Props> {
/>
)}
<AddToDashboard exploreId={exploreId} />
<RunButton
refreshInterval={refreshInterval}
onChangeRefreshInterval={this.onChangeRefreshInterval}
@ -162,7 +165,7 @@ class UnConnectedExploreToolbar extends PureComponent<Props> {
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;

View File

@ -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(
<Provider store={store}>

View File

@ -197,7 +197,7 @@ export const scanStopAction = createAction<ScanStopPayload>('explore/scanStop');
export interface AddResultsToCachePayload {
exploreId: ExploreId;
cacheKey: string;
queryResponse: PanelData;
queryResponse: ExplorePanelData;
}
export const addResultsToCacheAction = createAction<AddResultsToCachePayload>('explore/addResultsToCache');

View File

@ -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(

View File

@ -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