Detect folder changes when saving a dashboard (#85378)

This commit is contained in:
Ivan Ortega Alba 2024-04-08 12:06:50 +02:00 committed by GitHub
parent 6a75a8f354
commit d983629650
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 117 additions and 143 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ?? ''}

View File

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

View File

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

View File

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

View File

@ -17,6 +17,7 @@ export interface DashboardChangeInfo {
hasVariableValueChanges: boolean;
hasRefreshChange: boolean;
isNew?: boolean;
hasFolderChanges?: boolean;
}
export function isVersionMismatchError(error?: Error) {

View File

@ -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!];

View File

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

View File

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