From 1cf53a34d113d55146d0d25eacbaed4002422cf6 Mon Sep 17 00:00:00 2001
From: Victor Marin <36818606+mdvictor@users.noreply.github.com>
Date: Mon, 15 Jan 2024 16:57:45 +0200
Subject: [PATCH] Scenes: Render versions in dashboard settings (#80229)
* Add versions tab in dashboard settings
* Fetch and render dashboard versions
* PR discussion changes
* remove unnecessary async in test
* PR discussion mods
* linter fix
---
.betterer.results | 4 +
.../settings/VersionsEditView.test.tsx | 112 +++++++
.../settings/VersionsEditView.tsx | 115 ++++++-
.../settings/version-history/DiffGroup.tsx | 57 ++++
.../settings/version-history/DiffTitle.tsx | 61 ++++
.../settings/version-history/DiffValues.tsx | 37 +++
.../settings/version-history/DiffViewer.tsx | 73 +++++
.../version-history/HistorySrv.test.ts | 75 +++++
.../settings/version-history/HistorySrv.ts | 50 +++
.../version-history/RevertDashboardModal.tsx | 34 ++
.../version-history/VersionHistoryButtons.tsx | 31 ++
.../VersionHistoryComparison.tsx | 82 +++++
.../version-history/VersionHistoryHeader.tsx | 41 +++
.../version-history/VersionHistoryTable.tsx | 73 +++++
.../__mocks__/dashboardHistoryMocks.ts | 176 +++++++++++
.../settings/version-history/index.ts | 5 +
.../version-history/useDashboardRestore.tsx | 39 +++
.../settings/version-history/utils.test.ts | 295 ++++++++++++++++++
.../settings/version-history/utils.ts | 104 ++++++
.../DashboardSettings/VersionsSettings.tsx | 4 +-
.../VersionHistory/HistorySrv.test.ts | 9 +-
.../components/VersionHistory/HistorySrv.ts | 9 +-
22 files changed, 1470 insertions(+), 16 deletions(-)
create mode 100644 public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx
create mode 100644 public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx
create mode 100644 public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx
create mode 100644 public/app/features/dashboard-scene/settings/version-history/DiffValues.tsx
create mode 100644 public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx
create mode 100644 public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts
create mode 100644 public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts
create mode 100644 public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx
create mode 100644 public/app/features/dashboard-scene/settings/version-history/VersionHistoryButtons.tsx
create mode 100644 public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx
create mode 100644 public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx
create mode 100644 public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx
create mode 100644 public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts
create mode 100644 public/app/features/dashboard-scene/settings/version-history/index.ts
create mode 100644 public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx
create mode 100644 public/app/features/dashboard-scene/settings/version-history/utils.test.ts
create mode 100644 public/app/features/dashboard-scene/settings/version-history/utils.ts
diff --git a/.betterer.results b/.betterer.results
index 2730e52414c..8c7ff46d795 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -2440,6 +2440,10 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/settings/variables/utils.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
+ "public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx:5381": [
+ [0, 0, 0, "Do not use any type assertions.", "0"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "1"]
+ ],
"public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx
new file mode 100644
index 00000000000..7b927360567
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx
@@ -0,0 +1,112 @@
+import { SceneGridItem, SceneGridLayout, SceneTimeRange } from '@grafana/scenes';
+
+import { DashboardScene } from '../scene/DashboardScene';
+import { activateFullSceneTree } from '../utils/test-utils';
+
+import { VERSIONS_FETCH_LIMIT, VersionsEditView } from './VersionsEditView';
+import { historySrv } from './version-history';
+
+jest.mock('./version-history/HistorySrv');
+
+describe('VersionsEditView', () => {
+ describe('Dashboard Versions state', () => {
+ let dashboard: DashboardScene;
+ let versionsView: VersionsEditView;
+
+ beforeEach(async () => {
+ jest.mocked(historySrv.getHistoryList).mockResolvedValue(getVersions());
+
+ const result = await buildTestScene();
+ dashboard = result.dashboard;
+ versionsView = result.versionsView;
+ });
+
+ it('should return the correct urlKey', () => {
+ expect(versionsView.getUrlKey()).toBe('versions');
+ });
+
+ it('should return the dashboard', () => {
+ expect(versionsView.getDashboard()).toBe(dashboard);
+ });
+
+ it('should return the decorated list of versions', () => {
+ const versions = versionsView.versions;
+
+ expect(versions).toHaveLength(2);
+ expect(versions[0].createdDateString).toBe('2017-02-22 20:43:01');
+ expect(versions[0].ageString).toBe('7 years ago');
+ expect(versions[1].createdDateString).toBe('2017-02-22 20:43:01');
+ expect(versions[1].ageString).toBe('7 years ago');
+ });
+
+ it('should bump the start threshold when fetching more versions', async () => {
+ expect(versionsView.start).toBe(VERSIONS_FETCH_LIMIT);
+
+ versionsView.fetchVersions(true);
+ await new Promise(process.nextTick);
+
+ expect(versionsView.start).toBe(VERSIONS_FETCH_LIMIT * 2);
+ });
+ });
+});
+
+function getVersions() {
+ return [
+ {
+ id: 4,
+ dashboardId: 1,
+ dashboardUID: '_U4zObQMz',
+ parentVersion: 3,
+ restoredFrom: 0,
+ version: 4,
+ created: '2017-02-22T17:43:01-08:00',
+ createdBy: 'admin',
+ message: '',
+ },
+ {
+ id: 3,
+ dashboardId: 1,
+ dashboardUID: '_U4zObQMz',
+ parentVersion: 1,
+ restoredFrom: 1,
+ version: 3,
+ created: '2017-02-22T17:43:01-08:00',
+ createdBy: 'admin',
+ message: '',
+ },
+ ];
+}
+
+async function buildTestScene() {
+ const versionsView = new VersionsEditView({ versions: [] });
+ const dashboard = new DashboardScene({
+ $timeRange: new SceneTimeRange({}),
+ title: 'hello',
+ uid: 'dash-1',
+ meta: {
+ canEdit: true,
+ },
+ body: new SceneGridLayout({
+ children: [
+ new SceneGridItem({
+ key: 'griditem-1',
+ x: 0,
+ y: 0,
+ width: 10,
+ height: 12,
+ body: undefined,
+ }),
+ ],
+ }),
+ editview: versionsView,
+ });
+
+ activateFullSceneTree(dashboard);
+
+ await new Promise((r) => setTimeout(r, 1));
+
+ dashboard.onEnterEditMode();
+ versionsView.activate();
+
+ return { dashboard, versionsView };
+}
diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx
index 1805a00096a..e5f69fee760 100644
--- a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx
+++ b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx
@@ -1,36 +1,139 @@
import React from 'react';
-import { PageLayoutType } from '@grafana/data';
-import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes';
+import { PageLayoutType, dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
+import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes';
+import { HorizontalGroup, Spinner } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { DashboardScene } from '../scene/DashboardScene';
import { getDashboardSceneFor } from '../utils/utils';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
+import { RevisionsModel, VersionHistoryTable, historySrv } from './version-history';
-export interface VersionsEditViewState extends DashboardEditViewState {}
+export const VERSIONS_FETCH_LIMIT = 10;
+
+export type DecoratedRevisionModel = RevisionsModel & {
+ createdDateString: string;
+ ageString: string;
+};
+
+export interface VersionsEditViewState extends DashboardEditViewState {
+ versions?: DecoratedRevisionModel[];
+ isLoading?: boolean;
+ isAppending?: boolean;
+}
export class VersionsEditView extends SceneObjectBase implements DashboardEditView {
public static Component = VersionsEditorSettingsListView;
+ private _limit: number = VERSIONS_FETCH_LIMIT;
+ private _start = 0;
+
+ constructor(state: VersionsEditViewState) {
+ super({
+ ...state,
+ versions: [],
+ isLoading: true,
+ isAppending: true,
+ });
+
+ this.addActivationHandler(() => {
+ this.fetchVersions();
+ });
+ }
+
+ private get _dashboard(): DashboardScene {
+ return getDashboardSceneFor(this);
+ }
+
+ public get versions(): DecoratedRevisionModel[] {
+ return this.state.versions ?? [];
+ }
+
+ public get limit(): number {
+ return this._limit;
+ }
+
+ public get start(): number {
+ return this._start;
+ }
public getUrlKey(): string {
return 'versions';
}
public getDashboard(): DashboardScene {
- return getDashboardSceneFor(this);
+ return this._dashboard;
+ }
+
+ public getTimeRange() {
+ return sceneGraph.getTimeRange(this._dashboard);
+ }
+
+ public fetchVersions(append = false): void {
+ const uid = this._dashboard.state.uid;
+
+ if (!uid) {
+ return;
+ }
+
+ this.setState({ isAppending: append });
+
+ historySrv
+ .getHistoryList(uid, { limit: this._limit, start: this._start })
+ .then((result) => {
+ this.setState({
+ isLoading: false,
+ versions: [...(this.state.versions ?? []), ...this.decorateVersions(result)],
+ });
+ this._start += this._limit;
+ })
+ .catch((err) => console.log(err))
+ .finally(() => this.setState({ isAppending: false }));
+ }
+
+ private decorateVersions(versions: RevisionsModel[]): DecoratedRevisionModel[] {
+ const timeZone = this.getTimeRange().getTimeZone();
+
+ return versions.map((version) => {
+ return {
+ ...version,
+ createdDateString: dateTimeFormat(version.created, { timeZone: timeZone }),
+ ageString: dateTimeFormatTimeAgo(version.created, { timeZone: timeZone }),
+ checked: false,
+ };
+ });
}
}
function VersionsEditorSettingsListView({ model }: SceneComponentProps) {
const dashboard = model.getDashboard();
-
+ const { isLoading, isAppending } = model.useState();
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
+ const canCompare = model.versions.filter((version) => version.checked).length === 2;
+
return (
- TODO
+ {isLoading ? (
+
+ ) : (
+ {
+ console.log('todo');
+ }}
+ canCompare={canCompare}
+ />
+ )}
+ {isAppending && }
);
}
+
+export const VersionsHistorySpinner = ({ msg }: { msg: string }) => (
+
+
+ {msg}
+
+);
diff --git a/public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx
new file mode 100644
index 00000000000..d5d67c34d83
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx
@@ -0,0 +1,57 @@
+import { css } from '@emotion/css';
+import { last } from 'lodash';
+import React from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { useStyles2 } from '@grafana/ui';
+
+import { DiffTitle } from './DiffTitle';
+import { DiffValues } from './DiffValues';
+import { Diff, getDiffText } from './utils';
+
+type DiffGroupProps = {
+ diffs: Diff[];
+ title: string;
+};
+
+export const DiffGroup = ({ diffs, title }: DiffGroupProps) => {
+ const styles = useStyles2(getStyles);
+
+ if (diffs.length === 1) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {diffs.map((diff: Diff, idx: number) => {
+ return (
+ -
+ {getDiffText(diff)}
+
+ );
+ })}
+
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ container: css({
+ 'background-color': theme.colors.background.secondary,
+ 'font-size': theme.typography.h6.fontSize,
+ 'margin-bottom': theme.spacing(2),
+ padding: theme.spacing(2),
+ }),
+ list: css({
+ 'margin-left': theme.spacing(4),
+ }),
+ listItem: css({
+ 'margin-bottom': theme.spacing(1),
+ }),
+});
diff --git a/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx
new file mode 100644
index 00000000000..7622da29b7f
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx
@@ -0,0 +1,61 @@
+import { css } from '@emotion/css';
+import React from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { useStyles2, Icon } from '@grafana/ui';
+
+import { DiffValues } from './DiffValues';
+import { Diff, getDiffText } from './utils';
+
+type DiffTitleProps = {
+ diff?: Diff;
+ title: string;
+};
+
+const replaceDiff: Diff = { op: 'replace', originalValue: undefined, path: [''], value: undefined, startLineNumber: 0 };
+
+export const DiffTitle = ({ diff, title }: DiffTitleProps) => {
+ const styles = useStyles2(getDiffTitleStyles);
+
+ return diff ? (
+ <>
+ {title}{' '}
+ {getDiffText(diff, diff.path.length > 1)}
+ >
+ ) : (
+
+ {title}{' '}
+ {getDiffText(replaceDiff, false)}
+
+ );
+};
+
+const getDiffTitleStyles = (theme: GrafanaTheme2) => ({
+ embolden: css({
+ 'font-weight': `${theme.typography.fontWeightBold}`,
+ }),
+ add: css({
+ color: theme.colors.success.main,
+ }),
+ replace: css({
+ color: theme.colors.success.main,
+ }),
+ move: css({
+ color: theme.colors.success.main,
+ }),
+ copy: css({
+ color: theme.colors.success.main,
+ }),
+ _get: css({
+ color: theme.colors.success.main,
+ }),
+ test: css({
+ color: theme.colors.success.main,
+ }),
+ remove: css({
+ color: theme.colors.success.main,
+ }),
+ withoutDiff: css({
+ 'margin-bottom': theme.spacing(2),
+ }),
+});
diff --git a/public/app/features/dashboard-scene/settings/version-history/DiffValues.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffValues.tsx
new file mode 100644
index 00000000000..181b9c07541
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/DiffValues.tsx
@@ -0,0 +1,37 @@
+import { css } from '@emotion/css';
+import { isArray, isObject, isUndefined } from 'lodash';
+import React from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { useStyles2, Icon } from '@grafana/ui';
+
+import { Diff } from './utils';
+
+type DiffProps = {
+ diff: Diff;
+};
+
+export const DiffValues = ({ diff }: DiffProps) => {
+ const styles = useStyles2(getStyles);
+ const hasLeftValue =
+ !isUndefined(diff.originalValue) && !isArray(diff.originalValue) && !isObject(diff.originalValue);
+ const hasRightValue = !isUndefined(diff.value) && !isArray(diff.value) && !isObject(diff.value);
+
+ return (
+ <>
+ {hasLeftValue && {String(diff.originalValue)}}
+ {hasLeftValue && hasRightValue ? : null}
+ {hasRightValue && {String(diff.value)}}
+ >
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) =>
+ css({
+ 'background-color': theme.colors.action.hover,
+ 'border-radius': theme.shape.radius.default,
+ color: theme.colors.text.primary,
+ 'font-size': theme.typography.body.fontSize,
+ margin: `0 ${theme.spacing(0.5)}`,
+ padding: theme.spacing(0.5, 1),
+ });
diff --git a/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx
new file mode 100644
index 00000000000..89060012b00
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx
@@ -0,0 +1,73 @@
+import { css } from '@emotion/css';
+import React from 'react';
+import ReactDiffViewer, { ReactDiffViewerProps, DiffMethod } from 'react-diff-viewer';
+import tinycolor from 'tinycolor2';
+
+import { useTheme2 } from '@grafana/ui';
+
+export const DiffViewer = ({ oldValue, newValue }: ReactDiffViewerProps) => {
+ const theme = useTheme2();
+
+ const styles = {
+ variables: {
+ // the light theme supplied by ReactDiffViewer is very similar to Grafana
+ // the dark theme needs some tweaks.
+ dark: {
+ diffViewerBackground: theme.colors.background.canvas,
+ diffViewerColor: theme.colors.text.primary,
+ addedBackground: tinycolor(theme.v1.palette.greenShade).setAlpha(0.3).toString(),
+ addedColor: 'white',
+ removedBackground: tinycolor(theme.v1.palette.redShade).setAlpha(0.3).toString(),
+ removedColor: 'white',
+ wordAddedBackground: tinycolor(theme.v1.palette.greenBase).setAlpha(0.4).toString(),
+ wordRemovedBackground: tinycolor(theme.v1.palette.redBase).setAlpha(0.4).toString(),
+ addedGutterBackground: tinycolor(theme.v1.palette.greenShade).setAlpha(0.2).toString(),
+ removedGutterBackground: tinycolor(theme.v1.palette.redShade).setAlpha(0.2).toString(),
+ gutterBackground: theme.colors.background.primary,
+ gutterBackgroundDark: theme.colors.background.primary,
+ highlightBackground: tinycolor(theme.colors.primary.main).setAlpha(0.4).toString(),
+ highlightGutterBackground: tinycolor(theme.colors.primary.shade).setAlpha(0.2).toString(),
+ codeFoldGutterBackground: theme.colors.background.secondary,
+ codeFoldBackground: theme.colors.background.secondary,
+ emptyLineBackground: theme.colors.background.secondary,
+ gutterColor: theme.colors.text.disabled,
+ addedGutterColor: theme.colors.text.primary,
+ removedGutterColor: theme.colors.text.primary,
+ codeFoldContentColor: theme.colors.text.disabled,
+ diffViewerTitleBackground: theme.colors.background.secondary,
+ diffViewerTitleColor: theme.colors.text.disabled,
+ diffViewerTitleBorderColor: theme.colors.border.strong,
+ },
+ },
+ codeFold: {
+ fontSize: theme.typography.bodySmall.fontSize,
+ },
+ gutter: `
+ pre {
+ color: ${tinycolor(theme.colors.text.disabled).setAlpha(1).toString()};
+ opacity: 0.61;
+ }
+ `,
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts
new file mode 100644
index 00000000000..488b7722f89
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts
@@ -0,0 +1,75 @@
+import { createDashboardModelFixture } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures';
+
+import { HistorySrv } from './HistorySrv';
+import { restore, versions } from './__mocks__/dashboardHistoryMocks';
+
+const getMock = jest.fn().mockResolvedValue({});
+const postMock = jest.fn().mockResolvedValue({});
+
+jest.mock('app/core/store');
+jest.mock('@grafana/runtime', () => {
+ const original = jest.requireActual('@grafana/runtime');
+
+ return {
+ ...original,
+ getBackendSrv: () => ({
+ post: postMock,
+ get: getMock,
+ }),
+ };
+});
+
+describe('historySrv', () => {
+ const versionsResponse = versions();
+ const restoreResponse = restore;
+
+ let historySrv = new HistorySrv();
+
+ const dash = createDashboardModelFixture({ uid: '_U4zObQMz' });
+ const emptyDash = createDashboardModelFixture();
+ const historyListOpts = { limit: 10, start: 0 };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('getHistoryList', () => {
+ it('should return a versions array for the given dashboard id', () => {
+ getMock.mockImplementation(() => Promise.resolve(versionsResponse));
+ historySrv = new HistorySrv();
+
+ return historySrv.getHistoryList(dash.uid, historyListOpts).then((versions) => {
+ expect(versions).toEqual(versionsResponse);
+ });
+ });
+
+ it('should return an empty array when not given an id', () => {
+ return historySrv.getHistoryList(emptyDash.uid, historyListOpts).then((versions) => {
+ expect(versions).toEqual([]);
+ });
+ });
+
+ it('should return an empty array when not given a dashboard id', () => {
+ return historySrv.getHistoryList(null as unknown as string, historyListOpts).then((versions) => {
+ expect(versions).toEqual([]);
+ });
+ });
+ });
+
+ describe('restoreDashboard', () => {
+ it('should return a success response given valid parameters', () => {
+ const version = 6;
+ postMock.mockImplementation(() => Promise.resolve(restoreResponse(version)));
+ historySrv = new HistorySrv();
+ return historySrv.restoreDashboard(dash, version).then((response) => {
+ expect(response).toEqual(restoreResponse(version));
+ });
+ });
+
+ it('should return an empty object when not given an id', async () => {
+ historySrv = new HistorySrv();
+ const rsp = await historySrv.restoreDashboard(emptyDash, 6);
+ expect(rsp).toEqual({});
+ });
+ });
+});
diff --git a/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts
new file mode 100644
index 00000000000..f68390807c1
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts
@@ -0,0 +1,50 @@
+import { isNumber } from 'lodash';
+
+import { getBackendSrv } from '@grafana/runtime';
+import { DashboardModel } from 'app/features/dashboard/state';
+
+export interface HistoryListOpts {
+ limit: number;
+ start: number;
+}
+
+export interface RevisionsModel {
+ id: number;
+ checked: boolean;
+ dashboardUID: string;
+ parentVersion: number;
+ version: number;
+ created: Date;
+ createdBy: string;
+ message: string;
+}
+
+export interface DiffTarget {
+ dashboardUID: string;
+ version: number;
+ unsavedDashboard?: DashboardModel; // when doing diffs against unsaved dashboard version
+}
+
+export class HistorySrv {
+ getHistoryList(dashboardUID: string, options: HistoryListOpts) {
+ if (typeof dashboardUID !== 'string') {
+ return Promise.resolve([]);
+ }
+
+ return getBackendSrv().get(`api/dashboards/uid/${dashboardUID}/versions`, options);
+ }
+
+ getDashboardVersion(uid: string, version: number) {
+ return getBackendSrv().get(`api/dashboards/uid/${uid}/versions/${version}`);
+ }
+
+ restoreDashboard(dashboard: DashboardModel, version: number) {
+ const uid = dashboard && dashboard.uid ? dashboard.uid : void 0;
+ const url = `api/dashboards/uid/${uid}/restore`;
+
+ return uid && isNumber(version) ? getBackendSrv().post(url, { version }) : Promise.resolve({});
+ }
+}
+
+const historySrv = new HistorySrv();
+export { historySrv };
diff --git a/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx b/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx
new file mode 100644
index 00000000000..e24c2155693
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx
@@ -0,0 +1,34 @@
+import React, { useEffect } from 'react';
+
+import { ConfirmModal } from '@grafana/ui';
+
+import { useDashboardRestore } from './useDashboardRestore';
+export interface RevertDashboardModalProps {
+ hideModal: () => void;
+ version: number;
+}
+
+export const RevertDashboardModal = ({ hideModal, version }: RevertDashboardModalProps) => {
+ // TODO: how should state.error be handled?
+ const { state, onRestoreDashboard } = useDashboardRestore(version);
+
+ useEffect(() => {
+ if (!state.loading && state.value) {
+ hideModal();
+ }
+ }, [state, hideModal]);
+
+ return (
+ Are you sure you want to restore the dashboard to version {version}? All unsaved changes will be lost.
+ }
+ confirmText={`Yes, restore to version ${version}`}
+ />
+ );
+};
diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryButtons.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryButtons.tsx
new file mode 100644
index 00000000000..073b9c64d8d
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryButtons.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+
+import { Tooltip, Button, Stack } from '@grafana/ui';
+
+type VersionsButtonsType = {
+ hasMore: boolean;
+ canCompare: boolean;
+ getVersions: (append: boolean) => void;
+ getDiff: () => void;
+ isLastPage: boolean;
+};
+export const VersionsHistoryButtons = ({
+ hasMore,
+ canCompare,
+ getVersions,
+ getDiff,
+ isLastPage,
+}: VersionsButtonsType) => (
+
+ {hasMore && (
+
+ )}
+
+
+
+
+);
diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx
new file mode 100644
index 00000000000..a2239fa60a2
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx
@@ -0,0 +1,82 @@
+import { css, cx } from '@emotion/css';
+import React from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { Button, ModalsController, CollapsableSection, HorizontalGroup, useStyles2 } from '@grafana/ui';
+
+import { DecoratedRevisionModel } from '../VersionsEditView';
+
+import { DiffGroup } from './DiffGroup';
+import { DiffViewer } from './DiffViewer';
+import { RevertDashboardModal } from './RevertDashboardModal';
+import { jsonDiff } from './utils';
+
+type DiffViewProps = {
+ isNewLatest: boolean;
+ newInfo: DecoratedRevisionModel;
+ baseInfo: DecoratedRevisionModel;
+ diffData: { lhs: string; rhs: string };
+};
+
+export const VersionHistoryComparison = ({ baseInfo, newInfo, diffData, isNewLatest }: DiffViewProps) => {
+ const diff = jsonDiff(diffData.lhs, diffData.rhs);
+ const styles = useStyles2(getStyles);
+
+ return (
+
+
+
+
+
+ Version {newInfo.version} updated by {newInfo.createdBy} {newInfo.ageString} -{' '}
+ {newInfo.message}
+
+
+ Version {baseInfo.version} updated by {baseInfo.createdBy} {baseInfo.ageString} -{' '}
+ {baseInfo.message}
+
+
+ {isNewLatest && (
+
+ {({ showModal, hideModal }) => (
+
+ )}
+
+ )}
+
+
+
+ {Object.entries(diff).map(([key, diffs]) => (
+
+ ))}
+
+
+
+
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ spacer: css({
+ 'margin-bottom': theme.spacing(4),
+ }),
+ versionInfo: css({
+ color: theme.colors.text.secondary,
+ 'font-size': theme.typography.bodySmall.fontSize,
+ }),
+ noMarginBottom: css({
+ 'margin-bottom': 0,
+ }),
+});
diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx
new file mode 100644
index 00000000000..ccfdaa6d17e
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx
@@ -0,0 +1,41 @@
+import { css } from '@emotion/css';
+import { noop } from 'lodash';
+import React from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { Icon, IconButton, useStyles2 } from '@grafana/ui';
+
+type VersionHistoryHeaderProps = {
+ onClick?: () => void;
+ baseVersion?: number;
+ newVersion?: number;
+ isNewLatest?: boolean;
+};
+
+export const VersionHistoryHeader = ({
+ onClick = noop,
+ baseVersion = 0,
+ newVersion = 0,
+ isNewLatest = false,
+}: VersionHistoryHeaderProps) => {
+ const styles = useStyles2(getStyles);
+
+ return (
+
+
+
+ Comparing {baseVersion} {newVersion}{' '}
+ {isNewLatest && (Latest)}
+
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ header: css({
+ 'font-size': theme.typography.h3.fontSize,
+ display: 'flex',
+ gap: theme.spacing(2),
+ 'margin-bottom': theme.spacing(3),
+ }),
+});
diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx
new file mode 100644
index 00000000000..b766bdac061
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx
@@ -0,0 +1,73 @@
+import { css } from '@emotion/css';
+import React from 'react';
+
+import { Checkbox, Button, Tag, ModalsController } from '@grafana/ui';
+
+import { DecoratedRevisionModel } from '../VersionsEditView';
+
+import { RevertDashboardModal } from './RevertDashboardModal';
+
+type VersionsTableProps = {
+ versions: DecoratedRevisionModel[];
+ canCompare: boolean;
+ onCheck: (ev: React.FormEvent, versionId: number) => void;
+};
+
+export const VersionHistoryTable = ({ versions, canCompare, onCheck }: VersionsTableProps) => (
+
+
+
+ |
+ Version |
+ Date |
+ Updated by |
+ Notes |
+ |
+
+
+
+ {versions.map((version, idx) => (
+
+ |
+ onCheck(ev, version.id)}
+ disabled={!version.checked && canCompare}
+ />
+ |
+ {version.version} |
+ {version.createdDateString} |
+ {version.createdBy} |
+ {version.message} |
+
+ {idx === 0 ? (
+
+ ) : (
+
+ {({ showModal, hideModal }) => (
+
+ )}
+
+ )}
+ |
+
+ ))}
+
+
+);
diff --git a/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts b/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts
new file mode 100644
index 00000000000..0de97c47a95
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts
@@ -0,0 +1,176 @@
+export function versions() {
+ return [
+ {
+ id: 4,
+ dashboardId: 1,
+ dashboardUID: '_U4zObQMz',
+ parentVersion: 3,
+ restoredFrom: 0,
+ version: 4,
+ created: '2017-02-22T17:43:01-08:00',
+ createdBy: 'admin',
+ message: '',
+ },
+ {
+ id: 3,
+ dashboardId: 1,
+ dashboardUID: '_U4zObQMz',
+ parentVersion: 1,
+ restoredFrom: 1,
+ version: 3,
+ created: '2017-02-22T17:43:01-08:00',
+ createdBy: 'admin',
+ message: '',
+ },
+ {
+ id: 2,
+ dashboardId: 1,
+ dashboardUID: '_U4zObQMz',
+ parentVersion: 0,
+ restoredFrom: -1,
+ version: 2,
+ created: '2017-02-22T17:29:52-08:00',
+ createdBy: 'admin',
+ message: '',
+ },
+ {
+ id: 1,
+ dashboardId: 1,
+ dashboardUID: '_U4zObQMz',
+ parentVersion: 0,
+ restoredFrom: -1,
+ slug: 'history-dashboard',
+ version: 1,
+ created: '2017-02-22T17:06:37-08:00',
+ createdBy: 'admin',
+ message: '',
+ },
+ ];
+}
+
+export function restore(version: number, restoredFrom?: number) {
+ return {
+ dashboard: {
+ meta: {
+ type: 'db',
+ canSave: true,
+ canEdit: true,
+ canStar: true,
+ slug: 'history-dashboard',
+ expires: '0001-01-01T00:00:00Z',
+ created: '2017-02-21T18:40:45-08:00',
+ updated: '2017-04-11T21:31:22.59219665-07:00',
+ updatedBy: 'admin',
+ createdBy: 'admin',
+ version: version,
+ },
+ dashboard: {
+ annotations: {
+ list: [],
+ },
+ description: 'A random dashboard for implementing the history list',
+ editable: true,
+ gnetId: null,
+ graphTooltip: 0,
+ id: 1,
+ uid: '_U4zObQMz',
+ links: [],
+ restoredFrom: restoredFrom,
+ rows: [
+ {
+ collapse: false,
+ height: '250px',
+ panels: [
+ {
+ aliasColors: {},
+ bars: false,
+ datasource: null,
+ fill: 1,
+ id: 1,
+ legend: {
+ avg: false,
+ current: false,
+ max: false,
+ min: false,
+ show: true,
+ total: false,
+ values: false,
+ },
+ lines: true,
+ linewidth: 1,
+ nullPointMode: 'null',
+ percentage: false,
+ pointradius: 5,
+ points: false,
+ renderer: 'flot',
+ seriesOverrides: [],
+ span: 12,
+ stack: false,
+ steppedLine: false,
+ targets: [{}],
+ thresholds: [],
+ timeFrom: null,
+ timeShift: null,
+ title: 'Panel Title',
+ tooltip: {
+ shared: true,
+ sort: 0,
+ value_type: 'individual',
+ },
+ type: 'graph',
+ xaxis: {
+ mode: 'time',
+ name: null,
+ show: true,
+ values: [],
+ },
+ yaxes: [
+ {
+ format: 'short',
+ label: null,
+ logBase: 1,
+ max: null,
+ min: null,
+ show: true,
+ },
+ {
+ format: 'short',
+ label: null,
+ logBase: 1,
+ max: null,
+ min: null,
+ show: true,
+ },
+ ],
+ },
+ ],
+ repeat: null,
+ repeatIteration: null,
+ repeatRowId: null,
+ showTitle: false,
+ title: 'Dashboard Row',
+ titleSize: 'h6',
+ },
+ ],
+ schemaVersion: 14,
+ tags: ['development'],
+ templating: {
+ list: [],
+ },
+ time: {
+ from: 'now-6h',
+ to: 'now',
+ },
+ timepicker: {
+ refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'],
+ time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'],
+ },
+ timezone: 'utc',
+ title: 'History Dashboard',
+ version: version,
+ },
+ },
+ message: 'Dashboard restored to version ' + version,
+ version: version,
+ };
+}
diff --git a/public/app/features/dashboard-scene/settings/version-history/index.ts b/public/app/features/dashboard-scene/settings/version-history/index.ts
new file mode 100644
index 00000000000..c87d2d0b9b7
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/index.ts
@@ -0,0 +1,5 @@
+export { HistorySrv, historySrv, RevisionsModel } from './HistorySrv';
+export { VersionHistoryTable } from './VersionHistoryTable';
+export { VersionHistoryHeader } from './VersionHistoryHeader';
+export { VersionsHistoryButtons } from './VersionHistoryButtons';
+export { VersionHistoryComparison } from './VersionHistoryComparison';
diff --git a/public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx b/public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx
new file mode 100644
index 00000000000..447cd2fe392
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx
@@ -0,0 +1,39 @@
+import { useEffect } from 'react';
+import { useAsyncFn } from 'react-use';
+
+import { locationUtil } from '@grafana/data';
+import { locationService } from '@grafana/runtime';
+import { useAppNotification } from 'app/core/copy/appNotification';
+import { DashboardModel } from 'app/features/dashboard/state';
+import { useSelector } from 'app/types';
+
+import { dashboardWatcher } from '../../../live/dashboard/dashboardWatcher';
+
+import { historySrv } from './HistorySrv';
+
+const restoreDashboard = async (version: number, dashboard: DashboardModel) => {
+ // Skip the watcher logic for this save since it's handled by the hook
+ dashboardWatcher.ignoreNextSave();
+ return await historySrv.restoreDashboard(dashboard, version);
+};
+
+export const useDashboardRestore = (version: number) => {
+ const dashboard = useSelector((state) => state.dashboard.getModel());
+ const [state, onRestoreDashboard] = useAsyncFn(async () => await restoreDashboard(version, dashboard!), []);
+ const notifyApp = useAppNotification();
+
+ useEffect(() => {
+ if (state.value) {
+ const location = locationService.getLocation();
+ const newUrl = locationUtil.stripBaseFromUrl(state.value.url);
+ const prevState = (location.state as any)?.routeReloadCounter;
+ locationService.replace({
+ ...location,
+ pathname: newUrl,
+ state: { routeReloadCounter: prevState ? prevState + 1 : 1 },
+ });
+ notifyApp.success('Dashboard restored', `Restored from version ${version}`);
+ }
+ }, [state, version, notifyApp]);
+ return { state, onRestoreDashboard };
+};
diff --git a/public/app/features/dashboard-scene/settings/version-history/utils.test.ts b/public/app/features/dashboard-scene/settings/version-history/utils.test.ts
new file mode 100644
index 00000000000..8d38c5cf6e9
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/utils.test.ts
@@ -0,0 +1,295 @@
+import { Dashboard } from '@grafana/schema';
+
+import { Diff, getDiffOperationText, getDiffText, jsonDiff } from './utils';
+
+describe('getDiffOperationText', () => {
+ const cases = [
+ ['add', 'added'],
+ ['remove', 'deleted'],
+ ['replace', 'changed'],
+ ['byDefault', 'changed'],
+ ];
+
+ test.each(cases)('it returns the correct verb for an operation', (operation, expected) => {
+ expect(getDiffOperationText(operation)).toBe(expected);
+ });
+});
+
+type DiffTextCase = [Partial, string];
+describe('getDiffText', () => {
+ const addEmptyArray: DiffTextCase = [
+ { op: 'add', value: [], path: ['annotations', 'list'], startLineNumber: 24 },
+ 'added list',
+ ];
+ const addArrayNumericProp: DiffTextCase = [
+ {
+ op: 'add',
+ value: ['tag'],
+ path: ['panels', '3'],
+ },
+ 'added item 3',
+ ];
+ const addArrayProp: DiffTextCase = [
+ {
+ op: 'add',
+ value: [{ name: 'dummy target 1' }, { name: 'dummy target 2' }],
+ path: ['panels', '3', 'targets'],
+ },
+ 'added 2 targets',
+ ];
+ const addValueNumericProp: DiffTextCase = [
+ {
+ op: 'add',
+ value: 'foo',
+ path: ['panels', '3'],
+ },
+ 'added item 3',
+ ];
+ const addValueProp: DiffTextCase = [
+ {
+ op: 'add',
+ value: 'foo',
+ path: ['panels', '3', 'targets'],
+ },
+ 'added targets',
+ ];
+
+ const removeEmptyArray: DiffTextCase = [
+ { op: 'remove', originalValue: [], path: ['annotations', 'list'], startLineNumber: 24 },
+ 'deleted list',
+ ];
+ const removeArrayNumericProp: DiffTextCase = [
+ {
+ op: 'remove',
+ originalValue: ['tag'],
+ path: ['panels', '3'],
+ },
+ 'deleted item 3',
+ ];
+ const removeArrayProp: DiffTextCase = [
+ {
+ op: 'remove',
+ originalValue: [{ name: 'dummy target 1' }, { name: 'dummy target 2' }],
+ path: ['panels', '3', 'targets'],
+ },
+ 'deleted 2 targets',
+ ];
+ const removeValueNumericProp: DiffTextCase = [
+ {
+ op: 'remove',
+ originalValue: 'foo',
+ path: ['panels', '3'],
+ },
+ 'deleted item 3',
+ ];
+ const removeValueProp: DiffTextCase = [
+ {
+ op: 'remove',
+ originalValue: 'foo',
+ path: ['panels', '3', 'targets'],
+ },
+ 'deleted targets',
+ ];
+ const replaceValueNumericProp: DiffTextCase = [
+ {
+ op: 'replace',
+ originalValue: 'foo',
+ value: 'bar',
+ path: ['panels', '3'],
+ },
+ 'changed item 3',
+ ];
+ const replaceValueProp: DiffTextCase = [
+ {
+ op: 'replace',
+ originalValue: 'foo',
+ value: 'bar',
+ path: ['panels', '3', 'targets'],
+ },
+ 'changed targets',
+ ];
+
+ const cases = [
+ addEmptyArray,
+ addArrayNumericProp,
+ addArrayProp,
+ addValueNumericProp,
+ addValueProp,
+ removeEmptyArray,
+ removeArrayNumericProp,
+ removeArrayProp,
+ removeValueNumericProp,
+ removeValueProp,
+ replaceValueNumericProp,
+ replaceValueProp,
+ ];
+
+ test.each(cases)(
+ 'returns a semantic message based on the type of diff, the values and the location of the change',
+ (diff: Partial, expected: string) => {
+ expect(getDiffText(diff as unknown as Diff)).toBe(expected);
+ }
+ );
+});
+
+describe('jsonDiff', () => {
+ it('returns data related to each change', () => {
+ const lhs = {
+ annotations: {
+ list: [
+ {
+ builtIn: 1,
+ datasource: '-- Grafana --',
+ enable: true,
+ hide: true,
+ iconColor: 'rgba(0, 211, 255, 1)',
+ name: 'Annotations & Alerts',
+ type: 'dashboard',
+ },
+ ],
+ },
+ editable: true,
+ gnetId: null,
+ graphTooltip: 0,
+ id: 141,
+ links: [],
+ panels: [],
+ schemaVersion: 27,
+ tags: [],
+ templating: {
+ list: [],
+ },
+ time: {
+ from: 'now-6h',
+ to: 'now',
+ },
+ timepicker: {},
+ timezone: '',
+ title: 'test dashboard',
+ uid: '_U4zObQMz',
+ version: 2,
+ };
+
+ const rhs = {
+ annotations: {
+ list: [
+ {
+ builtIn: 1,
+ datasource: '-- Grafana --',
+ enable: true,
+ hide: true,
+ iconColor: 'rgba(0, 211, 255, 1)',
+ name: 'Annotations & Alerts',
+ type: 'dashboard',
+ },
+ ],
+ },
+ description: 'a description',
+ editable: true,
+ gnetId: null,
+ graphTooltip: 1,
+ id: 141,
+ links: [],
+ panels: [
+ {
+ type: 'graph',
+ },
+ ],
+ schemaVersion: 27,
+ tags: ['the tag'],
+ templating: {
+ list: [],
+ },
+ time: {
+ from: 'now-6h',
+ to: 'now',
+ },
+ timepicker: {
+ refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'],
+ },
+ timezone: 'utc',
+ title: 'My favourite dashboard',
+ uid: '_U4zObQMz',
+ version: 3,
+ };
+
+ const expected = {
+ description: [
+ {
+ op: 'add',
+ originalValue: undefined,
+ path: ['description'],
+ startLineNumber: 14,
+ value: 'a description',
+ },
+ ],
+ graphTooltip: [
+ {
+ op: 'replace',
+ originalValue: 0,
+ path: ['graphTooltip'],
+ startLineNumber: 17,
+ value: 1,
+ },
+ ],
+ panels: [
+ {
+ op: 'add',
+ originalValue: undefined,
+ path: ['panels', '0'],
+ startLineNumber: 21,
+ value: {
+ type: 'graph',
+ },
+ },
+ ],
+ tags: [
+ {
+ op: 'add',
+ originalValue: undefined,
+ path: ['tags', '0'],
+ startLineNumber: 27,
+ value: 'the tag',
+ },
+ ],
+ timepicker: [
+ {
+ op: 'add',
+ originalValue: undefined,
+ path: ['timepicker', 'refresh_intervals'],
+ startLineNumber: 37,
+ value: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'],
+ },
+ ],
+ timezone: [
+ {
+ op: 'replace',
+ originalValue: '',
+ path: ['timezone'],
+ startLineNumber: 51,
+ value: 'utc',
+ },
+ ],
+ title: [
+ {
+ op: 'replace',
+ originalValue: 'test dashboard',
+ path: ['title'],
+ startLineNumber: 52,
+ value: 'My favourite dashboard',
+ },
+ ],
+ version: [
+ {
+ op: 'replace',
+ originalValue: 2,
+ path: ['version'],
+ startLineNumber: 54,
+ value: 3,
+ },
+ ],
+ };
+
+ expect(jsonDiff(lhs as unknown as Dashboard, rhs as unknown as Dashboard)).toStrictEqual(expected);
+ });
+});
diff --git a/public/app/features/dashboard-scene/settings/version-history/utils.ts b/public/app/features/dashboard-scene/settings/version-history/utils.ts
new file mode 100644
index 00000000000..ac036e2acae
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/version-history/utils.ts
@@ -0,0 +1,104 @@
+import { compare, Operation } from 'fast-json-patch';
+// @ts-ignore
+import jsonMap from 'json-source-map';
+import { flow, get, isArray, isEmpty, last, sortBy, tail, toNumber, isNaN } from 'lodash';
+
+import { Dashboard } from '@grafana/schema';
+
+export type Diff = {
+ op: 'add' | 'replace' | 'remove' | 'copy' | 'test' | '_get' | 'move';
+ value: unknown;
+ originalValue: unknown;
+ path: string[];
+ startLineNumber: number;
+};
+
+export type Diffs = {
+ [key: string]: Diff[];
+};
+
+export type JSONValue = string | Dashboard;
+
+export const jsonDiff = (lhs: JSONValue, rhs: JSONValue): Diffs => {
+ const diffs = compare(lhs, rhs);
+ const lhsMap = jsonMap.stringify(lhs, null, 2);
+ const rhsMap = jsonMap.stringify(rhs, null, 2);
+
+ const getDiffInformation = (diffs: Operation[]): Diff[] => {
+ return diffs.map((diff) => {
+ let originalValue = undefined;
+ let value = undefined;
+ let startLineNumber = 0;
+
+ const path = tail(diff.path.split('/'));
+
+ if (diff.op === 'replace' && rhsMap.pointers[diff.path]) {
+ originalValue = get(lhs, path);
+ value = diff.value;
+ startLineNumber = rhsMap.pointers[diff.path].value.line;
+ }
+ if (diff.op === 'add' && rhsMap.pointers[diff.path]) {
+ value = diff.value;
+ startLineNumber = rhsMap.pointers[diff.path].value.line;
+ }
+ if (diff.op === 'remove' && lhsMap.pointers[diff.path]) {
+ originalValue = get(lhs, path);
+ startLineNumber = lhsMap.pointers[diff.path].value.line;
+ }
+
+ return {
+ op: diff.op,
+ value,
+ path,
+ originalValue,
+ startLineNumber,
+ };
+ });
+ };
+
+ const sortByLineNumber = (diffs: Diff[]) => sortBy(diffs, 'startLineNumber');
+ const groupByPath = (diffs: Diff[]) =>
+ diffs.reduce>((acc, value) => {
+ const groupKey: string = value.path[0];
+ if (!acc[groupKey]) {
+ acc[groupKey] = [];
+ }
+ acc[groupKey].push(value);
+ return acc;
+ }, {});
+
+ return flow([getDiffInformation, sortByLineNumber, groupByPath])(diffs);
+};
+
+export const getDiffText = (diff: Diff, showProp = true) => {
+ const prop = last(diff.path)!;
+ const propIsNumeric = isNumeric(prop);
+ const val = diff.op === 'remove' ? diff.originalValue : diff.value;
+ let text = getDiffOperationText(diff.op);
+
+ if (showProp) {
+ if (propIsNumeric) {
+ text += ` item ${prop}`;
+ } else {
+ if (isArray(val) && !isEmpty(val)) {
+ text += ` ${val.length} ${prop}`;
+ } else {
+ text += ` ${prop}`;
+ }
+ }
+ }
+
+ return text;
+};
+
+const isNumeric = (value: string) => !isNaN(toNumber(value));
+
+export const getDiffOperationText = (operation: string): string => {
+ if (operation === 'add') {
+ return 'added';
+ }
+ if (operation === 'remove') {
+ return 'deleted';
+ }
+ return 'changed';
+};
diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx
index d2b13040688..bd4585b2c15 100644
--- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx
+++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx
@@ -63,7 +63,7 @@ export class VersionsSettings extends PureComponent {
getVersions = (append = false) => {
this.setState({ isAppending: append });
historySrv
- .getHistoryList(this.props.dashboard, { limit: this.limit, start: this.start })
+ .getHistoryList(this.props.dashboard.uid, { limit: this.limit, start: this.start })
.then((res) => {
this.setState({
isLoading: false,
@@ -186,7 +186,7 @@ export class VersionsSettings extends PureComponent {
}
}
-const VersionsHistorySpinner = ({ msg }: { msg: string }) => (
+export const VersionsHistorySpinner = ({ msg }: { msg: string }) => (
{msg}
diff --git a/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts
index 8571795c054..ab89796f738 100644
--- a/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts
+++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts
@@ -1,4 +1,3 @@
-import { DashboardModel } from '../../state/DashboardModel';
import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures';
import { HistorySrv } from './HistorySrv';
@@ -39,19 +38,19 @@ describe('historySrv', () => {
getMock.mockImplementation(() => Promise.resolve(versionsResponse));
historySrv = new HistorySrv();
- return historySrv.getHistoryList(dash, historyListOpts).then((versions) => {
+ return historySrv.getHistoryList(dash.uid, historyListOpts).then((versions) => {
expect(versions).toEqual(versionsResponse);
});
});
it('should return an empty array when not given an id', () => {
- return historySrv.getHistoryList(emptyDash, historyListOpts).then((versions) => {
+ return historySrv.getHistoryList(emptyDash.uid, historyListOpts).then((versions) => {
expect(versions).toEqual([]);
});
});
- it('should return an empty array when not given a dashboard', () => {
- return historySrv.getHistoryList(null as unknown as DashboardModel, historyListOpts).then((versions) => {
+ it('should return an empty array when not given a dashboard id', () => {
+ return historySrv.getHistoryList(null as unknown as string, historyListOpts).then((versions) => {
expect(versions).toEqual([]);
});
});
diff --git a/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts
index 351882ac6dc..bb3aaec7abe 100644
--- a/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts
+++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts
@@ -27,9 +27,12 @@ export interface DiffTarget {
}
export class HistorySrv {
- getHistoryList(dashboard: DashboardModel, options: HistoryListOpts) {
- const uid = dashboard && dashboard.uid ? dashboard.uid : void 0;
- return uid ? getBackendSrv().get(`api/dashboards/uid/${uid}/versions`, options) : Promise.resolve([]);
+ getHistoryList(dashboardUID: string, options: HistoryListOpts) {
+ if (typeof dashboardUID !== 'string') {
+ return Promise.resolve([]);
+ }
+
+ return getBackendSrv().get(`api/dashboards/uid/${dashboardUID}/versions`, options);
}
getDashboardVersion(uid: string, version: number) {