mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Detect folder changes when saving a dashboard (#85378)
This commit is contained in:
parent
6a75a8f354
commit
d983629650
@ -2489,10 +2489,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/saving/getSaveDashboardChange.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
|
@ -35,61 +35,65 @@ export class DashboardSceneChangeTracker {
|
||||
Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'intervals') ||
|
||||
Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'refresh')
|
||||
) {
|
||||
this.detectChanges();
|
||||
this.detectSaveModelChanges();
|
||||
}
|
||||
}
|
||||
if (payload.changedObject instanceof behaviors.CursorSync) {
|
||||
this.detectChanges();
|
||||
this.detectSaveModelChanges();
|
||||
}
|
||||
if (payload.changedObject instanceof SceneDataLayerSet) {
|
||||
this.detectChanges();
|
||||
this.detectSaveModelChanges();
|
||||
}
|
||||
if (payload.changedObject instanceof DashboardGridItem) {
|
||||
this.detectChanges();
|
||||
this.detectSaveModelChanges();
|
||||
}
|
||||
if (payload.changedObject instanceof SceneGridLayout) {
|
||||
this.detectChanges();
|
||||
this.detectSaveModelChanges();
|
||||
}
|
||||
if (payload.changedObject instanceof DashboardScene) {
|
||||
if (Object.keys(payload.partialUpdate).some((key) => PERSISTED_PROPS.includes(key))) {
|
||||
this.detectChanges();
|
||||
this.detectSaveModelChanges();
|
||||
}
|
||||
}
|
||||
if (payload.changedObject instanceof SceneTimeRange) {
|
||||
this.detectChanges();
|
||||
this.detectSaveModelChanges();
|
||||
}
|
||||
if (payload.changedObject instanceof DashboardControls) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'hideTimeControls')) {
|
||||
this.detectChanges();
|
||||
this.detectSaveModelChanges();
|
||||
}
|
||||
}
|
||||
if (payload.changedObject instanceof SceneVariableSet) {
|
||||
this.detectChanges();
|
||||
this.detectSaveModelChanges();
|
||||
}
|
||||
if (payload.changedObject instanceof DashboardAnnotationsDataLayer) {
|
||||
if (!Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'data')) {
|
||||
this.detectChanges();
|
||||
this.detectSaveModelChanges();
|
||||
}
|
||||
}
|
||||
if (payload.changedObject instanceof behaviors.LiveNowTimer) {
|
||||
this.detectChanges();
|
||||
this.detectSaveModelChanges();
|
||||
}
|
||||
if (isSceneVariableInstance(payload.changedObject)) {
|
||||
this.detectChanges();
|
||||
this.detectSaveModelChanges();
|
||||
}
|
||||
}
|
||||
|
||||
private detectChanges() {
|
||||
private detectSaveModelChanges() {
|
||||
this._changesWorker?.postMessage({
|
||||
changed: transformSceneToSaveModel(this._dashboard),
|
||||
initial: this._dashboard.getInitialSaveModel(),
|
||||
});
|
||||
}
|
||||
|
||||
private hasMetadataChanges() {
|
||||
return this._dashboard.state.meta.folderUid !== this._dashboard.getInitialState()?.meta.folderUid;
|
||||
}
|
||||
|
||||
private updateIsDirty(result: DashboardChangeInfo) {
|
||||
const { hasChanges } = result;
|
||||
|
||||
if (hasChanges) {
|
||||
if (hasChanges || this.hasMetadataChanges()) {
|
||||
if (!this._dashboard.state.isDirty) {
|
||||
this._dashboard.setState({ isDirty: true });
|
||||
}
|
||||
|
@ -9,7 +9,6 @@ import { validationSrv } from 'app/features/manage-dashboards/services/Validatio
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
|
||||
import { SaveDashboardDrawer } from './SaveDashboardDrawer';
|
||||
import { DashboardChangeInfo, NameAlreadyExistsError, SaveButton, isNameExistsError } from './shared';
|
||||
import { useSaveDashboard } from './useSaveDashboard';
|
||||
|
||||
@ -23,11 +22,10 @@ interface SaveDashboardAsFormDTO {
|
||||
|
||||
export interface Props {
|
||||
dashboard: DashboardScene;
|
||||
drawer: SaveDashboardDrawer;
|
||||
changeInfo: DashboardChangeInfo;
|
||||
}
|
||||
|
||||
export function SaveDashboardAsForm({ dashboard, drawer, changeInfo }: Props) {
|
||||
export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
|
||||
const { changedSaveModel } = changeInfo;
|
||||
|
||||
const { register, handleSubmit, setValue, formState, getValues, watch, trigger } = useForm<SaveDashboardAsFormDTO>({
|
||||
|
@ -46,19 +46,21 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
|
||||
saveVariables,
|
||||
saveRefresh
|
||||
);
|
||||
const { changedSaveModel, initialSaveModel, diffs, diffCount } = changeInfo;
|
||||
const { changedSaveModel, initialSaveModel, diffs, diffCount, hasFolderChanges } = changeInfo;
|
||||
const changesCount = diffCount + (hasFolderChanges ? 1 : 0);
|
||||
const dashboard = model.state.dashboardRef.resolve();
|
||||
const isProvisioned = dashboard.state.meta.provisioned;
|
||||
const { meta } = dashboard.useState();
|
||||
const { provisioned: isProvisioned, folderTitle } = meta;
|
||||
|
||||
const tabs = (
|
||||
<TabsBar>
|
||||
<Tab label={'Details'} active={!showDiff} onChangeTab={() => model.setState({ showDiff: false })} />
|
||||
{diffCount > 0 && (
|
||||
{changesCount > 0 && (
|
||||
<Tab
|
||||
label={'Changes'}
|
||||
active={showDiff}
|
||||
onChangeTab={() => model.setState({ showDiff: true })}
|
||||
counter={diffCount}
|
||||
counter={changesCount}
|
||||
/>
|
||||
)}
|
||||
</TabsBar>
|
||||
@ -73,11 +75,20 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
|
||||
|
||||
const renderBody = () => {
|
||||
if (showDiff) {
|
||||
return <SaveDashboardDiff diff={diffs} oldValue={initialSaveModel} newValue={changedSaveModel} />;
|
||||
return (
|
||||
<SaveDashboardDiff
|
||||
diff={diffs}
|
||||
oldValue={initialSaveModel}
|
||||
newValue={changedSaveModel}
|
||||
hasFolderChanges={hasFolderChanges}
|
||||
oldFolder={dashboard.getInitialState()?.meta.folderTitle}
|
||||
newFolder={folderTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (saveAsCopy || changeInfo.isNew) {
|
||||
return <SaveDashboardAsForm dashboard={dashboard} changeInfo={changeInfo} drawer={model} />;
|
||||
return <SaveDashboardAsForm dashboard={dashboard} changeInfo={changeInfo} />;
|
||||
}
|
||||
|
||||
if (isProvisioned) {
|
||||
|
@ -104,7 +104,6 @@ export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) {
|
||||
<Stack gap={0} direction="column">
|
||||
<SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} />
|
||||
<Field label="Message">
|
||||
{/* config.featureToggles.dashgpt * TOOD GenAIDashboardChangesButton */}
|
||||
<TextArea
|
||||
aria-label="message"
|
||||
value={options.message ?? ''}
|
||||
|
@ -44,6 +44,17 @@ describe('getDashboardChangesFromScene', () => {
|
||||
expect(result.diffCount).toBe(1);
|
||||
});
|
||||
|
||||
it('Can detect folder change', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
dashboard.state.meta.folderUid = 'folder-2';
|
||||
|
||||
const result = getDashboardChangesFromScene(dashboard, false);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(0); // Diff count is 0 because the diff contemplate only the model
|
||||
expect(result.hasFolderChanges).toBe(true);
|
||||
});
|
||||
|
||||
it('Can detect refresh changed', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
|
@ -1,20 +1,35 @@
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
|
||||
|
||||
import { getDashboardChanges } from './getDashboardChanges';
|
||||
import { getDashboardChanges as getDashboardSaveModelChanges } from './getDashboardChanges';
|
||||
|
||||
/**
|
||||
* Get changes between the initial save model and the current scene.
|
||||
* It also checks if the folder has changed.
|
||||
* @param scene DashboardScene object
|
||||
* @param saveTimeRange if true, compare the time range
|
||||
* @param saveVariables if true, compare the variables
|
||||
* @param saveRefresh if true, compare the refresh interval
|
||||
* @returns
|
||||
*/
|
||||
export function getDashboardChangesFromScene(
|
||||
scene: DashboardScene,
|
||||
saveTimeRange?: boolean,
|
||||
saveVariables?: boolean,
|
||||
saveRefresh?: boolean
|
||||
) {
|
||||
const changeInfo = getDashboardChanges(
|
||||
const changeInfo = getDashboardSaveModelChanges(
|
||||
scene.getInitialSaveModel()!,
|
||||
transformSceneToSaveModel(scene),
|
||||
saveTimeRange,
|
||||
saveVariables,
|
||||
saveRefresh
|
||||
);
|
||||
return changeInfo;
|
||||
const hasFolderChanges = scene.getInitialState()?.meta.folderUid !== scene.state.meta.folderUid;
|
||||
|
||||
return {
|
||||
...changeInfo,
|
||||
hasFolderChanges,
|
||||
hasChanges: changeInfo.hasChanges || hasFolderChanges,
|
||||
};
|
||||
}
|
||||
|
@ -1,95 +0,0 @@
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { AdHocVariableModel, TypedVariableModel } from '@grafana/data';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
|
||||
import { jsonDiff } from '../settings/version-history/utils';
|
||||
|
||||
import { DashboardChangeInfo } from './shared';
|
||||
|
||||
export function getSaveDashboardChange(
|
||||
dashboard: DashboardScene,
|
||||
saveTimeRange?: boolean,
|
||||
saveVariables?: boolean,
|
||||
saveRefresh?: boolean
|
||||
): DashboardChangeInfo {
|
||||
const initialSaveModel = dashboard.getInitialSaveModel()!;
|
||||
|
||||
if (dashboard.state.editPanel) {
|
||||
dashboard.state.editPanel.commitChanges();
|
||||
}
|
||||
|
||||
const changedSaveModel = transformSceneToSaveModel(dashboard);
|
||||
const hasTimeChanged = getHasTimeChanged(changedSaveModel, initialSaveModel);
|
||||
|
||||
const hasVariableValueChanges = applyVariableChanges(changedSaveModel, initialSaveModel, saveVariables);
|
||||
const hasRefreshChanged = changedSaveModel.refresh !== initialSaveModel.refresh;
|
||||
|
||||
if (!saveTimeRange) {
|
||||
changedSaveModel.time = initialSaveModel.time;
|
||||
}
|
||||
|
||||
if (!saveRefresh) {
|
||||
changedSaveModel.refresh = initialSaveModel.refresh;
|
||||
}
|
||||
|
||||
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,
|
||||
hasTimeChanges: hasTimeChanged,
|
||||
isNew: changedSaveModel.version === 0,
|
||||
hasVariableValueChanges,
|
||||
hasRefreshChange: hasRefreshChanged,
|
||||
};
|
||||
}
|
||||
|
||||
export function getHasTimeChanged(saveModel: Dashboard, originalSaveModel: Dashboard) {
|
||||
return saveModel.time?.from !== originalSaveModel.time?.from || saveModel.time?.to !== originalSaveModel.time?.to;
|
||||
}
|
||||
|
||||
export function applyVariableChanges(saveModel: Dashboard, originalSaveModel: Dashboard, saveVariables?: boolean) {
|
||||
const originalVariables = originalSaveModel.templating?.list ?? [];
|
||||
const variablesToSave = saveModel.templating?.list ?? [];
|
||||
let hasVariableValueChanges = false;
|
||||
|
||||
for (const variable of variablesToSave) {
|
||||
const original = originalVariables.find(({ name, type }) => name === variable.name && type === variable.type);
|
||||
|
||||
if (!original) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Old schema property that never should be in persisted model
|
||||
if (original.current && Object.hasOwn(original.current, 'selected')) {
|
||||
delete original.current.selected;
|
||||
}
|
||||
|
||||
if (!isEqual(variable.current, original.current)) {
|
||||
hasVariableValueChanges = true;
|
||||
}
|
||||
|
||||
if (!saveVariables) {
|
||||
const typed = variable as TypedVariableModel;
|
||||
if (typed.type === 'adhoc') {
|
||||
typed.filters = (original as AdHocVariableModel).filters;
|
||||
} else {
|
||||
variable.current = original.current;
|
||||
variable.options = original.options;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasVariableValueChanges;
|
||||
}
|
@ -17,6 +17,7 @@ export interface DashboardChangeInfo {
|
||||
hasVariableValueChanges: boolean;
|
||||
hasRefreshChange: boolean;
|
||||
isNew?: boolean;
|
||||
hasFolderChanges?: boolean;
|
||||
}
|
||||
|
||||
export function isVersionMismatchError(error?: Error) {
|
||||
|
@ -175,7 +175,6 @@ describe('DashboardScene', () => {
|
||||
${'tags'} | ${['tag3', 'tag4']}
|
||||
${'editable'} | ${false}
|
||||
${'links'} | ${[]}
|
||||
${'meta'} | ${{ folderUid: 'new-folder-uid', folderTitle: 'new-folder-title', hasUnsavedFolderChange: true }}
|
||||
`(
|
||||
'A change to $prop should set isDirty true',
|
||||
({ prop, value }: { prop: keyof DashboardSceneState; value: unknown }) => {
|
||||
@ -189,6 +188,26 @@ describe('DashboardScene', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('A change to folderUid should set isDirty true', () => {
|
||||
const prevMeta = { ...scene.state.meta };
|
||||
|
||||
// The worker only detects changes in the model, so the folder change should be detected anyway
|
||||
mockResultsOfDetectChangesWorker({ hasChanges: false, hasTimeChanges: false, hasVariableValueChanges: false });
|
||||
|
||||
scene.setState({
|
||||
meta: {
|
||||
...prevMeta,
|
||||
folderUid: 'new-folder-uid',
|
||||
folderTitle: 'new-folder-title',
|
||||
},
|
||||
});
|
||||
|
||||
expect(scene.state.isDirty).toBe(true);
|
||||
|
||||
scene.exitEditMode({ skipConfirm: true });
|
||||
expect(scene.state.meta).toEqual(prevMeta);
|
||||
});
|
||||
|
||||
it('A change to refresh picker interval settings should set isDirty true', () => {
|
||||
const refreshPicker = dashboardSceneGraph.getRefreshPicker(scene)!;
|
||||
const prevState = [...refreshPicker.state.intervals!];
|
||||
|
@ -101,7 +101,6 @@ export class GeneralSettingsEditView
|
||||
...this._dashboard.state.meta,
|
||||
folderUid: newUID || this._dashboard.state.meta.folderUid,
|
||||
folderTitle: newTitle || this._dashboard.state.meta.folderTitle,
|
||||
hasUnsavedFolderChange: true,
|
||||
};
|
||||
|
||||
this._dashboard.setState({ meta: newMeta });
|
||||
|
@ -13,9 +13,19 @@ interface SaveDashboardDiffProps {
|
||||
|
||||
// calculated by parent so we can see summary in tabs
|
||||
diff?: Diffs;
|
||||
hasFolderChanges?: boolean;
|
||||
oldFolder?: string;
|
||||
newFolder?: string;
|
||||
}
|
||||
|
||||
export const SaveDashboardDiff = ({ diff, oldValue, newValue }: SaveDashboardDiffProps) => {
|
||||
export const SaveDashboardDiff = ({
|
||||
diff,
|
||||
oldValue,
|
||||
newValue,
|
||||
hasFolderChanges,
|
||||
oldFolder,
|
||||
newFolder,
|
||||
}: SaveDashboardDiffProps) => {
|
||||
const loader = useAsync(async () => {
|
||||
const oldJSON = JSON.stringify(oldValue ?? {}, null, 2);
|
||||
const newJSON = JSON.stringify(newValue ?? {}, null, 2);
|
||||
@ -48,23 +58,29 @@ export const SaveDashboardDiff = ({ diff, oldValue, newValue }: SaveDashboardDif
|
||||
}, [diff, oldValue, newValue]);
|
||||
|
||||
const { value } = loader;
|
||||
if (!value || !oldValue) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (value.count < 1) {
|
||||
return <div>No changes in this dashboard</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={1}>
|
||||
{value.schemaChange && value.schemaChange}
|
||||
{value.showDiffs && value.diffs}
|
||||
|
||||
<Box paddingTop={2}>
|
||||
<h4>Full JSON diff</h4>
|
||||
{value.jsonView}
|
||||
</Box>
|
||||
{hasFolderChanges && (
|
||||
<DiffGroup
|
||||
diffs={[{ op: 'replace', value: newFolder, originalValue: oldFolder, path: [], startLineNumber: 0 }]}
|
||||
key={'folder'}
|
||||
title={'folder'}
|
||||
/>
|
||||
)}
|
||||
{(!value || !oldValue) && <Spinner />}
|
||||
{value && value.count >= 1 ? (
|
||||
<>
|
||||
{value && value.schemaChange && value.schemaChange}
|
||||
{value && value.showDiffs && value.diffs}
|
||||
<Box paddingTop={1}>
|
||||
<h4>Full JSON diff</h4>
|
||||
{value.jsonView}
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box paddingTop={1}>No changes in the dashboard JSON</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user