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.", "3"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
|
[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": [
|
"public/app/features/dashboard-scene/scene/DashboardScene.test.tsx:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||||
],
|
],
|
||||||
|
@ -275,9 +275,10 @@ export const browseDashboardsAPI = createApi({
|
|||||||
}),
|
}),
|
||||||
// save an existing dashboard
|
// save an existing dashboard
|
||||||
saveDashboard: builder.mutation<SaveDashboardResponseDTO, SaveDashboardCommand>({
|
saveDashboard: builder.mutation<SaveDashboardResponseDTO, SaveDashboardCommand>({
|
||||||
query: ({ dashboard, folderUid, message, overwrite }) => ({
|
query: ({ dashboard, folderUid, message, overwrite, showErrorAlert }) => ({
|
||||||
url: `/dashboards/db`,
|
url: `/dashboards/db`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
showErrorAlert,
|
||||||
data: {
|
data: {
|
||||||
dashboard,
|
dashboard,
|
||||||
folderUid,
|
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,
|
SceneVariable,
|
||||||
SceneVariableDependencyConfigLike,
|
SceneVariableDependencyConfigLike,
|
||||||
} from '@grafana/scenes';
|
} from '@grafana/scenes';
|
||||||
import { DashboardLink } from '@grafana/schema';
|
import { Dashboard, DashboardLink } from '@grafana/schema';
|
||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
import { getNavModel } from 'app/core/selectors/navModel';
|
import { getNavModel } from 'app/core/selectors/navModel';
|
||||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||||
import { DashboardModel } from 'app/features/dashboard/state';
|
import { DashboardModel } from 'app/features/dashboard/state';
|
||||||
import { VariablesChanged } from 'app/features/variables/types';
|
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 { PanelEditor } from '../panel-edit/PanelEditor';
|
||||||
|
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
|
||||||
import { DashboardSceneRenderer } from '../scene/DashboardSceneRenderer';
|
import { DashboardSceneRenderer } from '../scene/DashboardSceneRenderer';
|
||||||
import { SaveDashboardDrawer } from '../serialization/SaveDashboardDrawer';
|
|
||||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||||
import { DecoratedRevisionModel } from '../settings/VersionsEditView';
|
import { DecoratedRevisionModel } from '../settings/VersionsEditView';
|
||||||
import { DashboardEditView } from '../settings/utils';
|
import { DashboardEditView } from '../settings/utils';
|
||||||
@ -82,7 +82,6 @@ export interface DashboardSceneState extends SceneObjectState {
|
|||||||
editview?: DashboardEditView;
|
editview?: DashboardEditView;
|
||||||
/** Edit panel */
|
/** Edit panel */
|
||||||
editPanel?: PanelEditor;
|
editPanel?: PanelEditor;
|
||||||
|
|
||||||
/** Scene object that handles the current drawer or modal */
|
/** Scene object that handles the current drawer or modal */
|
||||||
overlay?: SceneObject;
|
overlay?: SceneObject;
|
||||||
}
|
}
|
||||||
@ -104,6 +103,10 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
|||||||
* State before editing started
|
* State before editing started
|
||||||
*/
|
*/
|
||||||
private _initialState?: DashboardSceneState;
|
private _initialState?: DashboardSceneState;
|
||||||
|
/**
|
||||||
|
* The save model which the scene was originally created from
|
||||||
|
*/
|
||||||
|
private _initialSaveModel?: Dashboard;
|
||||||
/**
|
/**
|
||||||
* Url state before editing started
|
* Url state before editing started
|
||||||
*/
|
*/
|
||||||
@ -166,14 +169,42 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
|||||||
this.setState({ isEditing: true });
|
this.setState({ isEditing: true });
|
||||||
|
|
||||||
// Propagate change edit mode change to children
|
// Propagate change edit mode change to children
|
||||||
if (this.state.body instanceof SceneGridLayout) {
|
this.propagateEditModeChange();
|
||||||
this.state.body.setState({ isDraggable: true, isResizable: true });
|
|
||||||
forceRenderChildren(this.state.body, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.startTrackingChanges();
|
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 = () => {
|
public onDiscard = () => {
|
||||||
// No need to listen to changes anymore
|
// No need to listen to changes anymore
|
||||||
this.stopTrackingChanges();
|
this.stopTrackingChanges();
|
||||||
@ -198,12 +229,8 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
|||||||
this.setState({ ...this._initialState, isEditing: false });
|
this.setState({ ...this._initialState, isEditing: false });
|
||||||
// and start url sync again
|
// and start url sync again
|
||||||
this.startUrlSync();
|
this.startUrlSync();
|
||||||
|
|
||||||
// Disable grid dragging
|
// Disable grid dragging
|
||||||
if (this.state.body instanceof SceneGridLayout) {
|
this.propagateEditModeChange();
|
||||||
this.state.body.setState({ isDraggable: false, isResizable: false });
|
|
||||||
forceRenderChildren(this.state.body, true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public onRestore = async (version: DecoratedRevisionModel): Promise<boolean> => {
|
public onRestore = async (version: DecoratedRevisionModel): Promise<boolean> => {
|
||||||
@ -227,9 +254,18 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
public onSave = () => {
|
public openSaveDrawer({ saveAsCopy }: { saveAsCopy?: boolean }) {
|
||||||
this.setState({ overlay: new SaveDashboardDrawer({ dashboardRef: this.getRef() }) });
|
if (!this.state.isEditing) {
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
overlay: new SaveDashboardDrawer({
|
||||||
|
dashboardRef: this.getRef(),
|
||||||
|
saveAsCopy,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public getPageNav(location: H.Location, navIndex: NavIndex) {
|
public getPageNav(location: H.Location, navIndex: NavIndex) {
|
||||||
const { meta, viewPanelScene, editPanel } = this.state;
|
const { meta, viewPanelScene, editPanel } = this.state;
|
||||||
@ -383,6 +419,15 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
|||||||
|
|
||||||
return Boolean(meta.canEdit || meta.canMakeEditable);
|
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 {
|
export class DashboardVariableDependency implements SceneVariableDependencyConfigLike {
|
||||||
|
@ -120,6 +120,7 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
icon="pen"
|
icon="pen"
|
||||||
fill="text"
|
fill="text"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
@ -130,8 +131,9 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
|
|||||||
toolbarActions.push(
|
toolbarActions.push(
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dashboard.onSave();
|
dashboard.openSaveDrawer({ saveAsCopy: true });
|
||||||
}}
|
}}
|
||||||
|
size="sm"
|
||||||
tooltip="Save as copy"
|
tooltip="Save as copy"
|
||||||
fill="text"
|
fill="text"
|
||||||
key="save-as"
|
key="save-as"
|
||||||
@ -146,6 +148,7 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
|
|||||||
}}
|
}}
|
||||||
tooltip="Discard changes"
|
tooltip="Discard changes"
|
||||||
fill="text"
|
fill="text"
|
||||||
|
size="sm"
|
||||||
key="discard"
|
key="discard"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
>
|
>
|
||||||
@ -156,11 +159,12 @@ export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
DashboardInteractions.toolbarSaveClick();
|
DashboardInteractions.toolbarSaveClick();
|
||||||
dashboard.onSave();
|
dashboard.openSaveDrawer({});
|
||||||
}}
|
}}
|
||||||
tooltip="Save changes"
|
tooltip="Save changes"
|
||||||
key="save"
|
key="save"
|
||||||
disabled={!isDirty}
|
size="sm"
|
||||||
|
variant={isDirty ? 'primary' : 'secondary'}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -110,6 +110,11 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
|
|||||||
onTrigger: scene.onOpenSettings,
|
onTrigger: scene.onOpenSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
keybindings.addBinding({
|
||||||
|
key: 'mod+s',
|
||||||
|
onTrigger: () => scene.openSaveDrawer({}),
|
||||||
|
});
|
||||||
|
|
||||||
// toggle all panel legends (TODO)
|
// toggle all panel legends (TODO)
|
||||||
// delete panel (TODO when we work on editing)
|
// delete panel (TODO when we work on editing)
|
||||||
// toggle all exemplars (TODO)
|
// 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)) {
|
if (!(layer instanceof dataLayers.AnnotationsDataLayer)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
...layer.state.query,
|
...layer.state.query,
|
||||||
enable: Boolean(layer.state.isEnabled),
|
enable: Boolean(layer.state.isEnabled),
|
||||||
|
@ -72,7 +72,11 @@ export function transformSaveModelToScene(rsp: DashboardDTO): DashboardScene {
|
|||||||
autoMigrateOldPanels: false,
|
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[] {
|
export function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { Dashboard } from '@grafana/schema';
|
import { Dashboard } from '@grafana/schema';
|
||||||
import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||||
import { Diffs } from 'app/features/dashboard-scene/settings/version-history/utils';
|
import { Diffs } from 'app/features/dashboard-scene/settings/version-history/utils';
|
||||||
import { DashboardDataDTO } from 'app/types';
|
|
||||||
|
|
||||||
export interface SaveDashboardData {
|
export interface SaveDashboardData {
|
||||||
clone: Dashboard; // cloned copy
|
clone: Dashboard; // cloned copy
|
||||||
@ -18,10 +17,11 @@ export interface SaveDashboardOptions extends CloneOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveDashboardCommand {
|
export interface SaveDashboardCommand {
|
||||||
dashboard: DashboardDataDTO;
|
dashboard: Dashboard;
|
||||||
message?: string;
|
message?: string;
|
||||||
folderUid?: string;
|
folderUid?: string;
|
||||||
overwrite?: boolean;
|
overwrite?: boolean;
|
||||||
|
showErrorAlert?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveDashboardFormProps {
|
export interface SaveDashboardFormProps {
|
||||||
|
@ -60,7 +60,7 @@ export function buildDataTrailFromQuery({ query, dsRef, timeRange }: DataTrailDr
|
|||||||
return new DataTrail({
|
return new DataTrail({
|
||||||
$timeRange: timeRange,
|
$timeRange: timeRange,
|
||||||
metric: query.metric,
|
metric: query.metric,
|
||||||
initialDS: ds?.name,
|
initialDS: ds?.uid,
|
||||||
initialFilters: filters,
|
initialFilters: filters,
|
||||||
embedded: true,
|
embedded: true,
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user