mirror of https://github.com/grafana/grafana
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>pull/46149/head
parent
c4ccfc3bf7
commit
09f48173fe
@ -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'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
@ -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> |
||||||
|
); |
||||||
|
}; |
@ -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; |
||||||
|
}; |
@ -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', |
||||||
|
}) |
||||||
|
); |
||||||
|
}); |
||||||
|
}); |
@ -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} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</> |
||||||
|
); |
||||||
|
}; |
Loading…
Reference in new issue