DashboardSettings: Migrates history diff view from angular to react (#31997)

* chore(dashboardsettings): introduce deep-diff for diffing dashboard models

* feat(dashboardsettings): initial commit of diff comparision react migration

* feat(dashboardsettings): wip - use json-source-map and monaco editor

* chore(deps): add react-diff-viewer to package.json

* feat(dashboardsettings): take the simplistic road for diff view

* refactor(dashboardsettings): clean up Version Settings components

* chore: delete angular historyListCtrl code

* refactor(dashboardsettings): styling fixes

* Small color tweaks

* refactor(versionhistory): fix issues around summary text. write tests

* test(versionhistory): add test for jsonDiff

* refactor(versionhistory): cleanup utils and reduce dom elements

* test(versionsettings): add tests for comparision view

* test(versionsettings): finialise tests for version history comparison view

* test(versionsettings): remove debug

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Jack Westbrook
2021-03-25 11:51:09 +01:00
committed by GitHub
parent 405f54565a
commit c9eff1892e
19 changed files with 916 additions and 451 deletions

View File

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

View File

@@ -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('<div></div>');
historySrv.getDashboardVersion
// @ts-ignore
.mockImplementationOnce(() => Promise.resolve(diffs.lhs))
.mockImplementationOnce(() => Promise.resolve(diffs.rhs));
render(<VersionsSettings dashboard={dashboard} />);
@@ -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());
});
});

View File

@@ -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<Props, State> {
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<Props, State> {
.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<Props, State> {
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<Props, State> {
};
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<Props, State> {
<VersionsHistorySpinner msg="Fetching changes&hellip;" />
) : (
<VersionHistoryComparison
dashboard={this.props.dashboard}
newInfo={newInfo}
baseInfo={baseInfo}
newInfo={newInfo!}
baseInfo={baseInfo!}
isNewLatest={isNewLatest}
onFetchFail={this.reset}
delta={delta}
diffData={diffData}
/>
)}
</div>

View File

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

View File

@@ -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<DiffGroupProps> = ({ diffs, title }) => {
const styles = useStyles(getStyles);
if (diffs.length === 1) {
return (
<div className={styles.container} data-testid="diffGroup">
<DiffTitle title={title} diff={diffs[0]} />
</div>
);
}
return (
<div className={styles.container} data-testid="diffGroup">
<DiffTitle title={title} />
<ul className={styles.list}>
{diffs.map((diff: Diff, idx: number) => {
return (
<li className={styles.listItem} key={`${_.last(diff.path)}__${idx}`}>
<span>{getDiffText(diff)}</span> <DiffValues diff={diff} />
</li>
);
})}
</ul>
</div>
);
};
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};
`,
});

View File

@@ -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<DiffTitleProps> = ({ diff, title }) => {
const styles = useStyles(getDiffTitleStyles);
return diff ? (
<>
<Icon type="mono" name="circle" className={styles[diff.op]} /> <span className={styles.embolden}>{title}</span>{' '}
<span>{getDiffText(diff, diff.path.length > 1)}</span> <DiffValues diff={diff} />
</>
) : (
<div className={styles.withoutDiff}>
<Icon type="mono" name="circle" className={styles.replace} /> <span className={styles.embolden}>{title}</span>{' '}
<span>{getDiffText(replaceDiff, false)}</span>
</div>
);
};
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};
`,
});

View File

@@ -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<DiffProps> = ({ 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 && <span className={styles}>{String(diff.originalValue)}</span>}
{hasLeftValue && hasRightValue ? <Icon name="arrow-right" /> : null}
{hasRightValue && <span className={styles}>{String(diff.value)}</span>}
</>
);
};
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};
`;

View File

@@ -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<ReactDiffViewerProps> = ({ 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 (
<div
className={css`
font-size: ${theme.typography.size.sm};
// prevent global styles interfering with diff viewer
pre {
all: revert;
}
`}
>
<ReactDiffViewer
styles={styles}
oldValue={oldValue}
newValue={newValue}
splitView={false}
compareMethod={DiffMethod.CSS}
useDarkTheme={theme.isDark}
/>
</div>
);
};

View File

@@ -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: '<div></div>',
json: '',
};
historyListCtrl.baseInfo = { version: 1 };
historyListCtrl.newInfo = { version: 2 };
historyListCtrl.isNewLatest = false;
});
it('should have basic diff state', () => {
expect(historyListCtrl.delta.basic).toBe('<div></div>');
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('<pre><code></code></pre>');
});
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: '<div></div>',
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: '<div></div>', 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);
});
});
});
});

View File

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

View File

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

View File

@@ -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<VersionsButtonsType> = ({
@@ -22,7 +22,7 @@ export const VersionsHistoryButtons: React.FC<VersionsButtonsType> = ({
</Button>
)}
<Tooltip content="Select 2 versions to start comparing" placement="bottom">
<Button type="button" disabled={canCompare} onClick={() => getDiff('basic')} icon="code-branch">
<Button type="button" disabled={canCompare} onClick={getDiff} icon="code-branch">
Compare versions
</Button>
</Tooltip>

View File

@@ -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<DiffViewProps> {
element?: HTMLElement | null;
angularCmp?: AngularComponent;
export const VersionHistoryComparison: React.FC<DiffViewProps> = ({ baseInfo, newInfo, diffData, isNewLatest }) => {
const diff = jsonDiff(diffData.lhs, diffData.rhs);
const styles = useStyles(getStyles);
constructor(props: DiffViewProps) {
super(props);
}
return (
<div>
<div className={styles.spacer}>
<HorizontalGroup justify="space-between" align="center">
<div>
<p className={styles.versionInfo}>
<strong>Version {newInfo.version}</strong> updated by {newInfo.createdBy} {newInfo.ageString} -{' '}
{newInfo.message}
</p>
<p className={cx(styles.versionInfo, styles.noMarginBottom)}>
<strong>Version {baseInfo.version}</strong> updated by {baseInfo.createdBy} {baseInfo.ageString} -{' '}
{baseInfo.message}
</p>
</div>
{isNewLatest && (
<ModalsController>
{({ showModal, hideModal }) => (
<Button
variant="destructive"
icon="history"
onClick={() => {
showModal(RevertDashboardModal, {
version: baseInfo.version,
hideModal,
});
}}
>
Restore to version {baseInfo.version}
</Button>
)}
</ModalsController>
)}
</HorizontalGroup>
</div>
<div className={styles.spacer}>
{Object.entries(diff).map(([key, diffs]) => (
<DiffGroup diffs={diffs} key={key} title={key} />
))}
</div>
<CollapsableSection isOpen={false} label="View JSON Diff">
<DiffViewer oldValue={JSON.stringify(diffData.lhs, null, 2)} newValue={JSON.stringify(diffData.rhs, null, 2)} />
</CollapsableSection>
</div>
);
};
componentDidMount() {
const loader = getAngularLoader();
const template =
'<gf-dashboard-history dashboard="dashboard" newinfo="newinfo" baseinfo="baseinfo" isnewlatest="isnewlatest" onfetchfail="onfetchfail" delta="delta"/>';
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 <div data-testid="angular-history-comparison" ref={(ref) => (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;
`,
});

View File

@@ -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<VersionHistoryHeaderProps> = ({
baseVersion = 0,
newVersion = 0,
isNewLatest = false,
}) => (
<h3 className="dashboard-settings__header">
<span onClick={onClick} className={isComparing ? 'pointer' : ''}>
Versions
</span>
{isComparing && (
<span>
<Icon name="angle-right" /> Comparing {baseVersion} <Icon name="arrows-h" /> {newVersion}{' '}
{isNewLatest && <cite className="muted">(Latest)</cite>}
}) => {
const styles = useStyles(getStyles);
return (
<h3 className={styles.header}>
<span onClick={onClick} className={isComparing ? 'pointer' : ''}>
Versions
</span>
)}
</h3>
);
{isComparing && (
<span>
<Icon name="angle-right" /> Comparing {baseVersion} <Icon name="arrows-h" /> {newVersion}{' '}
{isNewLatest && <cite className="muted">(Latest)</cite>}
</span>
)}
</h3>
);
};
const getStyles = (theme: GrafanaTheme) => ({
header: css`
font-size: ${theme.typography.heading.h3};
margin-bottom: ${theme.spacing.lg};
`,
});

View File

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

View File

@@ -1,39 +0,0 @@
<div ng-if="ctrl.loading">
<spinner inline="true" />
<em>Fetching changes&hellip;</em>
</div>
<div ng-if="!ctrl.loading">
<button
type="button"
class="btn btn-danger pull-right"
ng-click="ctrl.restore(ctrl.baseInfo.version)"
ng-if="ctrl.isNewLatest"
>
<icon name="'history'"></icon>&nbsp;&nbsp;Restore to version {{ctrl.baseInfo.version}}
</button>
<section>
<p class="small muted">
<strong>Version {{ctrl.newInfo.version}}</strong> updated by
<span>{{ctrl.newInfo.createdBy}} </span>
<span>{{ctrl.newInfo.ageString}}</span>
<span> - {{ctrl.newInfo.message}}</span>
</p>
<p class="small muted">
<strong>Version {{ctrl.baseInfo.version}}</strong> updated by
<span>{{ctrl.baseInfo.createdBy}} </span>
<span>{{ctrl.baseInfo.ageString}}</span>
<span> - {{ctrl.baseInfo.message}}</span>
</p>
</section>
<div id="delta" diff-delta>
<div class="delta-basic" compile="ctrl.delta.basic"></div>
</div>
<div class="gf-form-button-row">
<button class="btn btn-secondary" ng-click="ctrl.getDiff('json')">View JSON Diff</button>
</div>
<div class="delta-html" ng-show="ctrl.diff === 'json'" compile="ctrl.delta.json"></div>
</div>

View File

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

View File

@@ -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<Record<string, any>>((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';
};

View File

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