mirror of https://github.com/grafana/grafana
Scenes: Compare versions in dashboard settings (#80286)
* Add versions tab in dashboard settings * Fetch and render dashboard versions * Be able to compare two versions * PR discussion changes * remove unnecessary async in test * PR discussion mods * linter fix * styles and tests * Fix show more versions bug * migrate files + style fix * fix test * refactor styles - css object keys to camelCase * refactor file migrations * more files migrations * remove unused type, cleanuppull/80746/head
parent
9969218231
commit
5a509ef1f1
@ -1,34 +0,0 @@ |
|||||||
import React, { useEffect } from 'react'; |
|
||||||
|
|
||||||
import { ConfirmModal } from '@grafana/ui'; |
|
||||||
|
|
||||||
import { useDashboardRestore } from './useDashboardRestore'; |
|
||||||
export interface RevertDashboardModalProps { |
|
||||||
hideModal: () => void; |
|
||||||
version: number; |
|
||||||
} |
|
||||||
|
|
||||||
export const RevertDashboardModal = ({ hideModal, version }: RevertDashboardModalProps) => { |
|
||||||
// TODO: how should state.error be handled?
|
|
||||||
const { state, onRestoreDashboard } = useDashboardRestore(version); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (!state.loading && state.value) { |
|
||||||
hideModal(); |
|
||||||
} |
|
||||||
}, [state, hideModal]); |
|
||||||
|
|
||||||
return ( |
|
||||||
<ConfirmModal |
|
||||||
isOpen={true} |
|
||||||
title="Restore Version" |
|
||||||
icon="history" |
|
||||||
onDismiss={hideModal} |
|
||||||
onConfirm={onRestoreDashboard} |
|
||||||
body={ |
|
||||||
<p>Are you sure you want to restore the dashboard to version {version}? All unsaved changes will be lost.</p> |
|
||||||
} |
|
||||||
confirmText={`Yes, restore to version ${version}`} |
|
||||||
/> |
|
||||||
); |
|
||||||
}; |
|
@ -1,39 +0,0 @@ |
|||||||
import { useEffect } from 'react'; |
|
||||||
import { useAsyncFn } from 'react-use'; |
|
||||||
|
|
||||||
import { locationUtil } from '@grafana/data'; |
|
||||||
import { locationService } from '@grafana/runtime'; |
|
||||||
import { useAppNotification } from 'app/core/copy/appNotification'; |
|
||||||
import { DashboardModel } from 'app/features/dashboard/state'; |
|
||||||
import { useSelector } from 'app/types'; |
|
||||||
|
|
||||||
import { dashboardWatcher } from '../../../live/dashboard/dashboardWatcher'; |
|
||||||
|
|
||||||
import { historySrv } from './HistorySrv'; |
|
||||||
|
|
||||||
const restoreDashboard = async (version: number, dashboard: DashboardModel) => { |
|
||||||
// Skip the watcher logic for this save since it's handled by the hook
|
|
||||||
dashboardWatcher.ignoreNextSave(); |
|
||||||
return await historySrv.restoreDashboard(dashboard, version); |
|
||||||
}; |
|
||||||
|
|
||||||
export const useDashboardRestore = (version: number) => { |
|
||||||
const dashboard = useSelector((state) => state.dashboard.getModel()); |
|
||||||
const [state, onRestoreDashboard] = useAsyncFn(async () => await restoreDashboard(version, dashboard!), []); |
|
||||||
const notifyApp = useAppNotification(); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (state.value) { |
|
||||||
const location = locationService.getLocation(); |
|
||||||
const newUrl = locationUtil.stripBaseFromUrl(state.value.url); |
|
||||||
const prevState = (location.state as any)?.routeReloadCounter; |
|
||||||
locationService.replace({ |
|
||||||
...location, |
|
||||||
pathname: newUrl, |
|
||||||
state: { routeReloadCounter: prevState ? prevState + 1 : 1 }, |
|
||||||
}); |
|
||||||
notifyApp.success('Dashboard restored', `Restored from version ${version}`); |
|
||||||
} |
|
||||||
}, [state, version, notifyApp]); |
|
||||||
return { state, onRestoreDashboard }; |
|
||||||
}; |
|
@ -1,57 +0,0 @@ |
|||||||
import { css } from '@emotion/css'; |
|
||||||
import { last } from 'lodash'; |
|
||||||
import React from 'react'; |
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data'; |
|
||||||
import { useStyles2 } from '@grafana/ui'; |
|
||||||
|
|
||||||
import { DiffTitle } from './DiffTitle'; |
|
||||||
import { DiffValues } from './DiffValues'; |
|
||||||
import { Diff, getDiffText } from './utils'; |
|
||||||
|
|
||||||
type DiffGroupProps = { |
|
||||||
diffs: Diff[]; |
|
||||||
title: string; |
|
||||||
}; |
|
||||||
|
|
||||||
export const DiffGroup = ({ diffs, title }: DiffGroupProps) => { |
|
||||||
const styles = useStyles2(getStyles); |
|
||||||
|
|
||||||
if (diffs.length === 1) { |
|
||||||
return ( |
|
||||||
<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: GrafanaTheme2) => ({ |
|
||||||
container: css` |
|
||||||
background-color: ${theme.colors.background.secondary}; |
|
||||||
font-size: ${theme.typography.h6.fontSize}; |
|
||||||
margin-bottom: ${theme.spacing(2)}; |
|
||||||
padding: ${theme.spacing(2)}; |
|
||||||
`,
|
|
||||||
list: css` |
|
||||||
margin-left: ${theme.spacing(4)}; |
|
||||||
`,
|
|
||||||
listItem: css` |
|
||||||
margin-bottom: ${theme.spacing(1)}; |
|
||||||
`,
|
|
||||||
}); |
|
@ -1,61 +0,0 @@ |
|||||||
import { css } from '@emotion/css'; |
|
||||||
import React from 'react'; |
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data'; |
|
||||||
import { useStyles2, Icon } from '@grafana/ui'; |
|
||||||
|
|
||||||
import { DiffValues } from './DiffValues'; |
|
||||||
import { Diff, getDiffText } from './utils'; |
|
||||||
|
|
||||||
type DiffTitleProps = { |
|
||||||
diff?: Diff; |
|
||||||
title: string; |
|
||||||
}; |
|
||||||
|
|
||||||
const replaceDiff: Diff = { op: 'replace', originalValue: undefined, path: [''], value: undefined, startLineNumber: 0 }; |
|
||||||
|
|
||||||
export const DiffTitle = ({ diff, title }: DiffTitleProps) => { |
|
||||||
const styles = useStyles2(getDiffTitleStyles); |
|
||||||
|
|
||||||
return diff ? ( |
|
||||||
<> |
|
||||||
<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: GrafanaTheme2) => ({ |
|
||||||
embolden: css` |
|
||||||
font-weight: ${theme.typography.fontWeightBold}; |
|
||||||
`,
|
|
||||||
add: css` |
|
||||||
color: ${theme.colors.success.main}; |
|
||||||
`,
|
|
||||||
replace: css` |
|
||||||
color: ${theme.colors.success.main}; |
|
||||||
`,
|
|
||||||
move: css` |
|
||||||
color: ${theme.colors.success.main}; |
|
||||||
`,
|
|
||||||
copy: css` |
|
||||||
color: ${theme.colors.success.main}; |
|
||||||
`,
|
|
||||||
_get: css` |
|
||||||
color: ${theme.colors.success.main}; |
|
||||||
`,
|
|
||||||
test: css` |
|
||||||
color: ${theme.colors.success.main}; |
|
||||||
`,
|
|
||||||
remove: css` |
|
||||||
color: ${theme.colors.success.main}; |
|
||||||
`,
|
|
||||||
withoutDiff: css` |
|
||||||
margin-bottom: ${theme.spacing(2)}; |
|
||||||
`,
|
|
||||||
}); |
|
@ -1,36 +0,0 @@ |
|||||||
import { css } from '@emotion/css'; |
|
||||||
import { isArray, isObject, isUndefined } from 'lodash'; |
|
||||||
import React from 'react'; |
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data'; |
|
||||||
import { useStyles2, Icon } from '@grafana/ui'; |
|
||||||
|
|
||||||
import { Diff } from './utils'; |
|
||||||
|
|
||||||
type DiffProps = { |
|
||||||
diff: Diff; |
|
||||||
}; |
|
||||||
|
|
||||||
export const DiffValues = ({ diff }: DiffProps) => { |
|
||||||
const styles = useStyles2(getStyles); |
|
||||||
const hasLeftValue = |
|
||||||
!isUndefined(diff.originalValue) && !isArray(diff.originalValue) && !isObject(diff.originalValue); |
|
||||||
const hasRightValue = !isUndefined(diff.value) && !isArray(diff.value) && !isObject(diff.value); |
|
||||||
|
|
||||||
return ( |
|
||||||
<> |
|
||||||
{hasLeftValue && <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: GrafanaTheme2) => css` |
|
||||||
background-color: ${theme.colors.action.hover}; |
|
||||||
border-radius: ${theme.shape.radius.default}; |
|
||||||
color: ${theme.colors.text.primary}; |
|
||||||
font-size: ${theme.typography.body.fontSize}; |
|
||||||
margin: 0 ${theme.spacing(0.5)}; |
|
||||||
padding: ${theme.spacing(0.5, 1)}; |
|
||||||
`;
|
|
@ -1,73 +0,0 @@ |
|||||||
import { css } from '@emotion/css'; |
|
||||||
import React from 'react'; |
|
||||||
import ReactDiffViewer, { ReactDiffViewerProps, DiffMethod } from 'react-diff-viewer'; |
|
||||||
import tinycolor from 'tinycolor2'; |
|
||||||
|
|
||||||
import { useTheme2 } from '@grafana/ui'; |
|
||||||
|
|
||||||
export const DiffViewer = ({ oldValue, newValue }: ReactDiffViewerProps) => { |
|
||||||
const theme = useTheme2(); |
|
||||||
|
|
||||||
const styles = { |
|
||||||
variables: { |
|
||||||
// the light theme supplied by ReactDiffViewer is very similar to Grafana
|
|
||||||
// the dark theme needs some tweaks.
|
|
||||||
dark: { |
|
||||||
diffViewerBackground: theme.colors.background.canvas, |
|
||||||
diffViewerColor: theme.colors.text.primary, |
|
||||||
addedBackground: tinycolor(theme.v1.palette.greenShade).setAlpha(0.3).toString(), |
|
||||||
addedColor: 'white', |
|
||||||
removedBackground: tinycolor(theme.v1.palette.redShade).setAlpha(0.3).toString(), |
|
||||||
removedColor: 'white', |
|
||||||
wordAddedBackground: tinycolor(theme.v1.palette.greenBase).setAlpha(0.4).toString(), |
|
||||||
wordRemovedBackground: tinycolor(theme.v1.palette.redBase).setAlpha(0.4).toString(), |
|
||||||
addedGutterBackground: tinycolor(theme.v1.palette.greenShade).setAlpha(0.2).toString(), |
|
||||||
removedGutterBackground: tinycolor(theme.v1.palette.redShade).setAlpha(0.2).toString(), |
|
||||||
gutterBackground: theme.colors.background.primary, |
|
||||||
gutterBackgroundDark: theme.colors.background.primary, |
|
||||||
highlightBackground: tinycolor(theme.colors.primary.main).setAlpha(0.4).toString(), |
|
||||||
highlightGutterBackground: tinycolor(theme.colors.primary.shade).setAlpha(0.2).toString(), |
|
||||||
codeFoldGutterBackground: theme.colors.background.secondary, |
|
||||||
codeFoldBackground: theme.colors.background.secondary, |
|
||||||
emptyLineBackground: theme.colors.background.secondary, |
|
||||||
gutterColor: theme.colors.text.disabled, |
|
||||||
addedGutterColor: theme.colors.text.primary, |
|
||||||
removedGutterColor: theme.colors.text.primary, |
|
||||||
codeFoldContentColor: theme.colors.text.disabled, |
|
||||||
diffViewerTitleBackground: theme.colors.background.secondary, |
|
||||||
diffViewerTitleColor: theme.colors.text.disabled, |
|
||||||
diffViewerTitleBorderColor: theme.colors.border.strong, |
|
||||||
}, |
|
||||||
}, |
|
||||||
codeFold: { |
|
||||||
fontSize: theme.typography.bodySmall.fontSize, |
|
||||||
}, |
|
||||||
gutter: ` |
|
||||||
pre { |
|
||||||
color: ${tinycolor(theme.colors.text.disabled).setAlpha(1).toString()}; |
|
||||||
opacity: 0.61; |
|
||||||
} |
|
||||||
`,
|
|
||||||
}; |
|
||||||
|
|
||||||
return ( |
|
||||||
<div |
|
||||||
className={css` |
|
||||||
font-size: ${theme.typography.bodySmall.fontSize}; |
|
||||||
// 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,75 +0,0 @@ |
|||||||
import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures'; |
|
||||||
|
|
||||||
import { HistorySrv } from './HistorySrv'; |
|
||||||
import { restore, versions } from './__mocks__/dashboardHistoryMocks'; |
|
||||||
|
|
||||||
const getMock = jest.fn().mockResolvedValue({}); |
|
||||||
const postMock = jest.fn().mockResolvedValue({}); |
|
||||||
|
|
||||||
jest.mock('app/core/store'); |
|
||||||
jest.mock('@grafana/runtime', () => { |
|
||||||
const original = jest.requireActual('@grafana/runtime'); |
|
||||||
|
|
||||||
return { |
|
||||||
...original, |
|
||||||
getBackendSrv: () => ({ |
|
||||||
post: postMock, |
|
||||||
get: getMock, |
|
||||||
}), |
|
||||||
}; |
|
||||||
}); |
|
||||||
|
|
||||||
describe('historySrv', () => { |
|
||||||
const versionsResponse = versions(); |
|
||||||
const restoreResponse = restore; |
|
||||||
|
|
||||||
let historySrv = new HistorySrv(); |
|
||||||
|
|
||||||
const dash = createDashboardModelFixture({ uid: '_U4zObQMz' }); |
|
||||||
const emptyDash = createDashboardModelFixture(); |
|
||||||
const historyListOpts = { limit: 10, start: 0 }; |
|
||||||
|
|
||||||
beforeEach(() => { |
|
||||||
jest.clearAllMocks(); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('getHistoryList', () => { |
|
||||||
it('should return a versions array for the given dashboard id', () => { |
|
||||||
getMock.mockImplementation(() => Promise.resolve(versionsResponse)); |
|
||||||
historySrv = new HistorySrv(); |
|
||||||
|
|
||||||
return historySrv.getHistoryList(dash.uid, historyListOpts).then((versions) => { |
|
||||||
expect(versions).toEqual(versionsResponse); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should return an empty array when not given an id', () => { |
|
||||||
return historySrv.getHistoryList(emptyDash.uid, historyListOpts).then((versions) => { |
|
||||||
expect(versions).toEqual([]); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should return an empty array when not given a dashboard id', () => { |
|
||||||
return historySrv.getHistoryList(null as unknown as string, historyListOpts).then((versions) => { |
|
||||||
expect(versions).toEqual([]); |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('restoreDashboard', () => { |
|
||||||
it('should return a success response given valid parameters', () => { |
|
||||||
const version = 6; |
|
||||||
postMock.mockImplementation(() => Promise.resolve(restoreResponse(version))); |
|
||||||
historySrv = new HistorySrv(); |
|
||||||
return historySrv.restoreDashboard(dash, version).then((response) => { |
|
||||||
expect(response).toEqual(restoreResponse(version)); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should return an empty object when not given an id', async () => { |
|
||||||
historySrv = new HistorySrv(); |
|
||||||
const rsp = await historySrv.restoreDashboard(emptyDash, 6); |
|
||||||
expect(rsp).toEqual({}); |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
@ -1,51 +0,0 @@ |
|||||||
import { isNumber } from 'lodash'; |
|
||||||
|
|
||||||
import { getBackendSrv } from '@grafana/runtime'; |
|
||||||
|
|
||||||
import { DashboardModel } from '../../state/DashboardModel'; |
|
||||||
|
|
||||||
export interface HistoryListOpts { |
|
||||||
limit: number; |
|
||||||
start: number; |
|
||||||
} |
|
||||||
|
|
||||||
export interface RevisionsModel { |
|
||||||
id: number; |
|
||||||
checked: boolean; |
|
||||||
dashboardUID: string; |
|
||||||
parentVersion: number; |
|
||||||
version: number; |
|
||||||
created: Date; |
|
||||||
createdBy: string; |
|
||||||
message: string; |
|
||||||
} |
|
||||||
|
|
||||||
export interface DiffTarget { |
|
||||||
dashboardUID: string; |
|
||||||
version: number; |
|
||||||
unsavedDashboard?: DashboardModel; // when doing diffs against unsaved dashboard version
|
|
||||||
} |
|
||||||
|
|
||||||
export class HistorySrv { |
|
||||||
getHistoryList(dashboardUID: string, options: HistoryListOpts) { |
|
||||||
if (typeof dashboardUID !== 'string') { |
|
||||||
return Promise.resolve([]); |
|
||||||
} |
|
||||||
|
|
||||||
return getBackendSrv().get(`api/dashboards/uid/${dashboardUID}/versions`, options); |
|
||||||
} |
|
||||||
|
|
||||||
getDashboardVersion(uid: string, version: number) { |
|
||||||
return getBackendSrv().get(`api/dashboards/uid/${uid}/versions/${version}`); |
|
||||||
} |
|
||||||
|
|
||||||
restoreDashboard(dashboard: DashboardModel, version: number) { |
|
||||||
const uid = dashboard && dashboard.uid ? dashboard.uid : void 0; |
|
||||||
const url = `api/dashboards/uid/${uid}/restore`; |
|
||||||
|
|
||||||
return uid && isNumber(version) ? getBackendSrv().post(url, { version }) : Promise.resolve({}); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const historySrv = new HistorySrv(); |
|
||||||
export { historySrv }; |
|
@ -1,31 +0,0 @@ |
|||||||
import React from 'react'; |
|
||||||
|
|
||||||
import { Tooltip, Button, Stack } from '@grafana/ui'; |
|
||||||
|
|
||||||
type VersionsButtonsType = { |
|
||||||
hasMore: boolean; |
|
||||||
canCompare: boolean; |
|
||||||
getVersions: (append: boolean) => void; |
|
||||||
getDiff: () => void; |
|
||||||
isLastPage: boolean; |
|
||||||
}; |
|
||||||
export const VersionsHistoryButtons = ({ |
|
||||||
hasMore, |
|
||||||
canCompare, |
|
||||||
getVersions, |
|
||||||
getDiff, |
|
||||||
isLastPage, |
|
||||||
}: VersionsButtonsType) => ( |
|
||||||
<Stack> |
|
||||||
{hasMore && ( |
|
||||||
<Button type="button" onClick={() => getVersions(true)} variant="secondary" disabled={isLastPage}> |
|
||||||
Show more versions |
|
||||||
</Button> |
|
||||||
)} |
|
||||||
<Tooltip content="Select two versions to start comparing" placement="bottom"> |
|
||||||
<Button type="button" disabled={!canCompare} onClick={getDiff} icon="code-branch"> |
|
||||||
Compare versions |
|
||||||
</Button> |
|
||||||
</Tooltip> |
|
||||||
</Stack> |
|
||||||
); |
|
@ -1,82 +0,0 @@ |
|||||||
import { css, cx } from '@emotion/css'; |
|
||||||
import React from 'react'; |
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data'; |
|
||||||
import { Button, ModalsController, CollapsableSection, HorizontalGroup, useStyles2 } from '@grafana/ui'; |
|
||||||
|
|
||||||
import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings'; |
|
||||||
|
|
||||||
import { DiffGroup } from './DiffGroup'; |
|
||||||
import { DiffViewer } from './DiffViewer'; |
|
||||||
import { RevertDashboardModal } from './RevertDashboardModal'; |
|
||||||
import { jsonDiff } from './utils'; |
|
||||||
|
|
||||||
type DiffViewProps = { |
|
||||||
isNewLatest: boolean; |
|
||||||
newInfo: DecoratedRevisionModel; |
|
||||||
baseInfo: DecoratedRevisionModel; |
|
||||||
diffData: { lhs: unknown; rhs: unknown }; |
|
||||||
}; |
|
||||||
|
|
||||||
export const VersionHistoryComparison = ({ baseInfo, newInfo, diffData, isNewLatest }: DiffViewProps) => { |
|
||||||
const diff = jsonDiff(diffData.lhs, diffData.rhs); |
|
||||||
const styles = useStyles2(getStyles); |
|
||||||
|
|
||||||
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> |
|
||||||
); |
|
||||||
}; |
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({ |
|
||||||
spacer: css` |
|
||||||
margin-bottom: ${theme.spacing(4)}; |
|
||||||
`,
|
|
||||||
versionInfo: css` |
|
||||||
color: ${theme.colors.text.secondary}; |
|
||||||
font-size: ${theme.typography.bodySmall.fontSize}; |
|
||||||
`,
|
|
||||||
noMarginBottom: css` |
|
||||||
margin-bottom: 0; |
|
||||||
`,
|
|
||||||
}); |
|
@ -1,41 +0,0 @@ |
|||||||
import { css } from '@emotion/css'; |
|
||||||
import { noop } from 'lodash'; |
|
||||||
import React from 'react'; |
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data'; |
|
||||||
import { Icon, IconButton, useStyles2 } from '@grafana/ui'; |
|
||||||
|
|
||||||
type VersionHistoryHeaderProps = { |
|
||||||
onClick?: () => void; |
|
||||||
baseVersion?: number; |
|
||||||
newVersion?: number; |
|
||||||
isNewLatest?: boolean; |
|
||||||
}; |
|
||||||
|
|
||||||
export const VersionHistoryHeader = ({ |
|
||||||
onClick = noop, |
|
||||||
baseVersion = 0, |
|
||||||
newVersion = 0, |
|
||||||
isNewLatest = false, |
|
||||||
}: VersionHistoryHeaderProps) => { |
|
||||||
const styles = useStyles2(getStyles); |
|
||||||
|
|
||||||
return ( |
|
||||||
<h3 className={styles.header}> |
|
||||||
<IconButton name="arrow-left" size="xl" onClick={onClick} tooltip="Reset version" /> |
|
||||||
<span> |
|
||||||
Comparing {baseVersion} <Icon name="arrows-h" /> {newVersion}{' '} |
|
||||||
{isNewLatest && <cite className="muted">(Latest)</cite>} |
|
||||||
</span> |
|
||||||
</h3> |
|
||||||
); |
|
||||||
}; |
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({ |
|
||||||
header: css` |
|
||||||
font-size: ${theme.typography.h3.fontSize}; |
|
||||||
display: flex; |
|
||||||
gap: ${theme.spacing(2)}; |
|
||||||
margin-bottom: ${theme.spacing(3)}; |
|
||||||
`,
|
|
||||||
}); |
|
@ -1,73 +0,0 @@ |
|||||||
import { css } from '@emotion/css'; |
|
||||||
import React from 'react'; |
|
||||||
|
|
||||||
import { Checkbox, Button, Tag, ModalsController } from '@grafana/ui'; |
|
||||||
|
|
||||||
import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings'; |
|
||||||
|
|
||||||
import { RevertDashboardModal } from './RevertDashboardModal'; |
|
||||||
|
|
||||||
type VersionsTableProps = { |
|
||||||
versions: DecoratedRevisionModel[]; |
|
||||||
canCompare: boolean; |
|
||||||
onCheck: (ev: React.FormEvent<HTMLInputElement>, versionId: number) => void; |
|
||||||
}; |
|
||||||
|
|
||||||
export const VersionHistoryTable = ({ versions, canCompare, onCheck }: VersionsTableProps) => ( |
|
||||||
<table className="filter-table gf-form-group"> |
|
||||||
<thead> |
|
||||||
<tr> |
|
||||||
<th className="width-4"></th> |
|
||||||
<th className="width-4">Version</th> |
|
||||||
<th className="width-14">Date</th> |
|
||||||
<th className="width-10">Updated by</th> |
|
||||||
<th>Notes</th> |
|
||||||
<th></th> |
|
||||||
</tr> |
|
||||||
</thead> |
|
||||||
<tbody> |
|
||||||
{versions.map((version, idx) => ( |
|
||||||
<tr key={version.id}> |
|
||||||
<td> |
|
||||||
<Checkbox |
|
||||||
aria-label={`Toggle selection of version ${version.version}`} |
|
||||||
className={css` |
|
||||||
display: inline; |
|
||||||
`}
|
|
||||||
checked={version.checked} |
|
||||||
onChange={(ev) => onCheck(ev, version.id)} |
|
||||||
disabled={!version.checked && canCompare} |
|
||||||
/> |
|
||||||
</td> |
|
||||||
<td>{version.version}</td> |
|
||||||
<td>{version.createdDateString}</td> |
|
||||||
<td>{version.createdBy}</td> |
|
||||||
<td>{version.message}</td> |
|
||||||
<td className="text-right"> |
|
||||||
{idx === 0 ? ( |
|
||||||
<Tag name="Latest" colorIndex={17} /> |
|
||||||
) : ( |
|
||||||
<ModalsController> |
|
||||||
{({ showModal, hideModal }) => ( |
|
||||||
<Button |
|
||||||
variant="secondary" |
|
||||||
size="sm" |
|
||||||
icon="history" |
|
||||||
onClick={() => { |
|
||||||
showModal(RevertDashboardModal, { |
|
||||||
version: version.version, |
|
||||||
hideModal, |
|
||||||
}); |
|
||||||
}} |
|
||||||
> |
|
||||||
Restore |
|
||||||
</Button> |
|
||||||
)} |
|
||||||
</ModalsController> |
|
||||||
)} |
|
||||||
</td> |
|
||||||
</tr> |
|
||||||
))} |
|
||||||
</tbody> |
|
||||||
</table> |
|
||||||
); |
|
@ -1,176 +0,0 @@ |
|||||||
export function versions() { |
|
||||||
return [ |
|
||||||
{ |
|
||||||
id: 4, |
|
||||||
dashboardId: 1, |
|
||||||
dashboardUID: '_U4zObQMz', |
|
||||||
parentVersion: 3, |
|
||||||
restoredFrom: 0, |
|
||||||
version: 4, |
|
||||||
created: '2017-02-22T17:43:01-08:00', |
|
||||||
createdBy: 'admin', |
|
||||||
message: '', |
|
||||||
}, |
|
||||||
{ |
|
||||||
id: 3, |
|
||||||
dashboardId: 1, |
|
||||||
dashboardUID: '_U4zObQMz', |
|
||||||
parentVersion: 1, |
|
||||||
restoredFrom: 1, |
|
||||||
version: 3, |
|
||||||
created: '2017-02-22T17:43:01-08:00', |
|
||||||
createdBy: 'admin', |
|
||||||
message: '', |
|
||||||
}, |
|
||||||
{ |
|
||||||
id: 2, |
|
||||||
dashboardId: 1, |
|
||||||
dashboardUID: '_U4zObQMz', |
|
||||||
parentVersion: 0, |
|
||||||
restoredFrom: -1, |
|
||||||
version: 2, |
|
||||||
created: '2017-02-22T17:29:52-08:00', |
|
||||||
createdBy: 'admin', |
|
||||||
message: '', |
|
||||||
}, |
|
||||||
{ |
|
||||||
id: 1, |
|
||||||
dashboardId: 1, |
|
||||||
dashboardUID: '_U4zObQMz', |
|
||||||
parentVersion: 0, |
|
||||||
restoredFrom: -1, |
|
||||||
slug: 'history-dashboard', |
|
||||||
version: 1, |
|
||||||
created: '2017-02-22T17:06:37-08:00', |
|
||||||
createdBy: 'admin', |
|
||||||
message: '', |
|
||||||
}, |
|
||||||
]; |
|
||||||
} |
|
||||||
|
|
||||||
export function restore(version: number, restoredFrom?: number) { |
|
||||||
return { |
|
||||||
dashboard: { |
|
||||||
meta: { |
|
||||||
type: 'db', |
|
||||||
canSave: true, |
|
||||||
canEdit: true, |
|
||||||
canStar: true, |
|
||||||
slug: 'history-dashboard', |
|
||||||
expires: '0001-01-01T00:00:00Z', |
|
||||||
created: '2017-02-21T18:40:45-08:00', |
|
||||||
updated: '2017-04-11T21:31:22.59219665-07:00', |
|
||||||
updatedBy: 'admin', |
|
||||||
createdBy: 'admin', |
|
||||||
version: version, |
|
||||||
}, |
|
||||||
dashboard: { |
|
||||||
annotations: { |
|
||||||
list: [], |
|
||||||
}, |
|
||||||
description: 'A random dashboard for implementing the history list', |
|
||||||
editable: true, |
|
||||||
gnetId: null, |
|
||||||
graphTooltip: 0, |
|
||||||
id: 1, |
|
||||||
uid: '_U4zObQMz', |
|
||||||
links: [], |
|
||||||
restoredFrom: restoredFrom, |
|
||||||
rows: [ |
|
||||||
{ |
|
||||||
collapse: false, |
|
||||||
height: '250px', |
|
||||||
panels: [ |
|
||||||
{ |
|
||||||
aliasColors: {}, |
|
||||||
bars: false, |
|
||||||
datasource: null, |
|
||||||
fill: 1, |
|
||||||
id: 1, |
|
||||||
legend: { |
|
||||||
avg: false, |
|
||||||
current: false, |
|
||||||
max: false, |
|
||||||
min: false, |
|
||||||
show: true, |
|
||||||
total: false, |
|
||||||
values: false, |
|
||||||
}, |
|
||||||
lines: true, |
|
||||||
linewidth: 1, |
|
||||||
nullPointMode: 'null', |
|
||||||
percentage: false, |
|
||||||
pointradius: 5, |
|
||||||
points: false, |
|
||||||
renderer: 'flot', |
|
||||||
seriesOverrides: [], |
|
||||||
span: 12, |
|
||||||
stack: false, |
|
||||||
steppedLine: false, |
|
||||||
targets: [{}], |
|
||||||
thresholds: [], |
|
||||||
timeFrom: null, |
|
||||||
timeShift: null, |
|
||||||
title: 'Panel Title', |
|
||||||
tooltip: { |
|
||||||
shared: true, |
|
||||||
sort: 0, |
|
||||||
value_type: 'individual', |
|
||||||
}, |
|
||||||
type: 'graph', |
|
||||||
xaxis: { |
|
||||||
mode: 'time', |
|
||||||
name: null, |
|
||||||
show: true, |
|
||||||
values: [], |
|
||||||
}, |
|
||||||
yaxes: [ |
|
||||||
{ |
|
||||||
format: 'short', |
|
||||||
label: null, |
|
||||||
logBase: 1, |
|
||||||
max: null, |
|
||||||
min: null, |
|
||||||
show: true, |
|
||||||
}, |
|
||||||
{ |
|
||||||
format: 'short', |
|
||||||
label: null, |
|
||||||
logBase: 1, |
|
||||||
max: null, |
|
||||||
min: null, |
|
||||||
show: true, |
|
||||||
}, |
|
||||||
], |
|
||||||
}, |
|
||||||
], |
|
||||||
repeat: null, |
|
||||||
repeatIteration: null, |
|
||||||
repeatRowId: null, |
|
||||||
showTitle: false, |
|
||||||
title: 'Dashboard Row', |
|
||||||
titleSize: 'h6', |
|
||||||
}, |
|
||||||
], |
|
||||||
schemaVersion: 14, |
|
||||||
tags: ['development'], |
|
||||||
templating: { |
|
||||||
list: [], |
|
||||||
}, |
|
||||||
time: { |
|
||||||
from: 'now-6h', |
|
||||||
to: 'now', |
|
||||||
}, |
|
||||||
timepicker: { |
|
||||||
refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'], |
|
||||||
time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'], |
|
||||||
}, |
|
||||||
timezone: 'utc', |
|
||||||
title: 'History Dashboard', |
|
||||||
version: version, |
|
||||||
}, |
|
||||||
}, |
|
||||||
message: 'Dashboard restored to version ' + version, |
|
||||||
version: version, |
|
||||||
}; |
|
||||||
} |
|
@ -1,5 +0,0 @@ |
|||||||
export { HistorySrv, historySrv, RevisionsModel } from './HistorySrv'; |
|
||||||
export { VersionHistoryTable } from './VersionHistoryTable'; |
|
||||||
export { VersionHistoryHeader } from './VersionHistoryHeader'; |
|
||||||
export { VersionsHistoryButtons } from './VersionHistoryButtons'; |
|
||||||
export { VersionHistoryComparison } from './VersionHistoryComparison'; |
|
@ -1,293 +0,0 @@ |
|||||||
import { Diff, getDiffOperationText, getDiffText, jsonDiff } from './utils'; |
|
||||||
|
|
||||||
describe('getDiffOperationText', () => { |
|
||||||
const cases = [ |
|
||||||
['add', 'added'], |
|
||||||
['remove', 'deleted'], |
|
||||||
['replace', 'changed'], |
|
||||||
['byDefault', 'changed'], |
|
||||||
]; |
|
||||||
|
|
||||||
test.each(cases)('it returns the correct verb for an operation', (operation, expected) => { |
|
||||||
expect(getDiffOperationText(operation)).toBe(expected); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
type DiffTextCase = [Partial<Diff>, string]; |
|
||||||
describe('getDiffText', () => { |
|
||||||
const addEmptyArray: DiffTextCase = [ |
|
||||||
{ op: 'add', value: [], path: ['annotations', 'list'], startLineNumber: 24 }, |
|
||||||
'added list', |
|
||||||
]; |
|
||||||
const addArrayNumericProp: DiffTextCase = [ |
|
||||||
{ |
|
||||||
op: 'add', |
|
||||||
value: ['tag'], |
|
||||||
path: ['panels', '3'], |
|
||||||
}, |
|
||||||
'added item 3', |
|
||||||
]; |
|
||||||
const addArrayProp: DiffTextCase = [ |
|
||||||
{ |
|
||||||
op: 'add', |
|
||||||
value: [{ name: 'dummy target 1' }, { name: 'dummy target 2' }], |
|
||||||
path: ['panels', '3', 'targets'], |
|
||||||
}, |
|
||||||
'added 2 targets', |
|
||||||
]; |
|
||||||
const addValueNumericProp: DiffTextCase = [ |
|
||||||
{ |
|
||||||
op: 'add', |
|
||||||
value: 'foo', |
|
||||||
path: ['panels', '3'], |
|
||||||
}, |
|
||||||
'added item 3', |
|
||||||
]; |
|
||||||
const addValueProp: DiffTextCase = [ |
|
||||||
{ |
|
||||||
op: 'add', |
|
||||||
value: 'foo', |
|
||||||
path: ['panels', '3', 'targets'], |
|
||||||
}, |
|
||||||
'added targets', |
|
||||||
]; |
|
||||||
|
|
||||||
const removeEmptyArray: DiffTextCase = [ |
|
||||||
{ op: 'remove', originalValue: [], path: ['annotations', 'list'], startLineNumber: 24 }, |
|
||||||
'deleted list', |
|
||||||
]; |
|
||||||
const removeArrayNumericProp: DiffTextCase = [ |
|
||||||
{ |
|
||||||
op: 'remove', |
|
||||||
originalValue: ['tag'], |
|
||||||
path: ['panels', '3'], |
|
||||||
}, |
|
||||||
'deleted item 3', |
|
||||||
]; |
|
||||||
const removeArrayProp: DiffTextCase = [ |
|
||||||
{ |
|
||||||
op: 'remove', |
|
||||||
originalValue: [{ name: 'dummy target 1' }, { name: 'dummy target 2' }], |
|
||||||
path: ['panels', '3', 'targets'], |
|
||||||
}, |
|
||||||
'deleted 2 targets', |
|
||||||
]; |
|
||||||
const removeValueNumericProp: DiffTextCase = [ |
|
||||||
{ |
|
||||||
op: 'remove', |
|
||||||
originalValue: 'foo', |
|
||||||
path: ['panels', '3'], |
|
||||||
}, |
|
||||||
'deleted item 3', |
|
||||||
]; |
|
||||||
const removeValueProp: DiffTextCase = [ |
|
||||||
{ |
|
||||||
op: 'remove', |
|
||||||
originalValue: 'foo', |
|
||||||
path: ['panels', '3', 'targets'], |
|
||||||
}, |
|
||||||
'deleted targets', |
|
||||||
]; |
|
||||||
const replaceValueNumericProp: DiffTextCase = [ |
|
||||||
{ |
|
||||||
op: 'replace', |
|
||||||
originalValue: 'foo', |
|
||||||
value: 'bar', |
|
||||||
path: ['panels', '3'], |
|
||||||
}, |
|
||||||
'changed item 3', |
|
||||||
]; |
|
||||||
const replaceValueProp: DiffTextCase = [ |
|
||||||
{ |
|
||||||
op: 'replace', |
|
||||||
originalValue: 'foo', |
|
||||||
value: 'bar', |
|
||||||
path: ['panels', '3', 'targets'], |
|
||||||
}, |
|
||||||
'changed targets', |
|
||||||
]; |
|
||||||
|
|
||||||
const cases = [ |
|
||||||
addEmptyArray, |
|
||||||
addArrayNumericProp, |
|
||||||
addArrayProp, |
|
||||||
addValueNumericProp, |
|
||||||
addValueProp, |
|
||||||
removeEmptyArray, |
|
||||||
removeArrayNumericProp, |
|
||||||
removeArrayProp, |
|
||||||
removeValueNumericProp, |
|
||||||
removeValueProp, |
|
||||||
replaceValueNumericProp, |
|
||||||
replaceValueProp, |
|
||||||
]; |
|
||||||
|
|
||||||
test.each(cases)( |
|
||||||
'returns a semantic message based on the type of diff, the values and the location of the change', |
|
||||||
(diff: Partial<Diff>, expected: string) => { |
|
||||||
expect(getDiffText(diff as unknown as Diff)).toBe(expected); |
|
||||||
} |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('jsonDiff', () => { |
|
||||||
it('returns data related to each change', () => { |
|
||||||
const lhs = { |
|
||||||
annotations: { |
|
||||||
list: [ |
|
||||||
{ |
|
||||||
builtIn: 1, |
|
||||||
datasource: '-- Grafana --', |
|
||||||
enable: true, |
|
||||||
hide: true, |
|
||||||
iconColor: 'rgba(0, 211, 255, 1)', |
|
||||||
name: 'Annotations & Alerts', |
|
||||||
type: 'dashboard', |
|
||||||
}, |
|
||||||
], |
|
||||||
}, |
|
||||||
editable: true, |
|
||||||
gnetId: null, |
|
||||||
graphTooltip: 0, |
|
||||||
id: 141, |
|
||||||
links: [], |
|
||||||
panels: [], |
|
||||||
schemaVersion: 27, |
|
||||||
tags: [], |
|
||||||
templating: { |
|
||||||
list: [], |
|
||||||
}, |
|
||||||
time: { |
|
||||||
from: 'now-6h', |
|
||||||
to: 'now', |
|
||||||
}, |
|
||||||
timepicker: {}, |
|
||||||
timezone: '', |
|
||||||
title: 'test dashboard', |
|
||||||
uid: '_U4zObQMz', |
|
||||||
version: 2, |
|
||||||
}; |
|
||||||
|
|
||||||
const rhs = { |
|
||||||
annotations: { |
|
||||||
list: [ |
|
||||||
{ |
|
||||||
builtIn: 1, |
|
||||||
datasource: '-- Grafana --', |
|
||||||
enable: true, |
|
||||||
hide: true, |
|
||||||
iconColor: 'rgba(0, 211, 255, 1)', |
|
||||||
name: 'Annotations & Alerts', |
|
||||||
type: 'dashboard', |
|
||||||
}, |
|
||||||
], |
|
||||||
}, |
|
||||||
description: 'a description', |
|
||||||
editable: true, |
|
||||||
gnetId: null, |
|
||||||
graphTooltip: 1, |
|
||||||
id: 141, |
|
||||||
links: [], |
|
||||||
panels: [ |
|
||||||
{ |
|
||||||
type: 'graph', |
|
||||||
}, |
|
||||||
], |
|
||||||
schemaVersion: 27, |
|
||||||
tags: ['the tag'], |
|
||||||
templating: { |
|
||||||
list: [], |
|
||||||
}, |
|
||||||
time: { |
|
||||||
from: 'now-6h', |
|
||||||
to: 'now', |
|
||||||
}, |
|
||||||
timepicker: { |
|
||||||
refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'], |
|
||||||
}, |
|
||||||
timezone: 'utc', |
|
||||||
title: 'My favourite dashboard', |
|
||||||
uid: '_U4zObQMz', |
|
||||||
version: 3, |
|
||||||
}; |
|
||||||
|
|
||||||
const expected = { |
|
||||||
description: [ |
|
||||||
{ |
|
||||||
op: 'add', |
|
||||||
originalValue: undefined, |
|
||||||
path: ['description'], |
|
||||||
startLineNumber: 14, |
|
||||||
value: 'a description', |
|
||||||
}, |
|
||||||
], |
|
||||||
graphTooltip: [ |
|
||||||
{ |
|
||||||
op: 'replace', |
|
||||||
originalValue: 0, |
|
||||||
path: ['graphTooltip'], |
|
||||||
startLineNumber: 17, |
|
||||||
value: 1, |
|
||||||
}, |
|
||||||
], |
|
||||||
panels: [ |
|
||||||
{ |
|
||||||
op: 'add', |
|
||||||
originalValue: undefined, |
|
||||||
path: ['panels', '0'], |
|
||||||
startLineNumber: 21, |
|
||||||
value: { |
|
||||||
type: 'graph', |
|
||||||
}, |
|
||||||
}, |
|
||||||
], |
|
||||||
tags: [ |
|
||||||
{ |
|
||||||
op: 'add', |
|
||||||
originalValue: undefined, |
|
||||||
path: ['tags', '0'], |
|
||||||
startLineNumber: 27, |
|
||||||
value: 'the tag', |
|
||||||
}, |
|
||||||
], |
|
||||||
timepicker: [ |
|
||||||
{ |
|
||||||
op: 'add', |
|
||||||
originalValue: undefined, |
|
||||||
path: ['timepicker', 'refresh_intervals'], |
|
||||||
startLineNumber: 37, |
|
||||||
value: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'], |
|
||||||
}, |
|
||||||
], |
|
||||||
timezone: [ |
|
||||||
{ |
|
||||||
op: 'replace', |
|
||||||
originalValue: '', |
|
||||||
path: ['timezone'], |
|
||||||
startLineNumber: 51, |
|
||||||
value: 'utc', |
|
||||||
}, |
|
||||||
], |
|
||||||
title: [ |
|
||||||
{ |
|
||||||
op: 'replace', |
|
||||||
originalValue: 'test dashboard', |
|
||||||
path: ['title'], |
|
||||||
startLineNumber: 52, |
|
||||||
value: 'My favourite dashboard', |
|
||||||
}, |
|
||||||
], |
|
||||||
version: [ |
|
||||||
{ |
|
||||||
op: 'replace', |
|
||||||
originalValue: 2, |
|
||||||
path: ['version'], |
|
||||||
startLineNumber: 54, |
|
||||||
value: 3, |
|
||||||
}, |
|
||||||
], |
|
||||||
}; |
|
||||||
|
|
||||||
expect(jsonDiff(lhs, rhs)).toStrictEqual(expected); |
|
||||||
}); |
|
||||||
}); |
|
@ -1,100 +0,0 @@ |
|||||||
import { compare, Operation } from 'fast-json-patch'; |
|
||||||
// @ts-ignore
|
|
||||||
import jsonMap from 'json-source-map'; |
|
||||||
import { flow, get, isArray, isEmpty, last, sortBy, tail, toNumber, isNaN } from 'lodash'; |
|
||||||
|
|
||||||
export type Diff = { |
|
||||||
op: 'add' | 'replace' | 'remove' | 'copy' | 'test' | '_get' | 'move'; |
|
||||||
value: unknown; |
|
||||||
originalValue: unknown; |
|
||||||
path: string[]; |
|
||||||
startLineNumber: number; |
|
||||||
}; |
|
||||||
|
|
||||||
export type Diffs = { |
|
||||||
[key: string]: Diff[]; |
|
||||||
}; |
|
||||||
|
|
||||||
export 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' && rhsMap.pointers[diff.path]) { |
|
||||||
originalValue = get(lhs, path); |
|
||||||
value = diff.value; |
|
||||||
startLineNumber = rhsMap.pointers[diff.path].value.line; |
|
||||||
} |
|
||||||
if (diff.op === 'add' && rhsMap.pointers[diff.path]) { |
|
||||||
value = diff.value; |
|
||||||
startLineNumber = rhsMap.pointers[diff.path].value.line; |
|
||||||
} |
|
||||||
if (diff.op === 'remove' && lhsMap.pointers[diff.path]) { |
|
||||||
originalValue = get(lhs, path); |
|
||||||
startLineNumber = lhsMap.pointers[diff.path].value.line; |
|
||||||
} |
|
||||||
|
|
||||||
return { |
|
||||||
op: diff.op, |
|
||||||
value, |
|
||||||
path, |
|
||||||
originalValue, |
|
||||||
startLineNumber, |
|
||||||
}; |
|
||||||
}); |
|
||||||
}; |
|
||||||
|
|
||||||
const sortByLineNumber = (diffs: Diff[]) => sortBy(diffs, 'startLineNumber'); |
|
||||||
const groupByPath = (diffs: Diff[]) => |
|
||||||
diffs.reduce<Record<string, Diff[]>>((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'; |
|
||||||
}; |
|
Loading…
Reference in new issue