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.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"] [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": [ "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.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"], [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, 'intervals') ||
Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'refresh') Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'refresh')
) { ) {
this.detectChanges(); this.detectSaveModelChanges();
} }
} }
if (payload.changedObject instanceof behaviors.CursorSync) { if (payload.changedObject instanceof behaviors.CursorSync) {
this.detectChanges(); this.detectSaveModelChanges();
} }
if (payload.changedObject instanceof SceneDataLayerSet) { if (payload.changedObject instanceof SceneDataLayerSet) {
this.detectChanges(); this.detectSaveModelChanges();
} }
if (payload.changedObject instanceof DashboardGridItem) { if (payload.changedObject instanceof DashboardGridItem) {
this.detectChanges(); this.detectSaveModelChanges();
} }
if (payload.changedObject instanceof SceneGridLayout) { if (payload.changedObject instanceof SceneGridLayout) {
this.detectChanges(); this.detectSaveModelChanges();
} }
if (payload.changedObject instanceof DashboardScene) { if (payload.changedObject instanceof DashboardScene) {
if (Object.keys(payload.partialUpdate).some((key) => PERSISTED_PROPS.includes(key))) { if (Object.keys(payload.partialUpdate).some((key) => PERSISTED_PROPS.includes(key))) {
this.detectChanges(); this.detectSaveModelChanges();
} }
} }
if (payload.changedObject instanceof SceneTimeRange) { if (payload.changedObject instanceof SceneTimeRange) {
this.detectChanges(); this.detectSaveModelChanges();
} }
if (payload.changedObject instanceof DashboardControls) { if (payload.changedObject instanceof DashboardControls) {
if (Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'hideTimeControls')) { if (Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'hideTimeControls')) {
this.detectChanges(); this.detectSaveModelChanges();
} }
} }
if (payload.changedObject instanceof SceneVariableSet) { if (payload.changedObject instanceof SceneVariableSet) {
this.detectChanges(); this.detectSaveModelChanges();
} }
if (payload.changedObject instanceof DashboardAnnotationsDataLayer) { if (payload.changedObject instanceof DashboardAnnotationsDataLayer) {
if (!Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'data')) { if (!Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'data')) {
this.detectChanges(); this.detectSaveModelChanges();
} }
} }
if (payload.changedObject instanceof behaviors.LiveNowTimer) { if (payload.changedObject instanceof behaviors.LiveNowTimer) {
this.detectChanges(); this.detectSaveModelChanges();
} }
if (isSceneVariableInstance(payload.changedObject)) { if (isSceneVariableInstance(payload.changedObject)) {
this.detectChanges(); this.detectSaveModelChanges();
} }
} }
private detectChanges() { private detectSaveModelChanges() {
this._changesWorker?.postMessage({ this._changesWorker?.postMessage({
changed: transformSceneToSaveModel(this._dashboard), changed: transformSceneToSaveModel(this._dashboard),
initial: this._dashboard.getInitialSaveModel(), initial: this._dashboard.getInitialSaveModel(),
}); });
} }
private hasMetadataChanges() {
return this._dashboard.state.meta.folderUid !== this._dashboard.getInitialState()?.meta.folderUid;
}
private updateIsDirty(result: DashboardChangeInfo) { private updateIsDirty(result: DashboardChangeInfo) {
const { hasChanges } = result; const { hasChanges } = result;
if (hasChanges) { if (hasChanges || this.hasMetadataChanges()) {
if (!this._dashboard.state.isDirty) { if (!this._dashboard.state.isDirty) {
this._dashboard.setState({ isDirty: true }); 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 { DashboardScene } from '../scene/DashboardScene';
import { SaveDashboardDrawer } from './SaveDashboardDrawer';
import { DashboardChangeInfo, NameAlreadyExistsError, SaveButton, isNameExistsError } from './shared'; import { DashboardChangeInfo, NameAlreadyExistsError, SaveButton, isNameExistsError } from './shared';
import { useSaveDashboard } from './useSaveDashboard'; import { useSaveDashboard } from './useSaveDashboard';
@ -23,11 +22,10 @@ interface SaveDashboardAsFormDTO {
export interface Props { export interface Props {
dashboard: DashboardScene; dashboard: DashboardScene;
drawer: SaveDashboardDrawer;
changeInfo: DashboardChangeInfo; changeInfo: DashboardChangeInfo;
} }
export function SaveDashboardAsForm({ dashboard, drawer, changeInfo }: Props) { export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
const { changedSaveModel } = changeInfo; const { changedSaveModel } = changeInfo;
const { register, handleSubmit, setValue, formState, getValues, watch, trigger } = useForm<SaveDashboardAsFormDTO>({ const { register, handleSubmit, setValue, formState, getValues, watch, trigger } = useForm<SaveDashboardAsFormDTO>({

View File

@ -46,19 +46,21 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
saveVariables, saveVariables,
saveRefresh 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 dashboard = model.state.dashboardRef.resolve();
const isProvisioned = dashboard.state.meta.provisioned; const { meta } = dashboard.useState();
const { provisioned: isProvisioned, folderTitle } = meta;
const tabs = ( const tabs = (
<TabsBar> <TabsBar>
<Tab label={'Details'} active={!showDiff} onChangeTab={() => model.setState({ showDiff: false })} /> <Tab label={'Details'} active={!showDiff} onChangeTab={() => model.setState({ showDiff: false })} />
{diffCount > 0 && ( {changesCount > 0 && (
<Tab <Tab
label={'Changes'} label={'Changes'}
active={showDiff} active={showDiff}
onChangeTab={() => model.setState({ showDiff: true })} onChangeTab={() => model.setState({ showDiff: true })}
counter={diffCount} counter={changesCount}
/> />
)} )}
</TabsBar> </TabsBar>
@ -73,11 +75,20 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
const renderBody = () => { const renderBody = () => {
if (showDiff) { 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) { if (saveAsCopy || changeInfo.isNew) {
return <SaveDashboardAsForm dashboard={dashboard} changeInfo={changeInfo} drawer={model} />; return <SaveDashboardAsForm dashboard={dashboard} changeInfo={changeInfo} />;
} }
if (isProvisioned) { if (isProvisioned) {

View File

@ -104,7 +104,6 @@ export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) {
<Stack gap={0} direction="column"> <Stack gap={0} direction="column">
<SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} /> <SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} />
<Field label="Message"> <Field label="Message">
{/* config.featureToggles.dashgpt * TOOD GenAIDashboardChangesButton */}
<TextArea <TextArea
aria-label="message" aria-label="message"
value={options.message ?? ''} value={options.message ?? ''}

View File

@ -44,6 +44,17 @@ describe('getDashboardChangesFromScene', () => {
expect(result.diffCount).toBe(1); 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', () => { it('Can detect refresh changed', () => {
const dashboard = setup(); const dashboard = setup();

View File

@ -1,20 +1,35 @@
import { DashboardScene } from '../scene/DashboardScene'; import { DashboardScene } from '../scene/DashboardScene';
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; 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( export function getDashboardChangesFromScene(
scene: DashboardScene, scene: DashboardScene,
saveTimeRange?: boolean, saveTimeRange?: boolean,
saveVariables?: boolean, saveVariables?: boolean,
saveRefresh?: boolean saveRefresh?: boolean
) { ) {
const changeInfo = getDashboardChanges( const changeInfo = getDashboardSaveModelChanges(
scene.getInitialSaveModel()!, scene.getInitialSaveModel()!,
transformSceneToSaveModel(scene), transformSceneToSaveModel(scene),
saveTimeRange, saveTimeRange,
saveVariables, saveVariables,
saveRefresh 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; hasVariableValueChanges: boolean;
hasRefreshChange: boolean; hasRefreshChange: boolean;
isNew?: boolean; isNew?: boolean;
hasFolderChanges?: boolean;
} }
export function isVersionMismatchError(error?: Error) { export function isVersionMismatchError(error?: Error) {

View File

@ -175,7 +175,6 @@ describe('DashboardScene', () => {
${'tags'} | ${['tag3', 'tag4']} ${'tags'} | ${['tag3', 'tag4']}
${'editable'} | ${false} ${'editable'} | ${false}
${'links'} | ${[]} ${'links'} | ${[]}
${'meta'} | ${{ folderUid: 'new-folder-uid', folderTitle: 'new-folder-title', hasUnsavedFolderChange: true }}
`( `(
'A change to $prop should set isDirty true', 'A change to $prop should set isDirty true',
({ prop, value }: { prop: keyof DashboardSceneState; value: unknown }) => { ({ 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', () => { it('A change to refresh picker interval settings should set isDirty true', () => {
const refreshPicker = dashboardSceneGraph.getRefreshPicker(scene)!; const refreshPicker = dashboardSceneGraph.getRefreshPicker(scene)!;
const prevState = [...refreshPicker.state.intervals!]; const prevState = [...refreshPicker.state.intervals!];

View File

@ -101,7 +101,6 @@ export class GeneralSettingsEditView
...this._dashboard.state.meta, ...this._dashboard.state.meta,
folderUid: newUID || this._dashboard.state.meta.folderUid, folderUid: newUID || this._dashboard.state.meta.folderUid,
folderTitle: newTitle || this._dashboard.state.meta.folderTitle, folderTitle: newTitle || this._dashboard.state.meta.folderTitle,
hasUnsavedFolderChange: true,
}; };
this._dashboard.setState({ meta: newMeta }); this._dashboard.setState({ meta: newMeta });

View File

@ -13,9 +13,19 @@ interface SaveDashboardDiffProps {
// calculated by parent so we can see summary in tabs // calculated by parent so we can see summary in tabs
diff?: Diffs; 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 loader = useAsync(async () => {
const oldJSON = JSON.stringify(oldValue ?? {}, null, 2); const oldJSON = JSON.stringify(oldValue ?? {}, null, 2);
const newJSON = JSON.stringify(newValue ?? {}, null, 2); const newJSON = JSON.stringify(newValue ?? {}, null, 2);
@ -48,23 +58,29 @@ export const SaveDashboardDiff = ({ diff, oldValue, newValue }: SaveDashboardDif
}, [diff, oldValue, newValue]); }, [diff, oldValue, newValue]);
const { value } = loader; const { value } = loader;
if (!value || !oldValue) {
return <Spinner />;
}
if (value.count < 1) {
return <div>No changes in this dashboard</div>;
}
return ( return (
<Stack direction="column" gap={1}> <Stack direction="column" gap={1}>
{value.schemaChange && value.schemaChange} {hasFolderChanges && (
{value.showDiffs && value.diffs} <DiffGroup
diffs={[{ op: 'replace', value: newFolder, originalValue: oldFolder, path: [], startLineNumber: 0 }]}
<Box paddingTop={2}> key={'folder'}
<h4>Full JSON diff</h4> title={'folder'}
{value.jsonView} />
</Box> )}
{(!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> </Stack>
); );
}; };