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 }) => ( + + )} + + )} +
+
+
+ {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… -
- -
- -
-

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

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