Dashboards: show changes in save dialog (#46557)

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Ryan McKinley
2022-03-16 09:28:09 -07:00
committed by GitHub
parent a338c78ca8
commit 15ca294be0
26 changed files with 475 additions and 382 deletions

View File

@@ -1,50 +0,0 @@
import React, { useState } from 'react';
import { css } from '@emotion/css';
import { Modal } from '@grafana/ui';
import { SaveDashboardAsForm } from './forms/SaveDashboardAsForm';
import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy';
import { useDashboardSave } from './useDashboardSave';
import { SaveDashboardModalProps } from './types';
export const SaveDashboardAsModal: React.FC<
SaveDashboardModalProps & {
isNew?: boolean;
}
> = ({ dashboard, onDismiss, isNew }) => {
const { state, onDashboardSave } = useDashboardSave(dashboard);
const [dashboardSaveModelClone, setDashboardSaveModelClone] = useState();
return (
<>
{state.error && (
<SaveDashboardErrorProxy
error={state.error}
dashboard={dashboard}
dashboardSaveModel={dashboardSaveModelClone}
onDismiss={onDismiss}
/>
)}
{!state.error && (
<Modal
isOpen={true}
title="Save dashboard as..."
icon="copy"
onDismiss={onDismiss}
className={css`
width: 500px;
`}
>
<SaveDashboardAsForm
dashboard={dashboard}
onCancel={onDismiss}
onSuccess={onDismiss}
onSubmit={(clone, options, dashboard) => {
setDashboardSaveModelClone(clone);
return onDashboardSave(clone, options, dashboard);
}}
isNew={isNew}
/>
</Modal>
)}
</>
);
};

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { Button, ButtonVariant, ModalsController, FullWidthButtonContainer } from '@grafana/ui';
import { DashboardModel } from 'app/features/dashboard/state';
import { SaveDashboardAsModal } from './SaveDashboardAsModal';
import { SaveDashboardModalProxy } from './SaveDashboardModalProxy';
import { SaveDashboardDrawer } from './SaveDashboardDrawer';
import { selectors } from '@grafana/e2e-selectors';
interface SaveDashboardButtonProps {
@@ -17,7 +16,7 @@ export const SaveDashboardButton: React.FC<SaveDashboardButtonProps> = ({ dashbo
return (
<Button
onClick={() => {
showModal(SaveDashboardModalProxy, {
showModal(SaveDashboardDrawer, {
dashboard,
onSaveSuccess,
onDismiss: hideModal,
@@ -45,10 +44,11 @@ export const SaveDashboardAsButton: React.FC<SaveDashboardButtonProps & { varian
<FullWidthButtonContainer>
<Button
onClick={() => {
showModal(SaveDashboardAsModal, {
showModal(SaveDashboardDrawer, {
dashboard,
onSaveSuccess,
onDismiss: hideModal,
isCopy: true,
});
}}
variant={variant}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { css } from '@emotion/css';
import { Spinner, useStyles2 } from '@grafana/ui';
import { Diffs } from '../VersionHistory/utils';
import { DiffGroup } from '../VersionHistory/DiffGroup';
import { DiffViewer } from '../VersionHistory/DiffViewer';
import { GrafanaTheme2 } from '@grafana/data';
import { useAsync } from 'react-use';
interface SaveDashboardDiffProps {
oldValue?: any;
newValue?: any;
// calculated by parent so we can see summary in tabs
diff?: Diffs;
}
export const SaveDashboardDiff = ({ diff, oldValue, newValue }: SaveDashboardDiffProps) => {
const styles = useStyles2(getStyles);
const loader = useAsync(async () => {
const oldJSON = JSON.stringify(oldValue ?? {}, null, 2);
const newJSON = JSON.stringify(newValue ?? {}, null, 2);
return {
oldJSON,
newJSON,
diffs: Object.entries(diff ?? []).map(([key, diffs]) => (
<DiffGroup diffs={diffs} key={key} title={key} /> // this takes a long time for large diffs
)),
};
}, [diff, oldValue, newValue]);
const { value } = loader;
if (!value || !oldValue) {
return <Spinner />;
}
if (!value.diffs.length) {
return <div>No changes in this dashboard</div>;
}
return (
<div>
<div className={styles.spacer}>{value.diffs}</div>
<h4>JSON Diff</h4>
<DiffViewer oldValue={value.oldJSON} newValue={value.newJSON} />
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
spacer: css`
margin-bottom: ${theme.v1.spacing.xl};
`,
});

View File

@@ -0,0 +1,128 @@
import React, { useMemo, useState } from 'react';
import { Drawer, Tab, TabsBar } from '@grafana/ui';
import { SaveDashboardData, SaveDashboardModalProps, SaveDashboardOptions } from './types';
import { jsonDiff } from '../VersionHistory/utils';
import { useAsync } from 'react-use';
import { backendSrv } from 'app/core/services/backend_srv';
import { useDashboardSave } from './useDashboardSave';
import { SaveProvisionedDashboardForm } from './forms/SaveProvisionedDashboardForm';
import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy';
import { SaveDashboardAsForm } from './forms/SaveDashboardAsForm';
import { SaveDashboardForm } from './forms/SaveDashboardForm';
import { SaveDashboardDiff } from './SaveDashboardDiff';
export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCopy }: SaveDashboardModalProps) => {
const [options, setOptions] = useState<SaveDashboardOptions>({});
const isProvisioned = dashboard.meta.provisioned;
const isNew = dashboard.version === 0;
const previous = useAsync(async () => {
if (isNew) {
return undefined;
}
const result = await backendSrv.getDashboardByUid(dashboard.uid);
return result.dashboard;
}, [dashboard, isNew]);
const data = useMemo<SaveDashboardData>(() => {
const clone = dashboard.getSaveModelClone({
saveTimerange: Boolean(options.saveTimerange),
saveVariables: Boolean(options.saveVariables),
});
if (!previous.value) {
return { clone, diff: {}, diffCount: 0, hasChanges: false };
}
const cloneJSON = JSON.stringify(clone, null, 2);
const cloneSafe = JSON.parse(cloneJSON); // avoids undefined issues
const diff = jsonDiff(previous.value, cloneSafe);
let diffCount = 0;
for (const d of Object.values(diff)) {
diffCount += d.length;
}
return {
clone,
diff,
diffCount,
hasChanges: diffCount > 0 && !isNew,
};
}, [dashboard, previous.value, options, isNew]);
const [showDiff, setShowDiff] = useState(false);
const { state, onDashboardSave } = useDashboardSave(dashboard);
const onSuccess = onSaveSuccess
? () => {
onDismiss();
onSaveSuccess();
}
: onDismiss;
const renderBody = () => {
if (showDiff) {
return <SaveDashboardDiff diff={data.diff} oldValue={previous.value} newValue={data.clone} />;
}
if (isNew || isCopy) {
return (
<SaveDashboardAsForm
dashboard={dashboard}
onCancel={onDismiss}
onSuccess={onSuccess}
onSubmit={onDashboardSave}
isNew={isNew}
/>
);
}
if (isProvisioned) {
return <SaveProvisionedDashboardForm dashboard={dashboard} onCancel={onDismiss} onSuccess={onSuccess} />;
}
return (
<SaveDashboardForm
dashboard={dashboard}
saveModel={data}
onCancel={onDismiss}
onSuccess={onSuccess}
onSubmit={onDashboardSave}
options={options}
onOptionsChange={setOptions}
/>
);
};
if (state.error) {
return (
<SaveDashboardErrorProxy
error={state.error}
dashboard={dashboard}
dashboardSaveModel={data.clone}
onDismiss={onDismiss}
/>
);
}
return (
<Drawer
title={isCopy ? 'Save dashboard copy' : 'Save dashboard'}
onClose={onDismiss}
width={'40%'}
subtitle={dashboard.title}
tabs={
<TabsBar>
<Tab label={'Details'} active={!showDiff} onChangeTab={() => setShowDiff(false)} />
<Tab label={'Changes'} active={showDiff} onChangeTab={() => setShowDiff(true)} counter={data.diffCount} />
</TabsBar>
}
expandable
scrollableContent
>
{renderBody()}
</Drawer>
);
};

View File

@@ -1,51 +0,0 @@
import React, { useState } from 'react';
import { Modal } from '@grafana/ui';
import { css } from '@emotion/css';
import { SaveDashboardForm } from './forms/SaveDashboardForm';
import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy';
import { useDashboardSave } from './useDashboardSave';
import { SaveDashboardModalProps } from './types';
export const SaveDashboardModal: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss, onSaveSuccess }) => {
const { state, onDashboardSave } = useDashboardSave(dashboard);
const [dashboardSaveModelClone, setDashboardSaveModelClone] = useState();
return (
<>
{state.error && (
<SaveDashboardErrorProxy
error={state.error}
dashboard={dashboard}
dashboardSaveModel={dashboardSaveModelClone}
onDismiss={onDismiss}
/>
)}
{!state.error && (
<Modal
isOpen={true}
title="Save dashboard"
icon="copy"
onDismiss={onDismiss}
className={css`
width: 500px;
`}
>
<SaveDashboardForm
dashboard={dashboard}
onCancel={onDismiss}
onSuccess={() => {
onDismiss();
if (onSaveSuccess) {
onSaveSuccess();
}
}}
onSubmit={(clone, options, dashboard) => {
setDashboardSaveModelClone(clone);
return onDashboardSave(clone, options, dashboard);
}}
/>
</Modal>
)}
</>
);
};

View File

@@ -1,25 +0,0 @@
import React from 'react';
import { SaveProvisionedDashboard } from './SaveProvisionedDashboard';
import { SaveDashboardAsModal } from './SaveDashboardAsModal';
import { SaveDashboardModalProps } from './types';
import { SaveDashboardModal } from './SaveDashboardModal';
export const SaveDashboardModalProxy: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss, onSaveSuccess }) => {
const isProvisioned = dashboard.meta.provisioned;
const isNew = dashboard.version === 0;
const isChanged = dashboard.version > 0;
const modalProps = {
dashboard,
onDismiss,
onSaveSuccess,
};
return (
<>
{isChanged && !isProvisioned && <SaveDashboardModal {...modalProps} />}
{isProvisioned && <SaveProvisionedDashboard {...modalProps} />}
{isNew && <SaveDashboardAsModal {...modalProps} isNew />}
</>
);
};

View File

@@ -1,12 +0,0 @@
import React from 'react';
import { Modal } from '@grafana/ui';
import { SaveProvisionedDashboardForm } from './forms/SaveProvisionedDashboardForm';
import { SaveDashboardModalProps } from './types';
export const SaveProvisionedDashboard: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss }) => {
return (
<Modal isOpen={true} title="Cannot save provisioned dashboard" icon="copy" onDismiss={onDismiss}>
<SaveProvisionedDashboardForm dashboard={dashboard} onCancel={onDismiss} onSuccess={onDismiss} />
</Modal>
);
};

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Button, Input, Switch, Form, Field, InputControl, Modal } from '@grafana/ui';
import { Button, Input, Switch, Form, Field, InputControl, HorizontalGroup } from '@grafana/ui';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { SaveDashboardFormProps } from '../types';
@@ -119,17 +119,19 @@ export const SaveDashboardAsForm: React.FC<SaveDashboardAsFormProps> = ({
name="$folder"
/>
</Field>
<Field label="Copy tags">
<Switch {...register('copyTags')} />
</Field>
<Modal.ButtonRow>
{!isNew && (
<Field label="Copy tags">
<Switch {...register('copyTags')} />
</Field>
)}
<HorizontalGroup>
<Button type="button" variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
<Button type="submit" aria-label="Save dashboard button">
Save
</Button>
</Modal.ButtonRow>
</HorizontalGroup>
</>
)}
</Form>

View File

@@ -3,6 +3,7 @@ import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { DashboardModel } from 'app/features/dashboard/state';
import { SaveDashboardForm } from './SaveDashboardForm';
import { SaveDashboardOptions } from '../types';
const prepareDashboardMock = (
timeChanged: boolean,
@@ -36,6 +37,16 @@ const renderAndSubmitForm = async (dashboard: any, submitSpy: any) => {
submitSpy(jsonModel);
return { status: 'success' };
}}
saveModel={{
clone: dashboard,
diff: {},
diffCount: 0,
hasChanges: true,
}}
options={{}}
onOptionsChange={(opts: SaveDashboardOptions) => {
return;
}}
/>
);
@@ -56,6 +67,16 @@ describe('SaveDashboardAsForm', () => {
onSubmit={async () => {
return {};
}}
saveModel={{
clone: prepareDashboardMock(true, true, jest.fn(), jest.fn()) as any,
diff: {},
diffCount: 0,
hasChanges: true,
}}
options={{}}
onOptionsChange={(opts: SaveDashboardOptions) => {
return;
}}
/>
);

View File

@@ -1,70 +1,106 @@
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { Button, Checkbox, Form, Modal, TextArea } from '@grafana/ui';
import { Button, Checkbox, Form, TextArea } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { SaveDashboardFormProps } from '../types';
import { DashboardModel } from 'app/features/dashboard/state';
import { SaveDashboardData, SaveDashboardOptions } from '../types';
import { Stack } from '@grafana/experimental';
interface SaveDashboardFormDTO {
interface FormDTO {
message: string;
saveVariables: boolean;
saveTimerange: boolean;
}
export const SaveDashboardForm: React.FC<SaveDashboardFormProps> = ({ dashboard, onCancel, onSuccess, onSubmit }) => {
type Props = {
dashboard: DashboardModel; // original
saveModel: SaveDashboardData; // already cloned
onCancel: () => void;
onSuccess: () => void;
onSubmit?: (clone: any, options: SaveDashboardOptions, dashboard: DashboardModel) => Promise<any>;
options: SaveDashboardOptions;
onOptionsChange: (opts: SaveDashboardOptions) => void;
};
export const SaveDashboardForm = ({
dashboard,
saveModel,
options,
onSubmit,
onCancel,
onSuccess,
onOptionsChange,
}: Props) => {
const hasTimeChanged = useMemo(() => dashboard.hasTimeChanged(), [dashboard]);
const hasVariableChanged = useMemo(() => dashboard.hasVariableValuesChanged(), [dashboard]);
const [saving, setSaving] = useState(false);
return (
<Form
onSubmit={async (data: SaveDashboardFormDTO) => {
onSubmit={async (data: FormDTO) => {
if (!onSubmit) {
return;
}
const result = await onSubmit(dashboard.getSaveModelClone(data), data, dashboard);
setSaving(true);
const result = await onSubmit(saveModel.clone, options, dashboard);
if (result.status === 'success') {
if (data.saveVariables) {
if (options.saveVariables) {
dashboard.resetOriginalVariables();
}
if (data.saveTimerange) {
if (options.saveTimerange) {
dashboard.resetOriginalTime();
}
onSuccess();
}
setSaving(false);
}}
>
{({ register, errors }) => (
<>
<div>
{hasTimeChanged && (
<Checkbox
{...register('saveTimerange')}
label="Save current time range as dashboard default"
aria-label={selectors.pages.SaveDashboardModal.saveTimerange}
/>
)}
{hasVariableChanged && (
<Checkbox
{...register('saveVariables')}
label="Save current variable values as dashboard default"
aria-label={selectors.pages.SaveDashboardModal.saveVariables}
/>
)}
{(hasVariableChanged || hasTimeChanged) && <div className="gf-form-group" />}
<Stack direction="column" gap={2}>
{hasTimeChanged && (
<Checkbox
checked={options.saveTimerange}
onChange={() =>
onOptionsChange({
...options,
saveTimerange: !options.saveTimerange,
})
}
label="Save current time range as dashboard default"
aria-label={selectors.pages.SaveDashboardModal.saveTimerange}
/>
)}
{hasVariableChanged && (
<Checkbox
checked={options.saveVariables}
onChange={() =>
onOptionsChange({
...options,
saveVariables: !options.saveVariables,
})
}
label="Save current variable values as dashboard default"
aria-label={selectors.pages.SaveDashboardModal.saveVariables}
/>
)}
<TextArea {...register('message')} placeholder="Add a note to describe your changes." autoFocus />
</div>
<TextArea {...register('message')} placeholder="Add a note to describe your changes." autoFocus rows={5} />
<Modal.ButtonRow>
<Stack alignItems="center">
<Button variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
<Button type="submit" aria-label={selectors.pages.SaveDashboardModal.save}>
Save
<Button
type="submit"
disabled={!saveModel.hasChanges}
icon={saving ? 'fa fa-spinner' : undefined}
aria-label={selectors.pages.SaveDashboardModal.save}
>
{saving ? '' : 'Save'}
</Button>
</Modal.ButtonRow>
</>
{!saveModel.hasChanges && <div>No changes to save</div>}
</Stack>
</Stack>
)}
</Form>
);

View File

@@ -1,10 +1,11 @@
import React, { useCallback, useState } from 'react';
import { css } from '@emotion/css';
import { saveAs } from 'file-saver';
import { Button, ClipboardButton, Modal, stylesFactory, TextArea, useTheme } from '@grafana/ui';
import { Button, ClipboardButton, HorizontalGroup, stylesFactory, TextArea, useTheme } from '@grafana/ui';
import { SaveDashboardFormProps } from '../types';
import { GrafanaTheme } from '@grafana/data';
import { useAppNotification } from 'app/core/copy/appNotification';
import { Stack } from '@grafana/experimental';
export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({ dashboard, onCancel }) => {
const theme = useTheme();
@@ -29,7 +30,7 @@ export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({
const styles = getStyles(theme);
return (
<>
<div>
<Stack direction="column" gap={2}>
<div>
This dashboard cannot be saved from the Grafana UI because it has been provisioned from another source. Copy
the JSON or save it to a file below, then you can update your dashboard in the provisioning source.
@@ -57,7 +58,7 @@ export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({
}}
className={styles.json}
/>
<Modal.ButtonRow>
<HorizontalGroup>
<Button variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
@@ -65,8 +66,8 @@ export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({
Copy JSON to clipboard
</ClipboardButton>
<Button onClick={saveToFile}>Save JSON to file</Button>
</Modal.ButtonRow>
</div>
</HorizontalGroup>
</Stack>
</>
);
};

View File

@@ -1,4 +1,12 @@
import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { Diffs } from '../VersionHistory/utils';
export interface SaveDashboardData {
clone: DashboardModel; // cloned copy
diff: Diffs;
diffCount: number; // cumulative count
hasChanges: boolean; // not new and has changes
}
export interface SaveDashboardOptions extends CloneOptions {
folderId?: number;
@@ -18,4 +26,5 @@ export interface SaveDashboardModalProps {
dashboard: DashboardModel;
onDismiss: () => void;
onSaveSuccess?: () => void;
isCopy?: boolean;
}