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:
Torkel Ödegaard 2024-01-29 12:04:45 +01:00 committed by GitHub
parent bcc2409564
commit 1d5edb2a18
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 916 additions and 66 deletions

View File

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

View File

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

View File

@ -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 : [],
};
}

View File

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

View File

@ -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>
);
};
}

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
);
};
}

View File

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

View File

@ -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[] {

View File

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

View File

@ -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,
});