mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
DashboardScene: Saving (#81163)
* DashboardScene: First save works * Updates * version mismatch works * Error handling * save current time range working * Progress on save as * Save as works * Progress * First tests * Add unit tests * Minor tweak * Update * Update isDirty state when saving
This commit is contained in:
parent
bcc2409564
commit
1d5edb2a18
@ -2401,6 +2401,13 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx:5381": [
|
||||
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"],
|
||||
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/saving/shared.tsx:5381": [
|
||||
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/scene/DashboardScene.test.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
|
@ -275,9 +275,10 @@ export const browseDashboardsAPI = createApi({
|
||||
}),
|
||||
// save an existing dashboard
|
||||
saveDashboard: builder.mutation<SaveDashboardResponseDTO, SaveDashboardCommand>({
|
||||
query: ({ dashboard, folderUid, message, overwrite }) => ({
|
||||
query: ({ dashboard, folderUid, message, overwrite, showErrorAlert }) => ({
|
||||
url: `/dashboards/db`,
|
||||
method: 'POST',
|
||||
showErrorAlert,
|
||||
data: {
|
||||
dashboard,
|
||||
folderUid,
|
||||
|
@ -0,0 +1,196 @@
|
||||
import React from 'react';
|
||||
import { UseFormSetValue, useForm } from 'react-hook-form';
|
||||
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { Button, Input, Switch, Field, Label, TextArea, Stack, Alert, Box } from '@grafana/ui';
|
||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
|
||||
import { SaveDashboardDrawer } from './SaveDashboardDrawer';
|
||||
import { DashboardChangeInfo, NameAlreadyExistsError, SaveButton, isNameExistsError } from './shared';
|
||||
import { useDashboardSave } from './useSaveDashboard';
|
||||
|
||||
interface SaveDashboardAsFormDTO {
|
||||
firstName?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
folder: { uid?: string; title?: string };
|
||||
copyTags: boolean;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
dashboard: DashboardScene;
|
||||
drawer: SaveDashboardDrawer;
|
||||
changeInfo: DashboardChangeInfo;
|
||||
}
|
||||
|
||||
export function SaveDashboardAsForm({ dashboard, drawer, changeInfo }: Props) {
|
||||
const { changedSaveModel } = changeInfo;
|
||||
|
||||
const { register, handleSubmit, setValue, formState, getValues } = useForm<SaveDashboardAsFormDTO>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
title: changeInfo.isNew ? changedSaveModel.title! : `${changedSaveModel.title} Copy`,
|
||||
description: changedSaveModel.description ?? '',
|
||||
folder: {
|
||||
uid: dashboard.state.meta.folderUid,
|
||||
title: dashboard.state.meta.folderTitle,
|
||||
},
|
||||
copyTags: false,
|
||||
},
|
||||
});
|
||||
const { errors, isValid, defaultValues } = formState;
|
||||
const formValues = getValues();
|
||||
|
||||
const { state, onSaveDashboard } = useDashboardSave(false);
|
||||
|
||||
const onSave = async (overwrite: boolean) => {
|
||||
const data = getValues();
|
||||
|
||||
const dashboardToSave: Dashboard = getSaveAsDashboardSaveModel(changedSaveModel, data, changeInfo.isNew);
|
||||
const result = await onSaveDashboard(dashboard, dashboardToSave, { overwrite, folderUid: data.folder.uid });
|
||||
|
||||
if (result.status === 'success') {
|
||||
dashboard.closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
const cancelButton = (
|
||||
<Button variant="secondary" onClick={() => dashboard.closeModal()} fill="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
);
|
||||
|
||||
const saveButton = (overwrite: boolean) => (
|
||||
<SaveButton isValid={isValid} isLoading={state.loading} onSave={onSave} overwrite={overwrite} />
|
||||
);
|
||||
|
||||
function renderFooter(error?: Error) {
|
||||
if (isNameExistsError(error)) {
|
||||
return <NameAlreadyExistsError cancelButton={cancelButton} saveButton={saveButton} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<Alert title="Failed to save dashboard" severity="error">
|
||||
<p>{error.message}</p>
|
||||
</Alert>
|
||||
)}
|
||||
<Stack alignItems="center">
|
||||
{cancelButton}
|
||||
{saveButton(false)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(() => onSave(false))}>
|
||||
<Field
|
||||
label={<TitleFieldLabel dashboard={changedSaveModel} onChange={setValue} />}
|
||||
invalid={!!errors.title}
|
||||
error={errors.title?.message}
|
||||
>
|
||||
<Input
|
||||
{...register('title', { required: 'Required', validate: validateDashboardName })}
|
||||
aria-label="Save dashboard title field"
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={<DescriptionLabel dashboard={changedSaveModel} onChange={setValue} />}
|
||||
invalid={!!errors.description}
|
||||
error={errors.description?.message}
|
||||
>
|
||||
<TextArea
|
||||
{...register('description', { required: false })}
|
||||
aria-label="Save dashboard description field"
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Folder">
|
||||
<FolderPicker
|
||||
onChange={(uid: string | undefined, title: string | undefined) => setValue('folder', { uid, title })}
|
||||
// Old folder picker fields
|
||||
value={formValues.folder.uid}
|
||||
initialTitle={defaultValues!.folder!.title}
|
||||
dashboardId={changedSaveModel.id ?? undefined}
|
||||
enableCreateNew
|
||||
/>
|
||||
</Field>
|
||||
{!changeInfo.isNew && (
|
||||
<Field label="Copy tags">
|
||||
<Switch {...register('copyTags')} />
|
||||
</Field>
|
||||
)}
|
||||
<Box paddingTop={2}>{renderFooter(state.error)}</Box>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TitleLabelProps {
|
||||
dashboard: Dashboard;
|
||||
onChange: UseFormSetValue<SaveDashboardAsFormDTO>;
|
||||
}
|
||||
|
||||
export function TitleFieldLabel(props: TitleLabelProps) {
|
||||
return (
|
||||
<Stack justifyContent="space-between">
|
||||
<Label htmlFor="description">Title</Label>
|
||||
{/* {config.featureToggles.dashgpt && isNew && (
|
||||
<GenAIDashDescriptionButton
|
||||
onGenerate={(description) => field.onChange(description)}
|
||||
dashboard={dashboard}
|
||||
/>
|
||||
)} */}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DescriptionLabelProps {
|
||||
dashboard: Dashboard;
|
||||
onChange: UseFormSetValue<SaveDashboardAsFormDTO>;
|
||||
}
|
||||
|
||||
export function DescriptionLabel(props: DescriptionLabelProps) {
|
||||
return (
|
||||
<Stack justifyContent="space-between">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
{/* {config.featureToggles.dashgpt && isNew && (
|
||||
<GenAIDashDescriptionButton
|
||||
onGenerate={(description) => field.onChange(description)}
|
||||
dashboard={dashboard}
|
||||
/>
|
||||
)} */}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
async function validateDashboardName(title: string, formValues: SaveDashboardAsFormDTO) {
|
||||
if (title === formValues.folder.title?.trim()) {
|
||||
return 'Dashboard name cannot be the same as folder name';
|
||||
}
|
||||
|
||||
try {
|
||||
await validationSrv.validateNewDashboardName(formValues.folder.uid ?? 'general', title);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return e instanceof Error ? e.message : 'Dashboard name is invalid';
|
||||
}
|
||||
}
|
||||
|
||||
function getSaveAsDashboardSaveModel(source: Dashboard, form: SaveDashboardAsFormDTO, isNew?: boolean): Dashboard {
|
||||
// TODO remove old alerts and thresholds when copying (See getSaveAsDashboardClone)
|
||||
return {
|
||||
...source,
|
||||
id: null,
|
||||
uid: '',
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
tags: isNew || form.copyTags ? source.tags : [],
|
||||
};
|
||||
}
|
@ -0,0 +1,207 @@
|
||||
import { screen, render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { sceneGraph } from '@grafana/scenes';
|
||||
import { SaveDashboardResponseDTO } from 'app/types';
|
||||
|
||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
|
||||
|
||||
import { SaveDashboardDrawer } from './SaveDashboardDrawer';
|
||||
|
||||
jest.mock('app/features/manage-dashboards/services/ValidationSrv', () => ({
|
||||
validationSrv: {
|
||||
validateNewDashboardName: () => true,
|
||||
},
|
||||
}));
|
||||
|
||||
const saveDashboardMutationMock = jest.fn();
|
||||
|
||||
jest.mock('app/features/browse-dashboards/api/browseDashboardsAPI', () => ({
|
||||
...jest.requireActual('app/features/browse-dashboards/api/browseDashboardsAPI'),
|
||||
useSaveDashboardMutation: () => [saveDashboardMutationMock],
|
||||
}));
|
||||
|
||||
describe('SaveDashboardDrawer', () => {
|
||||
describe('Given an already saved dashboard', () => {
|
||||
it('should render save drawer with only message textarea', async () => {
|
||||
setup().openAndRender();
|
||||
|
||||
expect(await screen.findByText('Save dashboard')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Save current time range')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No changes to save')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Tab Changes')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('When there are no changes', async () => {
|
||||
setup().openAndRender();
|
||||
expect(screen.getByText('No changes to save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('When time range changed show save time range option', async () => {
|
||||
const { dashboard, openAndRender } = setup();
|
||||
|
||||
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' });
|
||||
|
||||
openAndRender();
|
||||
|
||||
expect(await screen.findByText('Save dashboard')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Save current time range')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should update diff when including time range is', async () => {
|
||||
const { dashboard, openAndRender } = setup();
|
||||
|
||||
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' });
|
||||
|
||||
openAndRender();
|
||||
|
||||
expect(await screen.findByText('Save dashboard')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Save current time range')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Tab Changes')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText(selectors.pages.SaveDashboardModal.saveTimerange));
|
||||
|
||||
expect(await screen.findByLabelText('Tab Changes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Can show changes', async () => {
|
||||
const { dashboard, openAndRender } = setup();
|
||||
|
||||
dashboard.setState({ title: 'New title' });
|
||||
|
||||
openAndRender();
|
||||
|
||||
await userEvent.click(await screen.findByLabelText('Tab Changes'));
|
||||
|
||||
expect(await screen.findByText('JSON Model')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Can save', async () => {
|
||||
const { dashboard, openAndRender } = setup();
|
||||
|
||||
dashboard.setState({ title: 'New title' });
|
||||
|
||||
openAndRender();
|
||||
|
||||
mockSaveDashboard();
|
||||
|
||||
await userEvent.click(await screen.findByLabelText(selectors.pages.SaveDashboardModal.save));
|
||||
|
||||
const dataSent = saveDashboardMutationMock.mock.calls[0][0];
|
||||
expect(dataSent.dashboard.title).toEqual('New title');
|
||||
expect(dashboard.state.version).toEqual(11);
|
||||
expect(dashboard.state.uid).toEqual('my-uid-from-resp');
|
||||
expect(dashboard.state.isDirty).toEqual(false);
|
||||
});
|
||||
|
||||
it('Can handle save errors and overwrite', async () => {
|
||||
const { dashboard, openAndRender } = setup();
|
||||
|
||||
dashboard.setState({ title: 'New title' });
|
||||
|
||||
openAndRender();
|
||||
|
||||
mockSaveDashboard({ saveError: 'version-mismatch' });
|
||||
|
||||
await userEvent.click(await screen.findByLabelText(selectors.pages.SaveDashboardModal.save));
|
||||
|
||||
expect(await screen.findByText('Someone else has updated this dashboard')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Save and overwrite')).toBeInTheDocument();
|
||||
|
||||
// Now save and overwrite
|
||||
await userEvent.click(await screen.findByLabelText(selectors.pages.SaveDashboardModal.save));
|
||||
|
||||
const dataSent = saveDashboardMutationMock.mock.calls[1][0];
|
||||
expect(dataSent.overwrite).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Save as copy', () => {
|
||||
it('Should show save as form', async () => {
|
||||
const { openAndRender } = setup();
|
||||
openAndRender(true);
|
||||
|
||||
expect(await screen.findByText('Save dashboard copy')).toBeInTheDocument();
|
||||
|
||||
mockSaveDashboard();
|
||||
|
||||
await userEvent.click(await screen.findByLabelText(selectors.pages.SaveDashboardModal.save));
|
||||
|
||||
const dataSent = saveDashboardMutationMock.mock.calls[0][0];
|
||||
expect(dataSent.dashboard.uid).toEqual('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface MockBackendApiOptions {
|
||||
saveError: 'version-mismatch' | 'name-exists' | 'plugin-dashboard';
|
||||
}
|
||||
|
||||
function mockSaveDashboard(options: Partial<MockBackendApiOptions> = {}) {
|
||||
saveDashboardMutationMock.mockClear();
|
||||
|
||||
if (options.saveError) {
|
||||
saveDashboardMutationMock.mockResolvedValue({
|
||||
error: { status: 412, data: { status: 'version-mismatch', message: 'sad face' } },
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
saveDashboardMutationMock.mockResolvedValue({
|
||||
data: {
|
||||
id: 10,
|
||||
uid: 'my-uid-from-resp',
|
||||
slug: 'my-slug-from-resp',
|
||||
status: 'success',
|
||||
url: 'my-url',
|
||||
version: 11,
|
||||
...options,
|
||||
} as SaveDashboardResponseDTO,
|
||||
});
|
||||
}
|
||||
|
||||
let cleanUp = () => {};
|
||||
|
||||
function setup() {
|
||||
const dashboard = transformSaveModelToScene({
|
||||
dashboard: {
|
||||
title: 'hello',
|
||||
uid: 'my-uid',
|
||||
schemaVersion: 30,
|
||||
panels: [],
|
||||
version: 10,
|
||||
},
|
||||
meta: {},
|
||||
});
|
||||
|
||||
// Clear any data layers
|
||||
dashboard.setState({ $data: undefined });
|
||||
|
||||
const initialSaveModel = transformSceneToSaveModel(dashboard);
|
||||
dashboard.setInitialSaveModel(initialSaveModel);
|
||||
|
||||
cleanUp();
|
||||
cleanUp = dashboard.activate();
|
||||
|
||||
dashboard.onEnterEditMode();
|
||||
|
||||
const openAndRender = (saveAsCopy?: boolean) => {
|
||||
dashboard.openSaveDrawer({ saveAsCopy });
|
||||
const drawer = dashboard.state.overlay as SaveDashboardDrawer;
|
||||
render(
|
||||
<TestProvider>
|
||||
<drawer.Component model={drawer} />
|
||||
</TestProvider>
|
||||
);
|
||||
|
||||
return drawer;
|
||||
};
|
||||
|
||||
// await act(() => Promise.resolve());
|
||||
return { dashboard, openAndRender };
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
|
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneObjectRef } from '@grafana/scenes';
|
||||
import { Drawer, Tab, TabsBar } from '@grafana/ui';
|
||||
import { SaveDashboardDiff } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDiff';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
|
||||
import { SaveDashboardAsForm } from './SaveDashboardAsForm';
|
||||
import { SaveDashboardForm } from './SaveDashboardForm';
|
||||
import { getSaveDashboardChange } from './shared';
|
||||
|
||||
interface SaveDashboardDrawerState extends SceneObjectState {
|
||||
dashboardRef: SceneObjectRef<DashboardScene>;
|
||||
showDiff?: boolean;
|
||||
saveTimeRange?: boolean;
|
||||
saveVariables?: boolean;
|
||||
saveAsCopy?: boolean;
|
||||
}
|
||||
|
||||
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {
|
||||
public onClose = () => {
|
||||
this.state.dashboardRef.resolve().setState({ overlay: undefined });
|
||||
};
|
||||
|
||||
public onToggleSaveTimeRange = () => {
|
||||
this.setState({ saveTimeRange: !this.state.saveTimeRange });
|
||||
};
|
||||
|
||||
public onToggleSaveVariables = () => {
|
||||
this.setState({ saveTimeRange: !this.state.saveTimeRange });
|
||||
};
|
||||
|
||||
static Component = ({ model }: SceneComponentProps<SaveDashboardDrawer>) => {
|
||||
const { showDiff, saveAsCopy, saveTimeRange } = model.useState();
|
||||
const changeInfo = getSaveDashboardChange(model.state.dashboardRef.resolve(), saveTimeRange);
|
||||
const { changedSaveModel, initialSaveModel, diffs, diffCount } = changeInfo;
|
||||
const dashboard = model.state.dashboardRef.resolve();
|
||||
|
||||
const tabs = (
|
||||
<TabsBar>
|
||||
<Tab label={'Details'} active={!showDiff} onChangeTab={() => model.setState({ showDiff: false })} />
|
||||
{diffCount > 0 && (
|
||||
<Tab
|
||||
label={'Changes'}
|
||||
active={showDiff}
|
||||
onChangeTab={() => model.setState({ showDiff: true })}
|
||||
counter={diffCount}
|
||||
/>
|
||||
)}
|
||||
</TabsBar>
|
||||
);
|
||||
|
||||
let title = 'Save dashboard';
|
||||
if (saveAsCopy) {
|
||||
title = 'Save dashboard copy';
|
||||
}
|
||||
|
||||
// else if (isProvisioned) {
|
||||
// title = 'Provisioned dashboard';
|
||||
// }
|
||||
|
||||
const renderBody = () => {
|
||||
if (showDiff) {
|
||||
return <SaveDashboardDiff diff={diffs} oldValue={initialSaveModel} newValue={changedSaveModel} />;
|
||||
}
|
||||
|
||||
if (saveAsCopy || changeInfo.isNew) {
|
||||
return <SaveDashboardAsForm dashboard={dashboard} changeInfo={changeInfo} drawer={model} />;
|
||||
}
|
||||
|
||||
return <SaveDashboardForm dashboard={dashboard} changeInfo={changeInfo} drawer={model} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer title={title} subtitle={dashboard.state.title} onClose={model.onClose} tabs={tabs}>
|
||||
{renderBody()}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
}
|
145
public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx
Normal file
145
public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Button, Checkbox, TextArea, Stack, Alert, Box, Field } from '@grafana/ui';
|
||||
import { SaveDashboardOptions } from 'app/features/dashboard/components/SaveDashboard/types';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
|
||||
import { SaveDashboardDrawer } from './SaveDashboardDrawer';
|
||||
import {
|
||||
DashboardChangeInfo,
|
||||
NameAlreadyExistsError,
|
||||
SaveButton,
|
||||
isNameExistsError,
|
||||
isPluginDashboardError,
|
||||
isVersionMismatchError,
|
||||
} from './shared';
|
||||
import { useDashboardSave } from './useSaveDashboard';
|
||||
|
||||
export interface Props {
|
||||
dashboard: DashboardScene;
|
||||
drawer: SaveDashboardDrawer;
|
||||
changeInfo: DashboardChangeInfo;
|
||||
}
|
||||
|
||||
export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) {
|
||||
const { saveVariables = false, saveTimeRange = false } = drawer.useState();
|
||||
const { changedSaveModel, hasChanges, hasTimeChanged, hasVariableValuesChanged } = changeInfo;
|
||||
|
||||
const { state, onSaveDashboard } = useDashboardSave(false);
|
||||
const [options, setOptions] = useState<SaveDashboardOptions>({
|
||||
folderUid: dashboard.state.meta.folderUid,
|
||||
});
|
||||
|
||||
const onSave = async (overwrite: boolean) => {
|
||||
const result = await onSaveDashboard(dashboard, changedSaveModel, { ...options, overwrite });
|
||||
if (result.status === 'success') {
|
||||
dashboard.closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
const cancelButton = (
|
||||
<Button variant="secondary" onClick={() => dashboard.closeModal()} fill="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
);
|
||||
|
||||
const saveButton = (overwrite: boolean) => (
|
||||
<SaveButton isValid={hasChanges} isLoading={state.loading} onSave={onSave} overwrite={overwrite} />
|
||||
);
|
||||
|
||||
function renderFooter(error?: Error) {
|
||||
if (isVersionMismatchError(error)) {
|
||||
return (
|
||||
<Alert title="Someone else has updated this dashboard" severity="error">
|
||||
<p>Would you still like to save this dashboard?</p>
|
||||
<Box paddingTop={2}>
|
||||
<Stack alignItems="center">
|
||||
{cancelButton}
|
||||
{saveButton(true)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (isNameExistsError(error)) {
|
||||
return <NameAlreadyExistsError cancelButton={cancelButton} saveButton={saveButton} />;
|
||||
}
|
||||
|
||||
if (isPluginDashboardError(error)) {
|
||||
return (
|
||||
<Alert title="Plugin dashboard" severity="error">
|
||||
<p>
|
||||
Your changes will be lost when you update the plugin. Use <strong>Save As</strong> to create custom version.
|
||||
</p>
|
||||
<Box paddingTop={2}>
|
||||
<Stack alignItems="center">
|
||||
{cancelButton}
|
||||
{saveButton(true)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<Alert title="Failed to save dashboard" severity="error">
|
||||
<p>{error.message}</p>
|
||||
</Alert>
|
||||
)}
|
||||
<Stack alignItems="center">
|
||||
{cancelButton}
|
||||
{saveButton(false)}
|
||||
{!hasChanges && <div>No changes to save</div>}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0} direction="column">
|
||||
{hasTimeChanged && (
|
||||
<Field label="Save current time range" description="Will make current time range the new default">
|
||||
<Checkbox
|
||||
id="save-timerange"
|
||||
checked={saveTimeRange}
|
||||
onChange={drawer.onToggleSaveTimeRange}
|
||||
aria-label={selectors.pages.SaveDashboardModal.saveTimerange}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{hasVariableValuesChanged && (
|
||||
<Field label="Save current variable values" description="Will make the current values the new default">
|
||||
<Checkbox
|
||||
checked={saveVariables}
|
||||
onChange={drawer.onToggleSaveVariables}
|
||||
label="Save current variable values as dashboard default"
|
||||
aria-label={selectors.pages.SaveDashboardModal.saveVariables}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Message">
|
||||
{/* config.featureToggles.dashgpt * TOOD GenAIDashboardChangesButton */}
|
||||
|
||||
<TextArea
|
||||
aria-label="message"
|
||||
value={options.message ?? ''}
|
||||
onChange={(e) => {
|
||||
setOptions({
|
||||
...options,
|
||||
message: e.currentTarget.value,
|
||||
});
|
||||
}}
|
||||
placeholder="Add a note to describe your changes (optional)."
|
||||
autoFocus
|
||||
rows={5}
|
||||
/>
|
||||
</Field>
|
||||
<Box paddingTop={2}>{renderFooter(state.error)}</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
112
public/app/features/dashboard-scene/saving/shared.tsx
Normal file
112
public/app/features/dashboard-scene/saving/shared.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { isFetchError } from '@grafana/runtime';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { Alert, Box, Button, Stack } from '@grafana/ui';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
|
||||
import { Diffs, jsonDiff } from '../settings/version-history/utils';
|
||||
|
||||
export interface DashboardChangeInfo {
|
||||
changedSaveModel: Dashboard;
|
||||
initialSaveModel: Dashboard;
|
||||
diffs: Diffs;
|
||||
diffCount: number;
|
||||
hasChanges: boolean;
|
||||
hasTimeChanged: boolean;
|
||||
hasVariableValuesChanged: boolean;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export function getSaveDashboardChange(dashboard: DashboardScene, saveTimeRange?: boolean): DashboardChangeInfo {
|
||||
const initialSaveModel = dashboard.getInitialSaveModel()!;
|
||||
const changedSaveModel = transformSceneToSaveModel(dashboard);
|
||||
const hasTimeChanged = getHasTimeChanged(changedSaveModel, initialSaveModel);
|
||||
const hasVariableValuesChanged = getVariableValueChanges(changedSaveModel, initialSaveModel);
|
||||
|
||||
if (!saveTimeRange) {
|
||||
changedSaveModel.time = initialSaveModel.time;
|
||||
}
|
||||
|
||||
const diff = jsonDiff(initialSaveModel, changedSaveModel);
|
||||
|
||||
let diffCount = 0;
|
||||
for (const d of Object.values(diff)) {
|
||||
diffCount += d.length;
|
||||
}
|
||||
|
||||
return {
|
||||
changedSaveModel,
|
||||
initialSaveModel,
|
||||
diffs: diff,
|
||||
diffCount,
|
||||
hasChanges: diffCount > 0,
|
||||
hasTimeChanged,
|
||||
isNew: changedSaveModel.version === 0,
|
||||
hasVariableValuesChanged,
|
||||
};
|
||||
}
|
||||
|
||||
function getHasTimeChanged(saveModel: Dashboard, originalSaveModel: Dashboard) {
|
||||
return saveModel.time?.from !== originalSaveModel.time?.from || saveModel.time?.to !== originalSaveModel.time?.to;
|
||||
}
|
||||
|
||||
function getVariableValueChanges(saveModel: Dashboard, originalSaveModel: Dashboard) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isVersionMismatchError(error?: Error) {
|
||||
return isFetchError(error) && error.data && error.data.status === 'version-mismatch';
|
||||
}
|
||||
|
||||
export function isNameExistsError(error?: Error) {
|
||||
return isFetchError(error) && error.data && error.data.status === 'name-exists';
|
||||
}
|
||||
|
||||
export function isPluginDashboardError(error?: Error) {
|
||||
return isFetchError(error) && error.data && error.data.status === 'plugin-dashboard';
|
||||
}
|
||||
|
||||
export interface NameAlreadyExistsErrorProps {
|
||||
cancelButton: React.ReactNode;
|
||||
saveButton: (overwrite: boolean) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function NameAlreadyExistsError({ cancelButton, saveButton }: NameAlreadyExistsErrorProps) {
|
||||
return (
|
||||
<Alert title="Name already exists" severity="error">
|
||||
<p>
|
||||
A dashboard with the same name in selected folder already exists. Would you still like to save this dashboard?
|
||||
</p>
|
||||
<Box paddingTop={2}>
|
||||
<Stack alignItems="center">
|
||||
{cancelButton}
|
||||
{saveButton(true)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SaveButtonProps {
|
||||
overwrite: boolean;
|
||||
onSave: (overwrite: boolean) => void;
|
||||
isLoading: boolean;
|
||||
isValid?: boolean;
|
||||
}
|
||||
|
||||
export function SaveButton({ overwrite, isLoading, isValid, onSave }: SaveButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
disabled={!isValid || isLoading}
|
||||
icon={isLoading ? 'spinner' : undefined}
|
||||
aria-label={selectors.pages.SaveDashboardModal.save}
|
||||
onClick={() => onSave(overwrite)}
|
||||
variant={overwrite ? 'destructive' : 'primary'}
|
||||
>
|
||||
{isLoading ? 'Saving...' : overwrite ? 'Save and overwrite' : 'Save'}
|
||||
</Button>
|
||||
);
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
import { useAsyncFn } from 'react-use';
|
||||
|
||||
import { locationUtil } from '@grafana/data';
|
||||
import { locationService, reportInteraction } from '@grafana/runtime';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { updateDashboardName } from 'app/core/reducers/navBarTree';
|
||||
import { useSaveDashboardMutation } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
import { SaveDashboardOptions } from 'app/features/dashboard/components/SaveDashboard/types';
|
||||
import { useDispatch } from 'app/types';
|
||||
import { DashboardSavedEvent } from 'app/types/events';
|
||||
|
||||
import { updateDashboardUidLastUsedDatasource } from '../../dashboard/utils/dashboard';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
|
||||
export function useDashboardSave(isCopy = false) {
|
||||
const dispatch = useDispatch();
|
||||
const notifyApp = useAppNotification();
|
||||
const [saveDashboardRtkQuery] = useSaveDashboardMutation();
|
||||
|
||||
const [state, onSaveDashboard] = useAsyncFn(
|
||||
async (scene: DashboardScene, saveModel: Dashboard, options: SaveDashboardOptions) => {
|
||||
{
|
||||
const result = await saveDashboardRtkQuery({
|
||||
dashboard: saveModel,
|
||||
folderUid: options.folderUid,
|
||||
message: options.message,
|
||||
overwrite: options.overwrite,
|
||||
showErrorAlert: false,
|
||||
});
|
||||
|
||||
if ('error' in result) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const resultData = result.data;
|
||||
scene.saveCompleted(saveModel, resultData, options.folderUid);
|
||||
|
||||
// important that these happen before location redirect below
|
||||
appEvents.publish(new DashboardSavedEvent());
|
||||
notifyApp.success('Dashboard saved');
|
||||
|
||||
//Update local storage dashboard to handle things like last used datasource
|
||||
updateDashboardUidLastUsedDatasource(resultData.uid);
|
||||
|
||||
if (isCopy) {
|
||||
reportInteraction('grafana_dashboard_copied', {
|
||||
name: saveModel.title,
|
||||
url: resultData.url,
|
||||
});
|
||||
} else {
|
||||
reportInteraction(`grafana_dashboard_${resultData.uid ? 'saved' : 'created'}`, {
|
||||
name: saveModel.title,
|
||||
url: resultData.url,
|
||||
});
|
||||
}
|
||||
|
||||
const currentLocation = locationService.getLocation();
|
||||
const newUrl = locationUtil.stripBaseFromUrl(resultData.url);
|
||||
|
||||
if (newUrl !== currentLocation.pathname) {
|
||||
setTimeout(() => locationService.replace({ pathname: newUrl, search: currentLocation.search }));
|
||||
}
|
||||
|
||||
if (scene.state.meta.isStarred) {
|
||||
dispatch(
|
||||
updateDashboardName({
|
||||
id: resultData.uid,
|
||||
title: scene.state.title,
|
||||
url: newUrl,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
},
|
||||
[dispatch, notifyApp]
|
||||
);
|
||||
|
||||
return { state, onSaveDashboard };
|
||||
}
|
@ -18,17 +18,17 @@ import {
|
||||
SceneVariable,
|
||||
SceneVariableDependencyConfigLike,
|
||||
} from '@grafana/scenes';
|
||||
import { DashboardLink } from '@grafana/schema';
|
||||
import { Dashboard, DashboardLink } from '@grafana/schema';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import { DashboardModel } from 'app/features/dashboard/state';
|
||||
import { VariablesChanged } from 'app/features/variables/types';
|
||||
import { DashboardDTO, DashboardMeta } from 'app/types';
|
||||
import { DashboardDTO, DashboardMeta, SaveDashboardResponseDTO } from 'app/types';
|
||||
|
||||
import { PanelEditor } from '../panel-edit/PanelEditor';
|
||||
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
|
||||
import { DashboardSceneRenderer } from '../scene/DashboardSceneRenderer';
|
||||
import { SaveDashboardDrawer } from '../serialization/SaveDashboardDrawer';
|
||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import { DecoratedRevisionModel } from '../settings/VersionsEditView';
|
||||
import { DashboardEditView } from '../settings/utils';
|
||||
@ -82,7 +82,6 @@ export interface DashboardSceneState extends SceneObjectState {
|
||||
editview?: DashboardEditView;
|
||||
/** Edit panel */
|
||||
editPanel?: PanelEditor;
|
||||
|
||||
/** Scene object that handles the current drawer or modal */
|
||||
overlay?: SceneObject;
|
||||
}
|
||||
@ -104,6 +103,10 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
* State before editing started
|
||||
*/
|
||||
private _initialState?: DashboardSceneState;
|
||||
/**
|
||||
* The save model which the scene was originally created from
|
||||
*/
|
||||
private _initialSaveModel?: Dashboard;
|
||||
/**
|
||||
* Url state before editing started
|
||||
*/
|
||||
@ -166,14 +169,42 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
this.setState({ isEditing: true });
|
||||
|
||||
// Propagate change edit mode change to children
|
||||
if (this.state.body instanceof SceneGridLayout) {
|
||||
this.state.body.setState({ isDraggable: true, isResizable: true });
|
||||
forceRenderChildren(this.state.body, true);
|
||||
}
|
||||
|
||||
this.propagateEditModeChange();
|
||||
this.startTrackingChanges();
|
||||
};
|
||||
|
||||
public saveCompleted(saveModel: Dashboard, result: SaveDashboardResponseDTO, folderUid?: string) {
|
||||
this._initialSaveModel = {
|
||||
...saveModel,
|
||||
id: result.id,
|
||||
uid: result.uid,
|
||||
version: result.version,
|
||||
};
|
||||
|
||||
this.stopTrackingChanges();
|
||||
this.setState({
|
||||
version: result.version,
|
||||
isDirty: false,
|
||||
uid: result.uid,
|
||||
id: result.id,
|
||||
meta: {
|
||||
...this.state.meta,
|
||||
uid: result.uid,
|
||||
url: result.url,
|
||||
slug: result.slug,
|
||||
folderUid: folderUid,
|
||||
},
|
||||
});
|
||||
this.startTrackingChanges();
|
||||
}
|
||||
|
||||
private propagateEditModeChange() {
|
||||
if (this.state.body instanceof SceneGridLayout) {
|
||||
this.state.body.setState({ isDraggable: this.state.isEditing, isResizable: this.state.isEditing });
|
||||
forceRenderChildren(this.state.body, true);
|
||||
}
|
||||
}
|
||||
|
||||
public onDiscard = () => {
|
||||
// No need to listen to changes anymore
|
||||
this.stopTrackingChanges();
|
||||
@ -198,12 +229,8 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
this.setState({ ...this._initialState, isEditing: false });
|
||||
// and start url sync again
|
||||
this.startUrlSync();
|
||||
|
||||
// Disable grid dragging
|
||||
if (this.state.body instanceof SceneGridLayout) {
|
||||
this.state.body.setState({ isDraggable: false, isResizable: false });
|
||||
forceRenderChildren(this.state.body, true);
|
||||
}
|
||||
this.propagateEditModeChange();
|
||||
};
|
||||
|
||||
public onRestore = async (version: DecoratedRevisionModel): Promise<boolean> => {
|
||||
@ -227,9 +254,18 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
return true;
|
||||
};
|
||||
|
||||
public onSave = () => {
|
||||
this.setState({ overlay: new SaveDashboardDrawer({ dashboardRef: this.getRef() }) });
|
||||
};
|
||||
public openSaveDrawer({ saveAsCopy }: { saveAsCopy?: boolean }) {
|
||||
if (!this.state.isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
overlay: new SaveDashboardDrawer({
|
||||
dashboardRef: this.getRef(),
|
||||
saveAsCopy,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public getPageNav(location: H.Location, navIndex: NavIndex) {
|
||||
const { meta, viewPanelScene, editPanel } = this.state;
|
||||
@ -383,6 +419,15 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
|
||||
return Boolean(meta.canEdit || meta.canMakeEditable);
|
||||
}
|
||||
|
||||
public getInitialSaveModel() {
|
||||
return this._initialSaveModel;
|
||||
}
|
||||
|
||||
/** Hacky temp function until we refactor transformSaveModelToScene a bit */
|
||||
public setInitialSaveModel(saveModel: Dashboard) {
|
||||
this._initialSaveModel = saveModel;
|
||||
}
|
||||
}
|
||||
|
||||
export class DashboardVariableDependency implements SceneVariableDependencyConfigLike {
|
||||
|
@ -120,6 +120,7 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
|
||||
variant="primary"
|
||||
icon="pen"
|
||||
fill="text"
|
||||
size="sm"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
@ -130,8 +131,9 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
|
||||
toolbarActions.push(
|
||||
<Button
|
||||
onClick={() => {
|
||||
dashboard.onSave();
|
||||
dashboard.openSaveDrawer({ saveAsCopy: true });
|
||||
}}
|
||||
size="sm"
|
||||
tooltip="Save as copy"
|
||||
fill="text"
|
||||
key="save-as"
|
||||
@ -146,6 +148,7 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
|
||||
}}
|
||||
tooltip="Discard changes"
|
||||
fill="text"
|
||||
size="sm"
|
||||
key="discard"
|
||||
variant="destructive"
|
||||
>
|
||||
@ -156,11 +159,12 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
|
||||
<Button
|
||||
onClick={() => {
|
||||
DashboardInteractions.toolbarSaveClick();
|
||||
dashboard.onSave();
|
||||
dashboard.openSaveDrawer({});
|
||||
}}
|
||||
tooltip="Save changes"
|
||||
key="save"
|
||||
disabled={!isDirty}
|
||||
size="sm"
|
||||
variant={isDirty ? 'primary' : 'secondary'}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
@ -110,6 +110,11 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
|
||||
onTrigger: scene.onOpenSettings,
|
||||
});
|
||||
|
||||
keybindings.addBinding({
|
||||
key: 'mod+s',
|
||||
onTrigger: () => scene.openSaveDrawer({}),
|
||||
});
|
||||
|
||||
// toggle all panel legends (TODO)
|
||||
// delete panel (TODO when we work on editing)
|
||||
// toggle all exemplars (TODO)
|
||||
|
@ -1,41 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneObjectRef } from '@grafana/scenes';
|
||||
import { Drawer } from '@grafana/ui';
|
||||
import { SaveDashboardDiff } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDiff';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { jsonDiff } from '../settings/version-history/utils';
|
||||
|
||||
import { transformSceneToSaveModel } from './transformSceneToSaveModel';
|
||||
|
||||
interface SaveDashboardDrawerState extends SceneObjectState {
|
||||
dashboardRef: SceneObjectRef<DashboardScene>;
|
||||
}
|
||||
|
||||
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {
|
||||
onClose = () => {
|
||||
this.state.dashboardRef.resolve().setState({ overlay: undefined });
|
||||
};
|
||||
|
||||
static Component = ({ model }: SceneComponentProps<SaveDashboardDrawer>) => {
|
||||
const dashboard = model.state.dashboardRef.resolve();
|
||||
const initialState = dashboard.getInitialState();
|
||||
const initialScene = new DashboardScene(initialState!);
|
||||
const initialSaveModel = transformSceneToSaveModel(initialScene);
|
||||
const changedSaveModel = transformSceneToSaveModel(dashboard);
|
||||
|
||||
const diff = jsonDiff(initialSaveModel, changedSaveModel);
|
||||
|
||||
// let diffCount = 0;
|
||||
// for (const d of Object.values(diff)) {
|
||||
// diffCount += d.length;
|
||||
// }
|
||||
|
||||
return (
|
||||
<Drawer title="Save dashboard" subtitle={dashboard.state.title} onClose={model.onClose}>
|
||||
<SaveDashboardDiff diff={diff} oldValue={initialSaveModel} newValue={changedSaveModel} />
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
}
|
@ -7,6 +7,7 @@ export function dataLayersToAnnotations(layers: SceneDataLayerProvider[]) {
|
||||
if (!(layer instanceof dataLayers.AnnotationsDataLayer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = {
|
||||
...layer.state.query,
|
||||
enable: Boolean(layer.state.isEnabled),
|
||||
|
@ -72,7 +72,11 @@ export function transformSaveModelToScene(rsp: DashboardDTO): DashboardScene {
|
||||
autoMigrateOldPanels: false,
|
||||
});
|
||||
|
||||
return createDashboardSceneFromDashboardModel(oldModel);
|
||||
const scene = createDashboardSceneFromDashboardModel(oldModel);
|
||||
// TODO: refactor createDashboardSceneFromDashboardModel to work on Dashboard schema model
|
||||
scene.setInitialSaveModel(rsp.dashboard);
|
||||
|
||||
return scene;
|
||||
}
|
||||
|
||||
export function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||
import { Diffs } from 'app/features/dashboard-scene/settings/version-history/utils';
|
||||
import { DashboardDataDTO } from 'app/types';
|
||||
|
||||
export interface SaveDashboardData {
|
||||
clone: Dashboard; // cloned copy
|
||||
@ -18,10 +17,11 @@ export interface SaveDashboardOptions extends CloneOptions {
|
||||
}
|
||||
|
||||
export interface SaveDashboardCommand {
|
||||
dashboard: DashboardDataDTO;
|
||||
dashboard: Dashboard;
|
||||
message?: string;
|
||||
folderUid?: string;
|
||||
overwrite?: boolean;
|
||||
showErrorAlert?: boolean;
|
||||
}
|
||||
|
||||
export interface SaveDashboardFormProps {
|
||||
|
@ -60,7 +60,7 @@ export function buildDataTrailFromQuery({ query, dsRef, timeRange }: DataTrailDr
|
||||
return new DataTrail({
|
||||
$timeRange: timeRange,
|
||||
metric: query.metric,
|
||||
initialDS: ds?.name,
|
||||
initialDS: ds?.uid,
|
||||
initialFilters: filters,
|
||||
embedded: true,
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user