diff --git a/package.json b/package.json
index 471b32d6c3a..00d3511f27f 100644
--- a/package.json
+++ b/package.json
@@ -241,12 +241,14 @@
"debounce-promise": "3.1.2",
"emotion": "10.0.27",
"eventemitter3": "4.0.0",
+ "fast-json-patch": "2.2.1",
"fast-text-encoding": "^1.0.0",
"file-saver": "2.0.2",
"hoist-non-react-statics": "3.3.2",
"immutable": "3.8.2",
"is-hotkey": "0.1.6",
"jquery": "3.5.1",
+ "json-source-map": "0.6.1",
"jsurl": "^0.1.5",
"lodash": "4.17.21",
"lru-cache": "^5.1.1",
@@ -264,6 +266,7 @@
"re-resizable": "^6.2.0",
"react": "17.0.1",
"react-beautiful-dnd": "13.0.0",
+ "react-diff-viewer": "^3.1.1",
"react-dom": "17.0.1",
"react-grid-layout": "1.2.0",
"react-highlight-words": "0.16.0",
diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx
index 9fe0e5b0ebc..00788bbe65d 100644
--- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx
+++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx
@@ -5,14 +5,25 @@ import { within } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { historySrv } from '../VersionHistory/HistorySrv';
import { VersionsSettings, VERSIONS_FETCH_LIMIT } from './VersionsSettings';
-import { versions } from './__mocks__/versions';
+import { versions, diffs } from './__mocks__/versions';
jest.mock('../VersionHistory/HistorySrv');
+const queryByFullText = (text: string) =>
+ screen.queryByText((_, node: Element | undefined | null) => {
+ if (node) {
+ const nodeHasText = (node: HTMLElement | Element) => node.textContent?.includes(text);
+ const currentNodeHasText = nodeHasText(node);
+ const childrenDontHaveText = Array.from(node.children).every((child) => !nodeHasText(child));
+ return Boolean(currentNodeHasText && childrenDontHaveText);
+ }
+ return false;
+ });
+
describe('VersionSettings', () => {
const dashboard: any = {
id: 74,
- version: 7,
+ version: 11,
formatDate: jest.fn(() => 'date'),
getRelativeTime: jest.fn(() => 'time ago'),
};
@@ -115,8 +126,10 @@ describe('VersionSettings', () => {
test('selecting two versions and clicking compare button should render compare view', async () => {
// @ts-ignore
historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT));
- // @ts-ignore
- historySrv.calculateDiff.mockResolvedValue('
');
+ historySrv.getDashboardVersion
+ // @ts-ignore
+ .mockImplementationOnce(() => Promise.resolve(diffs.lhs))
+ .mockImplementationOnce(() => Promise.resolve(diffs.rhs));
render();
@@ -126,17 +139,39 @@ describe('VersionSettings', () => {
const compareButton = screen.getByRole('button', { name: /compare versions/i });
const tableBody = screen.getAllByRole('rowgroup')[1];
- userEvent.click(within(tableBody).getAllByRole('checkbox')[1]);
- userEvent.click(within(tableBody).getAllByRole('checkbox')[4]);
+ userEvent.click(within(tableBody).getAllByRole('checkbox')[0]);
+ userEvent.click(within(tableBody).getAllByRole('checkbox')[VERSIONS_FETCH_LIMIT - 1]);
expect(compareButton).toBeEnabled();
- userEvent.click(within(tableBody).getAllByRole('checkbox')[0]);
+ userEvent.click(within(tableBody).getAllByRole('checkbox')[1]);
expect(compareButton).toBeDisabled();
- // TODO: currently blows up due to angularLoader.load would be nice to assert the header...
- // userEvent.click(compareButton);
- // expect(historySrv.calculateDiff).toBeCalledTimes(1);
- // await waitFor(() => expect(screen.getByTestId('angular-history-comparison')).toBeInTheDocument());
+
+ userEvent.click(within(tableBody).getAllByRole('checkbox')[1]);
+ userEvent.click(compareButton);
+
+ await waitFor(() => expect(screen.getByRole('heading', { name: /versions comparing 2 11/i })).toBeInTheDocument());
+
+ expect(queryByFullText('Version 11 updated by admin')).toBeInTheDocument();
+ expect(queryByFullText('Version 2 updated by admin')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /restore to version 2/i })).toBeInTheDocument();
+ expect(screen.queryAllByTestId('diffGroup').length).toBe(5);
+
+ const diffGroups = screen.getAllByTestId('diffGroup');
+
+ expect(queryByFullText('description added The dashboard description')).toBeInTheDocument();
+ expect(queryByFullText('panels changed')).toBeInTheDocument();
+ expect(within(diffGroups[1]).queryByRole('list')).toBeInTheDocument();
+ expect(within(diffGroups[1]).queryByText(/added title/i)).toBeInTheDocument();
+ expect(within(diffGroups[1]).queryByText(/changed id/i)).toBeInTheDocument();
+ expect(queryByFullText('tags deleted item 0')).toBeInTheDocument();
+ expect(queryByFullText('timepicker added 1 refresh_intervals')).toBeInTheDocument();
+ expect(queryByFullText('version changed')).toBeInTheDocument();
+ expect(screen.queryByText(/view json diff/i)).toBeInTheDocument();
+
+ userEvent.click(screen.getByText(/view json diff/i));
+
+ await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument());
});
});
diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx
index 6d0b06ff4a4..f5702c2eb5a 100644
--- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx
+++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx
@@ -1,11 +1,15 @@
import React, { PureComponent } from 'react';
import { Spinner, HorizontalGroup } from '@grafana/ui';
import { DashboardModel } from '../../state/DashboardModel';
-import { historySrv, RevisionsModel, CalculateDiffOptions } from '../VersionHistory/HistorySrv';
-import { VersionHistoryTable } from '../VersionHistory/VersionHistoryTable';
-import { VersionHistoryHeader } from '../VersionHistory/VersionHistoryHeader';
-import { VersionsHistoryButtons } from '../VersionHistory/VersionHistoryButtons';
-import { VersionHistoryComparison } from '../VersionHistory/VersionHistoryComparison';
+import {
+ historySrv,
+ RevisionsModel,
+ VersionHistoryTable,
+ VersionHistoryHeader,
+ VersionsHistoryButtons,
+ VersionHistoryComparison,
+} from '../VersionHistory';
+
interface Props {
dashboard: DashboardModel;
}
@@ -15,7 +19,7 @@ type State = {
isAppending: boolean;
versions: DecoratedRevisionModel[];
viewMode: 'list' | 'compare';
- delta: { basic: string; json: string };
+ diffData: { lhs: any; rhs: any };
newInfo?: DecoratedRevisionModel;
baseInfo?: DecoratedRevisionModel;
isNewLatest: boolean;
@@ -24,7 +28,6 @@ type State = {
export type DecoratedRevisionModel = RevisionsModel & {
createdDateString: string;
ageString: string;
- checked: boolean;
};
export const VERSIONS_FETCH_LIMIT = 10;
@@ -38,15 +41,15 @@ export class VersionsSettings extends PureComponent {
this.limit = VERSIONS_FETCH_LIMIT;
this.start = 0;
this.state = {
- delta: {
- basic: '',
- json: '',
- },
isAppending: true,
isLoading: true,
versions: [],
viewMode: 'list',
isNewLatest: false,
+ diffData: {
+ lhs: {},
+ rhs: {},
+ },
};
}
@@ -69,51 +72,29 @@ export class VersionsSettings extends PureComponent {
.finally(() => this.setState({ isAppending: false }));
};
- getDiff = (diff: string) => {
+ getDiff = async () => {
const selectedVersions = this.state.versions.filter((version) => version.checked);
const [newInfo, baseInfo] = selectedVersions;
const isNewLatest = newInfo.version === this.props.dashboard.version;
this.setState({
- baseInfo,
isLoading: true,
+ });
+
+ const lhs = await historySrv.getDashboardVersion(this.props.dashboard.id, baseInfo.version);
+ const rhs = await historySrv.getDashboardVersion(this.props.dashboard.id, newInfo.version);
+
+ this.setState({
+ baseInfo,
+ isLoading: false,
isNewLatest,
newInfo,
viewMode: 'compare',
+ diffData: {
+ lhs: lhs.data,
+ rhs: rhs.data,
+ },
});
-
- const options: CalculateDiffOptions = {
- new: {
- dashboardId: this.props.dashboard.id,
- version: newInfo.version,
- },
- base: {
- dashboardId: this.props.dashboard.id,
- version: baseInfo.version,
- },
- diffType: diff,
- };
-
- return historySrv
- .calculateDiff(options)
- .then((response: any) => {
- this.setState({
- // @ts-ignore
- delta: {
- [diff]: response,
- },
- });
- })
- .catch(() => {
- this.setState({
- viewMode: 'list',
- });
- })
- .finally(() => {
- this.setState({
- isLoading: false,
- });
- });
};
decorateVersions = (versions: RevisionsModel[]) =>
@@ -139,7 +120,10 @@ export class VersionsSettings extends PureComponent {
reset = () => {
this.setState({
baseInfo: undefined,
- delta: { basic: '', json: '' },
+ diffData: {
+ lhs: {},
+ rhs: {},
+ },
isNewLatest: false,
newInfo: undefined,
versions: this.state.versions.map((version) => ({ ...version, checked: false })),
@@ -148,7 +132,7 @@ export class VersionsSettings extends PureComponent {
};
render() {
- const { versions, viewMode, baseInfo, newInfo, isNewLatest, isLoading, delta } = this.state;
+ const { versions, viewMode, baseInfo, newInfo, isNewLatest, isLoading, diffData } = this.state;
const canCompare = versions.filter((version) => version.checked).length !== 2;
const showButtons = versions.length > 1;
const hasMore = versions.length >= this.limit;
@@ -167,12 +151,10 @@ export class VersionsSettings extends PureComponent {
) : (
)}
diff --git a/public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts b/public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts
index cfe1a8cb684..fc3e141fe32 100644
--- a/public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts
+++ b/public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts
@@ -7,7 +7,7 @@ export const versions = [
version: 11,
created: '2021-01-15T14:44:44+01:00',
createdBy: 'admin',
- message: 'Another day another change...',
+ message: 'testing changes...',
},
{
id: 247,
@@ -110,3 +110,96 @@ export const versions = [
message: '',
},
];
+
+export const diffs = {
+ lhs: {
+ data: {
+ 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: [
+ {
+ type: 'graph',
+ id: 4,
+ },
+ ],
+ schemaVersion: 27,
+ style: 'dark',
+ tags: ['the tag'],
+ templating: {
+ list: [],
+ },
+ time: {
+ from: 'now-6h',
+ to: 'now',
+ },
+ timepicker: {},
+ timezone: '',
+ title: 'test dashboard',
+ uid: '_U4zObQMz',
+ version: 2,
+ },
+ },
+ rhs: {
+ data: {
+ annotations: {
+ list: [
+ {
+ builtIn: 1,
+ datasource: '-- Grafana --',
+ enable: true,
+ hide: true,
+ iconColor: 'rgba(0, 211, 255, 1)',
+ name: 'Annotations & Alerts',
+ type: 'dashboard',
+ },
+ ],
+ },
+ description: 'The dashboard description',
+ editable: true,
+ gnetId: null,
+ graphTooltip: 0,
+ id: 141,
+ links: [],
+ panels: [
+ {
+ type: 'graph',
+ title: 'panel title',
+ id: 6,
+ },
+ ],
+ schemaVersion: 27,
+ style: 'dark',
+ tags: [],
+ templating: {
+ list: [],
+ },
+ time: {
+ from: 'now-6h',
+ to: 'now',
+ },
+ timepicker: {
+ refresh_intervals: ['5s'],
+ },
+ timezone: '',
+ title: 'test dashboard',
+ uid: '_U4zObQMz',
+ version: 11,
+ },
+ },
+};
diff --git a/public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx b/public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx
new file mode 100644
index 00000000000..e2bc653e906
--- /dev/null
+++ b/public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import _ from 'lodash';
+import { useStyles } from '@grafana/ui';
+import { GrafanaTheme } from '@grafana/data';
+import { css } from 'emotion';
+import { DiffTitle } from './DiffTitle';
+import { DiffValues } from './DiffValues';
+import { Diff, getDiffText } from './utils';
+
+type DiffGroupProps = {
+ diffs: Diff[];
+ title: string;
+};
+
+export const DiffGroup: React.FC = ({ diffs, title }) => {
+ const styles = useStyles(getStyles);
+
+ if (diffs.length === 1) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {diffs.map((diff: Diff, idx: number) => {
+ return (
+ -
+ {getDiffText(diff)}
+
+ );
+ })}
+
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme) => ({
+ container: css`
+ background-color: ${theme.colors.bg2};
+ font-size: ${theme.typography.size.md};
+ margin-bottom: ${theme.spacing.md};
+ padding: ${theme.spacing.md};
+ `,
+ list: css`
+ margin-left: ${theme.spacing.xl};
+ `,
+ listItem: css`
+ margin-bottom: ${theme.spacing.sm};
+ `,
+});
diff --git a/public/app/features/dashboard/components/VersionHistory/DiffTitle.tsx b/public/app/features/dashboard/components/VersionHistory/DiffTitle.tsx
new file mode 100644
index 00000000000..21a8ea80445
--- /dev/null
+++ b/public/app/features/dashboard/components/VersionHistory/DiffTitle.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import { useStyles, Icon } from '@grafana/ui';
+import { GrafanaTheme } from '@grafana/data';
+import { css } from 'emotion';
+import { Diff, getDiffText } from './utils';
+import { DiffValues } from './DiffValues';
+
+type DiffTitleProps = {
+ diff?: Diff;
+ title: string;
+};
+
+const replaceDiff: Diff = { op: 'replace', originalValue: undefined, path: [''], value: undefined, startLineNumber: 0 };
+
+export const DiffTitle: React.FC = ({ diff, title }) => {
+ const styles = useStyles(getDiffTitleStyles);
+ return diff ? (
+ <>
+ {title}{' '}
+ {getDiffText(diff, diff.path.length > 1)}
+ >
+ ) : (
+
+ {title}{' '}
+ {getDiffText(replaceDiff, false)}
+
+ );
+};
+
+const getDiffTitleStyles = (theme: GrafanaTheme) => ({
+ embolden: css`
+ font-weight: ${theme.typography.weight.bold};
+ `,
+ add: css`
+ color: ${theme.palette.online};
+ `,
+ replace: css`
+ color: ${theme.palette.warn};
+ `,
+ move: css`
+ color: ${theme.palette.warn};
+ `,
+ copy: css`
+ color: ${theme.palette.warn};
+ `,
+ _get: css`
+ color: ${theme.palette.warn};
+ `,
+ test: css`
+ color: ${theme.palette.warn};
+ `,
+ remove: css`
+ color: ${theme.palette.critical};
+ `,
+ withoutDiff: css`
+ margin-bottom: ${theme.spacing.md};
+ `,
+});
diff --git a/public/app/features/dashboard/components/VersionHistory/DiffValues.tsx b/public/app/features/dashboard/components/VersionHistory/DiffValues.tsx
new file mode 100644
index 00000000000..69bc204421d
--- /dev/null
+++ b/public/app/features/dashboard/components/VersionHistory/DiffValues.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import _ from 'lodash';
+import { useStyles, Icon } from '@grafana/ui';
+import { GrafanaTheme } from '@grafana/data';
+import { css } from 'emotion';
+import { Diff } from './utils';
+
+type DiffProps = {
+ diff: Diff;
+};
+
+export const DiffValues: React.FC = ({ diff }) => {
+ const styles = useStyles(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: GrafanaTheme) => css`
+ background-color: ${theme.colors.bg3};
+ border-radius: ${theme.border.radius.md};
+ color: ${theme.colors.textHeading};
+ font-size: ${theme.typography.size.base};
+ margin: 0 ${theme.spacing.xs};
+ padding: ${theme.spacing.xs} ${theme.spacing.sm};
+`;
diff --git a/public/app/features/dashboard/components/VersionHistory/DiffViewer.tsx b/public/app/features/dashboard/components/VersionHistory/DiffViewer.tsx
new file mode 100644
index 00000000000..41b5620a947
--- /dev/null
+++ b/public/app/features/dashboard/components/VersionHistory/DiffViewer.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import { css } from 'emotion';
+import ReactDiffViewer, { ReactDiffViewerProps, DiffMethod } from 'react-diff-viewer';
+import { useTheme } from '@grafana/ui';
+import tinycolor from 'tinycolor2';
+
+export const DiffViewer: React.FC = ({ oldValue, newValue }) => {
+ const theme = useTheme();
+
+ const styles = {
+ variables: {
+ // the light theme supplied by ReactDiffViewer is very similar to grafana
+ // the dark theme needs some tweaks.
+ dark: {
+ diffViewerBackground: theme.colors.dashboardBg,
+ diffViewerColor: theme.colors.text,
+ addedBackground: tinycolor(theme.palette.greenShade).setAlpha(0.3).toString(),
+ addedColor: 'white',
+ removedBackground: tinycolor(theme.palette.redShade).setAlpha(0.3).toString(),
+ removedColor: 'white',
+ wordAddedBackground: tinycolor(theme.palette.greenBase).setAlpha(0.4).toString(),
+ wordRemovedBackground: tinycolor(theme.palette.redBase).setAlpha(0.4).toString(),
+ addedGutterBackground: tinycolor(theme.palette.greenShade).setAlpha(0.2).toString(),
+ removedGutterBackground: tinycolor(theme.palette.redShade).setAlpha(0.2).toString(),
+ gutterBackground: theme.colors.bg1,
+ gutterBackgroundDark: theme.colors.bg1,
+ highlightBackground: tinycolor(theme.colors.bgBlue1).setAlpha(0.4).toString(),
+ highlightGutterBackground: tinycolor(theme.colors.bgBlue2).setAlpha(0.2).toString(),
+ codeFoldGutterBackground: theme.colors.bg2,
+ codeFoldBackground: theme.colors.bg2,
+ emptyLineBackground: theme.colors.bg2,
+ gutterColor: theme.colors.textFaint,
+ addedGutterColor: theme.colors.text,
+ removedGutterColor: theme.colors.text,
+ codeFoldContentColor: theme.colors.textFaint,
+ diffViewerTitleBackground: theme.colors.bg2,
+ diffViewerTitleColor: theme.colors.textFaint,
+ diffViewerTitleBorderColor: theme.colors.border3,
+ },
+ },
+ codeFold: {
+ fontSize: theme.typography.size.sm,
+ },
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts
deleted file mode 100644
index 16117b4ea42..00000000000
--- a/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-import { IScope } from 'angular';
-
-import { HistoryListCtrl } from './HistoryListCtrl';
-import { compare, restore, versions } from './__mocks__/dashboardHistoryMocks';
-import { appEvents } from '../../../../core/core';
-
-jest.mock('app/core/core', () => ({
- appEvents: { publish: jest.fn() },
-}));
-
-describe('HistoryListCtrl', () => {
- const RESTORE_ID = 4;
-
- const versionsResponse: any = versions();
-
- restore(7, RESTORE_ID);
-
- let historySrv: any;
- let $rootScope: any;
- const $scope: IScope = ({ $evalAsync: jest.fn() } as any) as IScope;
- let historyListCtrl: any;
- beforeEach(() => {
- historySrv = {
- calculateDiff: jest.fn(),
- restoreDashboard: jest.fn(() => Promise.resolve({})),
- };
- $rootScope = {
- appEvent: jest.fn(),
- onAppEvent: jest.fn(),
- };
- });
-
- describe('when the history list component is loaded', () => {
- beforeEach(async () => {
- historySrv.getHistoryList = jest.fn(() => Promise.resolve(versionsResponse));
- historyListCtrl = new HistoryListCtrl({}, $rootScope, {} as any, historySrv, $scope);
-
- historyListCtrl.dashboard = {
- id: 2,
- version: 3,
- formatDate: jest.fn(() => 'date'),
- getRelativeTime: jest.fn(() => 'time ago'),
- };
- historySrv.calculateDiff = jest.fn(() => Promise.resolve(compare('basic')));
- historyListCtrl.delta = {
- basic: '',
- json: '',
- };
- historyListCtrl.baseInfo = { version: 1 };
- historyListCtrl.newInfo = { version: 2 };
- historyListCtrl.isNewLatest = false;
- });
-
- it('should have basic diff state', () => {
- expect(historyListCtrl.delta.basic).toBe('');
- expect(historyListCtrl.delta.json).toBe('');
- expect(historyListCtrl.diff).toBe('basic');
- });
-
- it('should indicate loading has finished', () => {
- expect(historyListCtrl.loading).toBe(false);
- });
-
- describe('and the json diff is successfully fetched', () => {
- beforeEach(async () => {
- historySrv.calculateDiff = jest.fn(() => Promise.resolve(compare('json')));
- await historyListCtrl.getDiff('json');
- });
-
- it('should fetch the json diff', () => {
- expect(historySrv.calculateDiff).toHaveBeenCalledTimes(1);
- expect(historyListCtrl.delta.json).toBe('
');
- });
-
- it('should set the json diff view as active', () => {
- expect(historyListCtrl.diff).toBe('json');
- });
-
- it('should indicate loading has finished', () => {
- expect(historyListCtrl.loading).toBe(false);
- });
- });
-
- describe('and diffs have already been fetched', () => {
- beforeEach(async () => {
- historySrv.calculateDiff = jest.fn(() => Promise.resolve(compare('basic')));
- historyListCtrl.delta.basic = 'cached basic';
- historyListCtrl.getDiff('basic');
- await historySrv.calculateDiff();
- });
-
- it('should use the cached diffs instead of fetching', () => {
- expect(historySrv.calculateDiff).toHaveBeenCalledTimes(1);
- expect(historyListCtrl.delta.basic).toBe('cached basic');
- });
-
- it('should indicate loading has finished', () => {
- expect(historyListCtrl.loading).toBe(false);
- });
- });
-
- describe('and fetching the diff fails', () => {
- beforeEach(async () => {
- historySrv.calculateDiff = jest.fn(() => Promise.reject());
- historyListCtrl.onFetchFail = jest.fn();
- historyListCtrl.delta = {
- basic: '',
- json: '',
- };
- await historyListCtrl.getDiff('json');
- });
-
- it('should call calculateDiff', () => {
- expect(historySrv.calculateDiff).toHaveBeenCalledTimes(1);
- });
-
- it('should call onFetchFail', () => {
- expect(historyListCtrl.onFetchFail).toBeCalledTimes(1);
- });
-
- it('should indicate loading has finished', () => {
- expect(historyListCtrl.loading).toBe(false);
- });
-
- it('should have a default delta/changeset', () => {
- expect(historyListCtrl.delta).toEqual({ basic: '', json: '' });
- });
- });
- });
-
- describe('when the user wants to restore a revision', () => {
- beforeEach(async () => {
- historySrv.getHistoryList = jest.fn(() => Promise.resolve(versionsResponse));
- historySrv.restoreDashboard = jest.fn(() => Promise.resolve());
-
- historyListCtrl = new HistoryListCtrl({}, $rootScope, {} as any, historySrv, $scope);
-
- historyListCtrl.dashboard = {
- id: 1,
- };
- historyListCtrl.restore();
- historySrv.restoreDashboard = jest.fn(() => Promise.resolve(versionsResponse));
- });
-
- it('should display a modal allowing the user to restore or cancel', () => {
- expect(appEvents.publish).toHaveBeenCalledTimes(1);
- expect(appEvents.publish).toHaveBeenCalledWith(expect.objectContaining({ type: 'show-confirm-modal' }));
- });
-
- describe('and restore fails to fetch', () => {
- beforeEach(async () => {
- historySrv.getHistoryList = jest.fn(() => Promise.resolve(versionsResponse));
- historySrv.restoreDashboard = jest.fn(() => Promise.resolve());
- historyListCtrl = new HistoryListCtrl({}, $rootScope, {} as any, historySrv, $scope);
- historySrv.restoreDashboard = jest.fn(() => Promise.reject(new Error('RestoreError')));
- historyListCtrl.restoreConfirm(RESTORE_ID);
- });
-
- it('should indicate loading has finished', () => {
- expect(historyListCtrl.loading).toBe(false);
- });
- });
- });
-});
diff --git a/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts
deleted file mode 100644
index 70480194743..00000000000
--- a/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import angular, { ILocationService, IScope } from 'angular';
-
-import { DashboardModel } from '../../state/DashboardModel';
-import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings';
-import { CalculateDiffOptions, HistorySrv } from './HistorySrv';
-import { AppEvents, locationUtil } from '@grafana/data';
-import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
-import { promiseToDigest } from '../../../../core/utils/promiseToDigest';
-import { ShowConfirmModalEvent } from '../../../../types/events';
-import { appEvents } from 'app/core/core';
-
-export class HistoryListCtrl {
- dashboard: DashboardModel;
- delta: { basic: string; json: string };
- diff: string;
- loading: boolean;
- newInfo: DecoratedRevisionModel;
- baseInfo: DecoratedRevisionModel;
- isNewLatest: boolean;
- onFetchFail: () => void;
-
- /** @ngInject */
- constructor(
- private $route: any,
- private $rootScope: GrafanaRootScope,
- private $location: ILocationService,
- private historySrv: HistorySrv,
- public $scope: IScope
- ) {
- this.diff = 'basic';
- this.loading = false;
- }
- getDiff(diff: 'basic' | 'json') {
- this.diff = diff;
-
- // has it already been fetched?
- if (this.delta[diff]) {
- return Promise.resolve(this.delta[diff]);
- }
-
- this.loading = true;
- const options: CalculateDiffOptions = {
- new: {
- dashboardId: this.dashboard.id,
- version: this.newInfo.version,
- },
- base: {
- dashboardId: this.dashboard.id,
- version: this.baseInfo.version,
- },
- diffType: diff,
- };
-
- return promiseToDigest(this.$scope)(
- this.historySrv
- .calculateDiff(options)
- .then((response: any) => {
- // @ts-ignore
- this.delta[this.diff] = response;
- })
- .catch(this.onFetchFail)
- .finally(() => {
- this.loading = false;
- })
- );
- }
-
- restore(version: number) {
- appEvents.publish(
- new ShowConfirmModalEvent({
- title: 'Restore version',
- text: '',
- text2: `Are you sure you want to restore the dashboard to version ${version}? All unsaved changes will be lost.`,
- icon: 'history',
- yesText: `Yes, restore to version ${version}`,
- onConfirm: this.restoreConfirm.bind(this, version),
- })
- );
- }
-
- restoreConfirm(version: number) {
- this.loading = true;
- return promiseToDigest(this.$scope)(
- this.historySrv
- .restoreDashboard(this.dashboard, version)
- .then((response: any) => {
- this.$location.url(locationUtil.stripBaseFromUrl(response.url)).replace();
- this.$route.reload();
- this.$rootScope.appEvent(AppEvents.alertSuccess, ['Dashboard restored', 'Restored from version ' + version]);
- })
- .catch(() => {
- this.loading = false;
- })
- );
- }
-}
-
-export function dashboardHistoryDirective() {
- return {
- restrict: 'E',
- templateUrl: 'public/app/features/dashboard/components/VersionHistory/template.html',
- controller: HistoryListCtrl,
- bindToController: true,
- controllerAs: 'ctrl',
- scope: {
- dashboard: '=',
- delta: '=',
- baseInfo: '=baseinfo',
- newInfo: '=newinfo',
- isNewLatest: '=isnewlatest',
- onFetchFail: '=onfetchfail',
- },
- };
-}
-
-angular.module('grafana.directives').directive('gfDashboardHistory', dashboardHistoryDirective);
diff --git a/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts
index 8124f20b4a5..fbfd06d1155 100644
--- a/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts
+++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts
@@ -19,12 +19,6 @@ export interface RevisionsModel {
message: string;
}
-export interface CalculateDiffOptions {
- new: DiffTarget;
- base: DiffTarget;
- diffType: string;
-}
-
export interface DiffTarget {
dashboardId: number;
version: number;
@@ -37,8 +31,8 @@ export class HistorySrv {
return id ? getBackendSrv().get(`api/dashboards/id/${id}/versions`, options) : Promise.resolve([]);
}
- calculateDiff(options: CalculateDiffOptions) {
- return getBackendSrv().post('api/dashboards/calculate-diff', options);
+ getDashboardVersion(id: number, version: number) {
+ return getBackendSrv().get(`api/dashboards/id/${id}/versions/${version}`);
}
restoreDashboard(dashboard: DashboardModel, version: number) {
diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryButtons.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryButtons.tsx
index fcb23f9d29e..5923ba75b10 100644
--- a/public/app/features/dashboard/components/VersionHistory/VersionHistoryButtons.tsx
+++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryButtons.tsx
@@ -5,7 +5,7 @@ type VersionsButtonsType = {
hasMore: boolean;
canCompare: boolean;
getVersions: (append: boolean) => void;
- getDiff: (diff: string) => void;
+ getDiff: () => void;
isLastPage: boolean;
};
export const VersionsHistoryButtons: React.FC = ({
@@ -22,7 +22,7 @@ export const VersionsHistoryButtons: React.FC = ({
)}
-
diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx
index 0639303b3d7..1c37639d903 100644
--- a/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx
+++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx
@@ -1,47 +1,80 @@
-import React, { PureComponent } from 'react';
-import { AngularComponent, getAngularLoader } from '@grafana/runtime';
-import { DashboardModel } from '../../state/DashboardModel';
+import React from 'react';
+import { css, cx } from 'emotion';
+
+import { Button, ModalsController, CollapsableSection, HorizontalGroup, useStyles } from '@grafana/ui';
import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings';
+import { RevertDashboardModal } from './RevertDashboardModal';
+import { DiffGroup } from './DiffGroup';
+import { DiffViewer } from './DiffViewer';
+import { jsonDiff } from './utils';
+import { GrafanaTheme } from '@grafana/data';
type DiffViewProps = {
- dashboard: DashboardModel;
isNewLatest: boolean;
- newInfo?: DecoratedRevisionModel;
- baseInfo?: DecoratedRevisionModel;
- delta: { basic: string; json: string };
- onFetchFail: () => void;
+ newInfo: DecoratedRevisionModel;
+ baseInfo: DecoratedRevisionModel;
+ diffData: { lhs: any; rhs: any };
};
-export class VersionHistoryComparison extends PureComponent {
- element?: HTMLElement | null;
- angularCmp?: AngularComponent;
+export const VersionHistoryComparison: React.FC = ({ baseInfo, newInfo, diffData, isNewLatest }) => {
+ const diff = jsonDiff(diffData.lhs, diffData.rhs);
+ const styles = useStyles(getStyles);
- constructor(props: DiffViewProps) {
- super(props);
- }
+ 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 }) => (
+ {
+ showModal(RevertDashboardModal, {
+ version: baseInfo.version,
+ hideModal,
+ });
+ }}
+ >
+ Restore to version {baseInfo.version}
+
+ )}
+
+ )}
+
+
+
+ {Object.entries(diff).map(([key, diffs]) => (
+
+ ))}
+
+
+
+
+
+ );
+};
- componentDidMount() {
- const loader = getAngularLoader();
- const template =
- '';
- const scopeProps = {
- dashboard: this.props.dashboard,
- delta: this.props.delta,
- baseinfo: this.props.baseInfo,
- newinfo: this.props.newInfo,
- isnewlatest: this.props.isNewLatest,
- onfetchfail: this.props.onFetchFail,
- };
- this.angularCmp = loader.load(this.element, scopeProps, template);
- }
-
- componentWillUnmount() {
- if (this.angularCmp) {
- this.angularCmp.destroy();
- }
- }
-
- render() {
- return (this.element = ref)} />;
- }
-}
+const getStyles = (theme: GrafanaTheme) => ({
+ spacer: css`
+ margin-bottom: ${theme.spacing.xl};
+ `,
+ versionInfo: css`
+ color: ${theme.colors.textWeak};
+ font-size: ${theme.typography.size.sm};
+ `,
+ noMarginBottom: css`
+ margin-bottom: 0;
+ `,
+});
diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx
index d6d77801530..f25744410de 100644
--- a/public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx
+++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx
@@ -1,6 +1,8 @@
import React from 'react';
+import { css } from 'emotion';
import noop from 'lodash/noop';
-import { Icon } from '@grafana/ui';
+import { GrafanaTheme } from '@grafana/data';
+import { Icon, useStyles } from '@grafana/ui';
type VersionHistoryHeaderProps = {
isComparing?: boolean;
@@ -16,16 +18,27 @@ export const VersionHistoryHeader: React.FC
= ({
baseVersion = 0,
newVersion = 0,
isNewLatest = false,
-}) => (
-
-
- Versions
-
- {isComparing && (
-
- Comparing {baseVersion} {newVersion}{' '}
- {isNewLatest && (Latest)}
+}) => {
+ const styles = useStyles(getStyles);
+
+ return (
+
+
+ Versions
- )}
-
-);
+ {isComparing && (
+
+ Comparing {baseVersion} {newVersion}{' '}
+ {isNewLatest && (Latest)}
+
+ )}
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme) => ({
+ header: css`
+ font-size: ${theme.typography.heading.h3};
+ margin-bottom: ${theme.spacing.lg};
+ `,
+});
diff --git a/public/app/features/dashboard/components/VersionHistory/index.ts b/public/app/features/dashboard/components/VersionHistory/index.ts
index 138de434bf3..c87d2d0b9b7 100644
--- a/public/app/features/dashboard/components/VersionHistory/index.ts
+++ b/public/app/features/dashboard/components/VersionHistory/index.ts
@@ -1,2 +1,5 @@
-export { HistoryListCtrl } from './HistoryListCtrl';
-export { HistorySrv } from './HistorySrv';
+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/components/VersionHistory/template.html b/public/app/features/dashboard/components/VersionHistory/template.html
deleted file mode 100644
index 444ab0811ae..00000000000
--- a/public/app/features/dashboard/components/VersionHistory/template.html
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
- Fetching changes…
-
-
-
-
- Restore to version {{ctrl.baseInfo.version}}
-
-
-
- Version {{ctrl.newInfo.version}} updated by
- {{ctrl.newInfo.createdBy}}
- {{ctrl.newInfo.ageString}}
- - {{ctrl.newInfo.message}}
-
-
- Version {{ctrl.baseInfo.version}} updated by
- {{ctrl.baseInfo.createdBy}}
- {{ctrl.baseInfo.ageString}}
- - {{ctrl.baseInfo.message}}
-
-
-
-
-
-
- View JSON Diff
-
-
-
-
diff --git a/public/app/features/dashboard/components/VersionHistory/utils.test.ts b/public/app/features/dashboard/components/VersionHistory/utils.test.ts
new file mode 100644
index 00000000000..a0eb0979584
--- /dev/null
+++ b/public/app/features/dashboard/components/VersionHistory/utils.test.ts
@@ -0,0 +1,291 @@
+import { getDiffText, getDiffOperationText, jsonDiff, Diff } 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);
+ });
+});
+
+describe('getDiffText', () => {
+ const addEmptyArray = [{ op: 'add', value: [], path: ['annotations', 'list'], startLineNumber: 24 }, 'added list'];
+ const addArrayNumericProp = [
+ {
+ op: 'add',
+ value: ['tag'],
+ path: ['panels', '3'],
+ },
+ 'added item 3',
+ ];
+ const addArrayProp = [
+ {
+ op: 'add',
+ value: [{ name: 'dummy target 1' }, { name: 'dummy target 2' }],
+ path: ['panels', '3', 'targets'],
+ },
+ 'added 2 targets',
+ ];
+ const addValueNumericProp = [
+ {
+ op: 'add',
+ value: 'foo',
+ path: ['panels', '3'],
+ },
+ 'added item 3',
+ ];
+ const addValueProp = [
+ {
+ op: 'add',
+ value: 'foo',
+ path: ['panels', '3', 'targets'],
+ },
+ 'added targets',
+ ];
+
+ const removeEmptyArray = [
+ { op: 'remove', originalValue: [], path: ['annotations', 'list'], startLineNumber: 24 },
+ 'deleted list',
+ ];
+ const removeArrayNumericProp = [
+ {
+ op: 'remove',
+ originalValue: ['tag'],
+ path: ['panels', '3'],
+ },
+ 'deleted item 3',
+ ];
+ const removeArrayProp = [
+ {
+ op: 'remove',
+ originalValue: [{ name: 'dummy target 1' }, { name: 'dummy target 2' }],
+ path: ['panels', '3', 'targets'],
+ },
+ 'deleted 2 targets',
+ ];
+ const removeValueNumericProp = [
+ {
+ op: 'remove',
+ originalValue: 'foo',
+ path: ['panels', '3'],
+ },
+ 'deleted item 3',
+ ];
+ const removeValueProp = [
+ {
+ op: 'remove',
+ originalValue: 'foo',
+ path: ['panels', '3', 'targets'],
+ },
+ 'deleted targets',
+ ];
+ const replaceValueNumericProp = [
+ {
+ op: 'replace',
+ originalValue: 'foo',
+ value: 'bar',
+ path: ['panels', '3'],
+ },
+ 'changed item 3',
+ ];
+ const replaceValueProp = [
+ {
+ 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: Diff, expected: string) => {
+ expect(getDiffText(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,
+ style: 'dark',
+ 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,
+ style: 'dark',
+ 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: 28,
+ value: 'the tag',
+ },
+ ],
+ timepicker: [
+ {
+ op: 'add',
+ originalValue: undefined,
+ path: ['timepicker', 'refresh_intervals'],
+ startLineNumber: 38,
+ value: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'],
+ },
+ ],
+ timezone: [
+ {
+ op: 'replace',
+ originalValue: '',
+ path: ['timezone'],
+ startLineNumber: 52,
+ value: 'utc',
+ },
+ ],
+ title: [
+ {
+ op: 'replace',
+ originalValue: 'test dashboard',
+ path: ['title'],
+ startLineNumber: 53,
+ value: 'My favourite dashboard',
+ },
+ ],
+ version: [
+ {
+ op: 'replace',
+ originalValue: 2,
+ path: ['version'],
+ startLineNumber: 55,
+ value: 3,
+ },
+ ],
+ };
+
+ expect(jsonDiff(lhs, rhs)).toStrictEqual(expected);
+ });
+});
diff --git a/public/app/features/dashboard/components/VersionHistory/utils.ts b/public/app/features/dashboard/components/VersionHistory/utils.ts
new file mode 100644
index 00000000000..90b9e086c09
--- /dev/null
+++ b/public/app/features/dashboard/components/VersionHistory/utils.ts
@@ -0,0 +1,100 @@
+import { compare, Operation } from 'fast-json-patch';
+// @ts-ignore
+import jsonMap from 'json-source-map';
+import _ from 'lodash';
+
+export type Diff = {
+ op: 'add' | 'replace' | 'remove' | 'copy' | 'test' | '_get' | 'move';
+ value: any;
+ originalValue: any;
+ path: string[];
+ startLineNumber: number;
+};
+
+export type Diffs = {
+ [key: string]: Diff[];
+};
+
+export const jsonDiff = (lhs: any, rhs: any): 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') {
+ originalValue = _.get(lhs, path);
+ value = diff.value;
+ startLineNumber = rhsMap.pointers[diff.path].value.line;
+ }
+ if (diff.op === 'add') {
+ value = diff.value;
+ startLineNumber = rhsMap.pointers[diff.path].value.line;
+ }
+ if (diff.op === 'remove') {
+ 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/yarn.lock b/yarn.lock
index ef29ba3e8d2..e896321606c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10737,7 +10737,7 @@ create-ecdh@^4.0.0:
bn.js "^4.1.0"
elliptic "^6.0.0"
-create-emotion@^10.0.27:
+create-emotion@^10.0.14, create-emotion@^10.0.27:
version "10.0.27"
resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-10.0.27.tgz#cb4fa2db750f6ca6f9a001a33fbf1f6c46789503"
integrity sha512-fIK73w82HPPn/RsAij7+Zt8eCE8SptcJ3WoRMfxMtjteYxud8GDTKKld7MYwAX2TVhrw29uR1N/bVGxeStHILg==
@@ -12309,7 +12309,7 @@ emotion-theming@^10.0.19:
"@emotion/weak-memoize" "0.2.5"
hoist-non-react-statics "^3.3.0"
-emotion@10.0.27, emotion@^10.0.27:
+emotion@10.0.27, emotion@^10.0.14, emotion@^10.0.27:
version "10.0.27"
resolved "https://registry.yarnpkg.com/emotion/-/emotion-10.0.27.tgz#f9ca5df98630980a23c819a56262560562e5d75e"
integrity sha512-2xdDzdWWzue8R8lu4G76uWX5WhyQuzATon9LmNeCy/2BHVC6dsEpfhN1a0qhELgtDVdjyEA6J8Y/VlI5ZnaH0g==
@@ -13477,6 +13477,13 @@ fast-json-parse@^1.0.3:
resolved "https://registry.yarnpkg.com/fast-json-parse/-/fast-json-parse-1.0.3.tgz#43e5c61ee4efa9265633046b770fb682a7577c4d"
integrity sha512-FRWsaZRWEJ1ESVNbDWmsAlqDk96gPQezzLghafp5J4GUKjbCz3OkAHuZs5TuPEtkbVQERysLp9xv6c24fBm8Aw==
+fast-json-patch@2.2.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/fast-json-patch/-/fast-json-patch-2.2.1.tgz#18150d36c9ab65c7209e7d4eb113f4f8eaabe6d9"
+ integrity sha512-4j5uBaTnsYAV5ebkidvxiLUYOwjQ+JSFljeqfTxCrH9bDmlCQaOJFS84oDJ2rAXZq2yskmk3ORfoP9DCwqFNig==
+ dependencies:
+ fast-deep-equal "^2.0.1"
+
fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"
@@ -17100,6 +17107,11 @@ json-schema@0.2.3:
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
+json-source-map@0.6.1:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/json-source-map/-/json-source-map-0.6.1.tgz#e0b1f6f4ce13a9ad57e2ae165a24d06e62c79a0f"
+ integrity sha512-1QoztHPsMQqhDq0hlXY5ZqcEdUzxQEIxgFkKl4WUp2pgShObl+9ovi4kRh2TfvAfxAoHOJ9vIMEqk3k4iex7tg==
+
json-stable-stringify-without-jsonify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
@@ -18098,7 +18110,7 @@ memfs@^3.1.2:
dependencies:
fs-monkey "1.0.1"
-memoize-one@5.1.1, "memoize-one@>=3.1.1 <6", memoize-one@^5.0.0, memoize-one@^5.1.1:
+memoize-one@5.1.1, "memoize-one@>=3.1.1 <6", memoize-one@^5.0.0, memoize-one@^5.0.4, memoize-one@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==
@@ -21732,6 +21744,18 @@ react-dev-utils@^10.0.0, react-dev-utils@^10.2.1:
strip-ansi "6.0.0"
text-table "0.2.0"
+react-diff-viewer@^3.1.1:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/react-diff-viewer/-/react-diff-viewer-3.1.1.tgz#21ac9c891193d05a3734bfd6bd54b107ee6d46cc"
+ integrity sha512-rmvwNdcClp6ZWdS11m1m01UnBA4OwYaLG/li0dB781e/bQEzsGyj+qewVd6W5ztBwseQ72pO7nwaCcq5jnlzcw==
+ dependencies:
+ classnames "^2.2.6"
+ create-emotion "^10.0.14"
+ diff "^4.0.1"
+ emotion "^10.0.14"
+ memoize-one "^5.0.4"
+ prop-types "^15.6.2"
+
react-docgen-typescript-loader@3.7.2:
version "3.7.2"
resolved "https://registry.yarnpkg.com/react-docgen-typescript-loader/-/react-docgen-typescript-loader-3.7.2.tgz#45cb2305652c0602767242a8700ad1ebd66bbbbd"