mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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",
|
||||
|
@@ -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());
|
||||
});
|
||||
});
|
||||
|
@@ -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…" />
|
||||
) : (
|
||||
<VersionHistoryComparison
|
||||
dashboard={this.props.dashboard}
|
||||
newInfo={newInfo}
|
||||
baseInfo={baseInfo}
|
||||
newInfo={newInfo!}
|
||||
baseInfo={baseInfo!}
|
||||
isNewLatest={isNewLatest}
|
||||
onFetchFail={this.reset}
|
||||
delta={delta}
|
||||
diffData={diffData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@@ -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};
|
||||
`,
|
||||
});
|
@@ -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};
|
||||
`,
|
||||
});
|
@@ -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};
|
||||
`;
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -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);
|
@@ -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) {
|
||||
|
@@ -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>
|
||||
|
@@ -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;
|
||||
`,
|
||||
});
|
||||
|
@@ -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};
|
||||
`,
|
||||
});
|
||||
|
@@ -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';
|
||||
|
@@ -1,39 +0,0 @@
|
||||
<div ng-if="ctrl.loading">
|
||||
<spinner inline="true" />
|
||||
<em>Fetching changes…</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> 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>
|
@@ -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);
|
||||
});
|
||||
});
|
100
public/app/features/dashboard/components/VersionHistory/utils.ts
Normal file
100
public/app/features/dashboard/components/VersionHistory/utils.ts
Normal 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';
|
||||
};
|
30
yarn.lock
30
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"
|
||||
|
Reference in New Issue
Block a user