mirror of https://github.com/grafana/grafana
Dashboard: migrate version history list (#29970)
* refactor(dashboard): remove redundant directive code from SaveDashboardAsButton * feat(dashboard): initial commit of rendering version history with react * feat(dashboard): append versions, use historySrv, UI as functional components * feat(dashboard): initial commit of versions settings diff view * refactor(historylist): remove code related to listing versions * refactor(dashboard): use angular directive to render version comparison * refactor(dashboard): clean up versions settings * refactor(dashboard): move version history UI components into own files * refactor(dashboard): update typings for version history react components * feat(dashboard): initial commit of react revert dashboard modal * test(dashboardsettings): clean up historylistctrl tests * chore(dashboardsettings): remove unused state variable * test(dashboardsettings): initial commit of VersionSettings component tests * feat(grafana-ui): add className concatenation on Checkbox label * Apply suggestions from code review Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com> * test(dashboardsettings): add more tests for Versions Settings react component * test(dashboardsettings): add test to assert latest badge in Version history table * fix(dashboardsettings): pass string to getDiff instead of react event object * test(dashboardsettings): remove failing test from versions settings * Moved scroll area to content, and fixed colors * Update public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com> * style(dashboardsettings): add new lines to versions settings tests Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com> Co-authored-by: Torkel Ödegaard <torkel@grafana.com>pull/30390/head
parent
0fceca5f73
commit
c0dd1b6d11
@ -0,0 +1,142 @@ |
||||
import React from 'react'; |
||||
import '@testing-library/jest-dom'; |
||||
import { render, screen, waitFor } from '@testing-library/react'; |
||||
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'; |
||||
|
||||
jest.mock('../VersionHistory/HistorySrv'); |
||||
|
||||
describe('VersionSettings', () => { |
||||
const dashboard: any = { |
||||
id: 74, |
||||
version: 7, |
||||
formatDate: jest.fn(() => 'date'), |
||||
getRelativeTime: jest.fn(() => 'time ago'), |
||||
}; |
||||
|
||||
beforeEach(() => { |
||||
jest.resetAllMocks(); |
||||
}); |
||||
|
||||
test('renders a header and a loading indicator followed by results in a table', async () => { |
||||
// @ts-ignore
|
||||
historySrv.getHistoryList.mockResolvedValue(versions); |
||||
render(<VersionsSettings dashboard={dashboard} />); |
||||
|
||||
expect(screen.getByRole('heading', { name: /versions/i })).toBeInTheDocument(); |
||||
expect(screen.queryByText(/fetching history list/i)).toBeInTheDocument(); |
||||
|
||||
await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); |
||||
const tableBodyRows = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row'); |
||||
|
||||
expect(tableBodyRows.length).toBe(versions.length); |
||||
|
||||
const firstRow = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row')[0]; |
||||
|
||||
expect(within(firstRow).getByText(/latest/i)).toBeInTheDocument(); |
||||
expect(within(screen.getByRole('table')).getAllByText(/latest/i)).toHaveLength(1); |
||||
}); |
||||
|
||||
test('does not render buttons if versions === 1', async () => { |
||||
// @ts-ignore
|
||||
historySrv.getHistoryList.mockResolvedValue(versions.slice(0, 1)); |
||||
render(<VersionsSettings dashboard={dashboard} />); |
||||
|
||||
expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); |
||||
expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument(); |
||||
|
||||
await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); |
||||
|
||||
expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); |
||||
expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
test('does not render show more button if versions < VERSIONS_FETCH_LIMIT', async () => { |
||||
// @ts-ignore
|
||||
historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT - 5)); |
||||
render(<VersionsSettings dashboard={dashboard} />); |
||||
|
||||
expect(screen.queryByRole('button', { name: /show more versions|/i })).not.toBeInTheDocument(); |
||||
expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument(); |
||||
|
||||
await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); |
||||
|
||||
expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); |
||||
expect(screen.queryByRole('button', { name: /compare versions/i })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
test('renders buttons if versions >= VERSIONS_FETCH_LIMIT', async () => { |
||||
// @ts-ignore
|
||||
historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT)); |
||||
render(<VersionsSettings dashboard={dashboard} />); |
||||
|
||||
expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); |
||||
expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument(); |
||||
|
||||
await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); |
||||
const compareButton = screen.getByRole('button', { name: /compare versions/i }); |
||||
const showMoreButton = screen.getByRole('button', { name: /show more versions/i }); |
||||
|
||||
expect(showMoreButton).toBeInTheDocument(); |
||||
expect(showMoreButton).toBeEnabled(); |
||||
|
||||
expect(compareButton).toBeInTheDocument(); |
||||
expect(compareButton).toBeDisabled(); |
||||
}); |
||||
|
||||
test('clicking show more appends results to the table', async () => { |
||||
historySrv.getHistoryList |
||||
// @ts-ignore
|
||||
.mockImplementationOnce(() => Promise.resolve(versions.slice(0, VERSIONS_FETCH_LIMIT))) |
||||
.mockImplementationOnce(() => Promise.resolve(versions.slice(VERSIONS_FETCH_LIMIT, versions.length))); |
||||
|
||||
render(<VersionsSettings dashboard={dashboard} />); |
||||
|
||||
expect(historySrv.getHistoryList).toBeCalledTimes(1); |
||||
|
||||
await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); |
||||
|
||||
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(VERSIONS_FETCH_LIMIT); |
||||
|
||||
const showMoreButton = screen.getByRole('button', { name: /show more versions/i }); |
||||
userEvent.click(showMoreButton); |
||||
|
||||
expect(historySrv.getHistoryList).toBeCalledTimes(2); |
||||
expect(screen.queryByText(/Fetching more entries/i)).toBeInTheDocument(); |
||||
|
||||
await waitFor(() => |
||||
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(versions.length) |
||||
); |
||||
}); |
||||
|
||||
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>'); |
||||
|
||||
render(<VersionsSettings dashboard={dashboard} />); |
||||
|
||||
expect(historySrv.getHistoryList).toBeCalledTimes(1); |
||||
|
||||
await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); |
||||
|
||||
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]); |
||||
|
||||
expect(compareButton).toBeEnabled(); |
||||
|
||||
userEvent.click(within(tableBody).getAllByRole('checkbox')[0]); |
||||
|
||||
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());
|
||||
}); |
||||
}); |
||||
@ -1,30 +1,210 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import { Spinner, HorizontalGroup } from '@grafana/ui'; |
||||
import { DashboardModel } from '../../state/DashboardModel'; |
||||
import { AngularComponent, getAngularLoader } from '@grafana/runtime'; |
||||
|
||||
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'; |
||||
interface Props { |
||||
dashboard: DashboardModel; |
||||
} |
||||
|
||||
export class VersionsSettings extends PureComponent<Props> { |
||||
element?: HTMLElement | null; |
||||
angularCmp?: AngularComponent; |
||||
type State = { |
||||
isLoading: boolean; |
||||
isAppending: boolean; |
||||
versions: DecoratedRevisionModel[]; |
||||
viewMode: 'list' | 'compare'; |
||||
delta: { basic: string; json: string }; |
||||
newInfo?: DecoratedRevisionModel; |
||||
baseInfo?: DecoratedRevisionModel; |
||||
isNewLatest: boolean; |
||||
}; |
||||
|
||||
componentDidMount() { |
||||
const loader = getAngularLoader(); |
||||
export type DecoratedRevisionModel = RevisionsModel & { |
||||
createdDateString: string; |
||||
ageString: string; |
||||
checked: boolean; |
||||
}; |
||||
|
||||
export const VERSIONS_FETCH_LIMIT = 10; |
||||
|
||||
const template = '<gf-dashboard-history dashboard="dashboard" />'; |
||||
const scopeProps = { dashboard: this.props.dashboard }; |
||||
this.angularCmp = loader.load(this.element, scopeProps, template); |
||||
export class VersionsSettings extends PureComponent<Props, State> { |
||||
limit: number; |
||||
start: number; |
||||
|
||||
constructor(props: Props) { |
||||
super(props); |
||||
this.limit = VERSIONS_FETCH_LIMIT; |
||||
this.start = 0; |
||||
this.state = { |
||||
delta: { |
||||
basic: '', |
||||
json: '', |
||||
}, |
||||
isAppending: true, |
||||
isLoading: true, |
||||
versions: [], |
||||
viewMode: 'list', |
||||
isNewLatest: false, |
||||
}; |
||||
} |
||||
|
||||
componentWillUnmount() { |
||||
if (this.angularCmp) { |
||||
this.angularCmp.destroy(); |
||||
} |
||||
componentDidMount() { |
||||
this.getVersions(); |
||||
} |
||||
|
||||
getVersions = (append = false) => { |
||||
this.setState({ isAppending: append }); |
||||
historySrv |
||||
.getHistoryList(this.props.dashboard, { limit: this.limit, start: this.start }) |
||||
.then(res => { |
||||
this.setState({ |
||||
isLoading: false, |
||||
versions: [...this.state.versions, ...this.decorateVersions(res)], |
||||
}); |
||||
this.start += this.limit; |
||||
}) |
||||
.catch(err => console.log(err)) |
||||
.finally(() => this.setState({ isAppending: false })); |
||||
}; |
||||
|
||||
getDiff = (diff: string) => { |
||||
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, |
||||
isNewLatest, |
||||
newInfo, |
||||
viewMode: 'compare', |
||||
}); |
||||
|
||||
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[]) => |
||||
versions.map(version => ({ |
||||
...version, |
||||
createdDateString: this.props.dashboard.formatDate(version.created), |
||||
ageString: this.props.dashboard.getRelativeTime(version.created), |
||||
checked: false, |
||||
})); |
||||
|
||||
isLastPage() { |
||||
return this.state.versions.find(rev => rev.version === 1); |
||||
} |
||||
|
||||
onCheck = (ev: React.FormEvent<HTMLInputElement>, versionId: number) => { |
||||
this.setState({ |
||||
versions: this.state.versions.map(version => |
||||
version.id === versionId ? { ...version, checked: ev.currentTarget.checked } : version |
||||
), |
||||
}); |
||||
}; |
||||
|
||||
reset = () => { |
||||
this.setState({ |
||||
baseInfo: undefined, |
||||
delta: { basic: '', json: '' }, |
||||
isNewLatest: false, |
||||
newInfo: undefined, |
||||
versions: this.state.versions.map(version => ({ ...version, checked: false })), |
||||
viewMode: 'list', |
||||
}); |
||||
}; |
||||
|
||||
render() { |
||||
return <div ref={ref => (this.element = ref)} />; |
||||
const { versions, viewMode, baseInfo, newInfo, isNewLatest, isLoading, delta } = this.state; |
||||
const canCompare = versions.filter(version => version.checked).length !== 2; |
||||
const showButtons = versions.length > 1; |
||||
const hasMore = versions.length >= this.limit; |
||||
|
||||
if (viewMode === 'compare') { |
||||
return ( |
||||
<div> |
||||
<VersionHistoryHeader |
||||
isComparing |
||||
onClick={this.reset} |
||||
baseVersion={baseInfo?.version} |
||||
newVersion={newInfo?.version} |
||||
isNewLatest={isNewLatest} |
||||
/> |
||||
{isLoading ? ( |
||||
<VersionsHistorySpinner msg="Fetching changes…" /> |
||||
) : ( |
||||
<VersionHistoryComparison |
||||
dashboard={this.props.dashboard} |
||||
newInfo={newInfo} |
||||
baseInfo={baseInfo} |
||||
isNewLatest={isNewLatest} |
||||
onFetchFail={this.reset} |
||||
delta={delta} |
||||
/> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<div> |
||||
<VersionHistoryHeader /> |
||||
{isLoading ? ( |
||||
<VersionsHistorySpinner msg="Fetching history list…" /> |
||||
) : ( |
||||
<VersionHistoryTable versions={versions} onCheck={this.onCheck} /> |
||||
)} |
||||
{this.state.isAppending && <VersionsHistorySpinner msg="Fetching more entries…" />} |
||||
{showButtons && ( |
||||
<VersionsHistoryButtons |
||||
hasMore={hasMore} |
||||
canCompare={canCompare} |
||||
getVersions={this.getVersions} |
||||
getDiff={this.getDiff} |
||||
isLastPage={!!this.isLastPage()} |
||||
/> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
|
||||
const VersionsHistorySpinner = ({ msg }: { msg: string }) => ( |
||||
<HorizontalGroup> |
||||
<Spinner /> |
||||
<em>{msg}</em> |
||||
</HorizontalGroup> |
||||
); |
||||
|
||||
@ -0,0 +1,112 @@ |
||||
export const versions = [ |
||||
{ |
||||
id: 249, |
||||
dashboardId: 74, |
||||
parentVersion: 10, |
||||
restoredFrom: 0, |
||||
version: 11, |
||||
created: '2021-01-15T14:44:44+01:00', |
||||
createdBy: 'admin', |
||||
message: 'Another day another change...', |
||||
}, |
||||
{ |
||||
id: 247, |
||||
dashboardId: 74, |
||||
parentVersion: 9, |
||||
restoredFrom: 0, |
||||
version: 10, |
||||
created: '2021-01-15T10:19:17+01:00', |
||||
createdBy: 'admin', |
||||
message: '', |
||||
}, |
||||
{ |
||||
id: 246, |
||||
dashboardId: 74, |
||||
parentVersion: 8, |
||||
restoredFrom: 0, |
||||
version: 9, |
||||
created: '2021-01-15T10:18:12+01:00', |
||||
createdBy: 'admin', |
||||
message: '', |
||||
}, |
||||
{ |
||||
id: 245, |
||||
dashboardId: 74, |
||||
parentVersion: 7, |
||||
restoredFrom: 0, |
||||
version: 8, |
||||
created: '2021-01-15T10:11:16+01:00', |
||||
createdBy: 'admin', |
||||
message: '', |
||||
}, |
||||
{ |
||||
id: 239, |
||||
dashboardId: 74, |
||||
parentVersion: 6, |
||||
restoredFrom: 0, |
||||
version: 7, |
||||
created: '2021-01-14T15:14:25+01:00', |
||||
createdBy: 'admin', |
||||
message: '', |
||||
}, |
||||
{ |
||||
id: 237, |
||||
dashboardId: 74, |
||||
parentVersion: 5, |
||||
restoredFrom: 0, |
||||
version: 6, |
||||
created: '2021-01-14T14:55:29+01:00', |
||||
createdBy: 'admin', |
||||
message: '', |
||||
}, |
||||
{ |
||||
id: 236, |
||||
dashboardId: 74, |
||||
parentVersion: 4, |
||||
restoredFrom: 0, |
||||
version: 5, |
||||
created: '2021-01-14T14:28:01+01:00', |
||||
createdBy: 'admin', |
||||
message: '', |
||||
}, |
||||
{ |
||||
id: 218, |
||||
dashboardId: 74, |
||||
parentVersion: 3, |
||||
restoredFrom: 0, |
||||
version: 4, |
||||
created: '2021-01-08T10:45:33+01:00', |
||||
createdBy: 'admin', |
||||
message: '', |
||||
}, |
||||
{ |
||||
id: 217, |
||||
dashboardId: 74, |
||||
parentVersion: 2, |
||||
restoredFrom: 0, |
||||
version: 3, |
||||
created: '2021-01-05T15:41:33+01:00', |
||||
createdBy: 'admin', |
||||
message: '', |
||||
}, |
||||
{ |
||||
id: 216, |
||||
dashboardId: 74, |
||||
parentVersion: 1, |
||||
restoredFrom: 0, |
||||
version: 2, |
||||
created: '2021-01-05T15:01:50+01:00', |
||||
createdBy: 'admin', |
||||
message: '', |
||||
}, |
||||
{ |
||||
id: 215, |
||||
dashboardId: 74, |
||||
parentVersion: 1, |
||||
restoredFrom: 0, |
||||
version: 1, |
||||
created: '2021-01-05T14:59:15+01:00', |
||||
createdBy: 'admin', |
||||
message: '', |
||||
}, |
||||
]; |
||||
@ -0,0 +1,36 @@ |
||||
import React from 'react'; |
||||
import { css } from 'emotion'; |
||||
import { HorizontalGroup, Modal, Button } from '@grafana/ui'; |
||||
import { useDashboardRestore } from './useDashboardRestore'; |
||||
export interface RevertDashboardModalProps { |
||||
hideModal: () => void; |
||||
version: number; |
||||
} |
||||
|
||||
export const RevertDashboardModal: React.FC<RevertDashboardModalProps> = ({ hideModal, version }) => { |
||||
// TODO: how should state.error be handled?
|
||||
const { onRestoreDashboard } = useDashboardRestore(version); |
||||
|
||||
return ( |
||||
<Modal |
||||
isOpen={true} |
||||
title="Restore Version" |
||||
icon="history" |
||||
onDismiss={hideModal} |
||||
className={css` |
||||
text-align: center; |
||||
width: 500px; |
||||
`}
|
||||
> |
||||
<p>Are you sure you want to restore the dashboard to version {version}? All unsaved changes will be lost.</p> |
||||
<HorizontalGroup justify="center"> |
||||
<Button variant="destructive" type="button" onClick={onRestoreDashboard}> |
||||
Yes, restore to version {version} |
||||
</Button> |
||||
<Button variant="secondary" onClick={hideModal}> |
||||
Cancel |
||||
</Button> |
||||
</HorizontalGroup> |
||||
</Modal> |
||||
); |
||||
}; |
||||
@ -0,0 +1,30 @@ |
||||
import React from 'react'; |
||||
import { HorizontalGroup, Tooltip, Button } from '@grafana/ui'; |
||||
|
||||
type VersionsButtonsType = { |
||||
hasMore: boolean; |
||||
canCompare: boolean; |
||||
getVersions: (append: boolean) => void; |
||||
getDiff: (diff: string) => void; |
||||
isLastPage: boolean; |
||||
}; |
||||
export const VersionsHistoryButtons: React.FC<VersionsButtonsType> = ({ |
||||
hasMore, |
||||
canCompare, |
||||
getVersions, |
||||
getDiff, |
||||
isLastPage, |
||||
}) => ( |
||||
<HorizontalGroup> |
||||
{hasMore && ( |
||||
<Button type="button" onClick={() => getVersions(true)} variant="secondary" disabled={isLastPage}> |
||||
Show more versions |
||||
</Button> |
||||
)} |
||||
<Tooltip content="Select 2 versions to start comparing" placement="bottom"> |
||||
<Button type="button" disabled={canCompare} onClick={() => getDiff('basic')} icon="code-branch"> |
||||
Compare versions |
||||
</Button> |
||||
</Tooltip> |
||||
</HorizontalGroup> |
||||
); |
||||
@ -0,0 +1,47 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import { AngularComponent, getAngularLoader } from '@grafana/runtime'; |
||||
import { DashboardModel } from '../../state/DashboardModel'; |
||||
import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings'; |
||||
|
||||
type DiffViewProps = { |
||||
dashboard: DashboardModel; |
||||
isNewLatest: boolean; |
||||
newInfo?: DecoratedRevisionModel; |
||||
baseInfo?: DecoratedRevisionModel; |
||||
delta: { basic: string; json: string }; |
||||
onFetchFail: () => void; |
||||
}; |
||||
|
||||
export class VersionHistoryComparison extends PureComponent<DiffViewProps> { |
||||
element?: HTMLElement | null; |
||||
angularCmp?: AngularComponent; |
||||
|
||||
constructor(props: DiffViewProps) { |
||||
super(props); |
||||
} |
||||
|
||||
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)} />; |
||||
} |
||||
} |
||||
@ -0,0 +1,31 @@ |
||||
import React from 'react'; |
||||
import noop from 'lodash/noop'; |
||||
import { Icon } from '@grafana/ui'; |
||||
|
||||
type VersionHistoryHeaderProps = { |
||||
isComparing?: boolean; |
||||
onClick?: () => void; |
||||
baseVersion?: number; |
||||
newVersion?: number; |
||||
isNewLatest?: boolean; |
||||
}; |
||||
|
||||
export const VersionHistoryHeader: React.FC<VersionHistoryHeaderProps> = ({ |
||||
isComparing = false, |
||||
onClick = noop, |
||||
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>} |
||||
</span> |
||||
)} |
||||
</h3> |
||||
); |
||||
@ -0,0 +1,66 @@ |
||||
import React from 'react'; |
||||
import { css } from 'emotion'; |
||||
import { Checkbox, Button, Tag, ModalsController } from '@grafana/ui'; |
||||
import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings'; |
||||
import { RevertDashboardModal } from './RevertDashboardModal'; |
||||
|
||||
type VersionsTableProps = { |
||||
versions: DecoratedRevisionModel[]; |
||||
onCheck: (ev: React.FormEvent<HTMLInputElement>, versionId: number) => void; |
||||
}; |
||||
export const VersionHistoryTable: React.FC<VersionsTableProps> = ({ versions, onCheck }) => ( |
||||
<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 |
||||
className={css` |
||||
display: inline; |
||||
`}
|
||||
checked={version.checked} |
||||
onChange={ev => onCheck(ev, version.id)} |
||||
/> |
||||
</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,140 +1,39 @@ |
||||
<h3 class="dashboard-settings__header"> |
||||
<a ng-click="ctrl.switchMode('list')">Versions</a> |
||||
<span ng-show="ctrl.mode === 'compare'"> |
||||
<icon name="'angle-right'"></icon> Comparing {{ctrl.baseInfo.version}} |
||||
<icon name="'arrows-h'"></icon> |
||||
{{ctrl.newInfo.version}} |
||||
<cite class="muted" ng-if="ctrl.isNewLatest">(Latest)</cite> |
||||
</span> |
||||
</h3> |
||||
|
||||
<div ng-if="ctrl.mode === 'list'"> |
||||
<div ng-if="ctrl.loading"> |
||||
<div ng-if="ctrl.loading"> |
||||
<spinner inline="true" /> |
||||
</spinner> |
||||
<em>Fetching history list…</em> |
||||
</div> |
||||
|
||||
<div ng-if="!ctrl.loading"> |
||||
<div class="gf-form-group"> |
||||
<table class="filter-table"> |
||||
<thead> |
||||
<tr> |
||||
<th class="width-4"></th> |
||||
<th class="width-4">Version</th> |
||||
<th class="width-14">Date</th> |
||||
<th class="width-10">Updated By</th> |
||||
<th>Notes</th> |
||||
<th></th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
<tr ng-repeat="revision in ctrl.revisions"> |
||||
<td |
||||
bs-tooltip="!revision.checked && ctrl.canCompare ? 'You can only compare 2 versions at a time' : ''" |
||||
data-placement="right" |
||||
> |
||||
<gf-form-checkbox |
||||
switch-class="gf-form-switch--table-cell" |
||||
checked="revision.checked" |
||||
on-change="ctrl.revisionSelectionChanged()" |
||||
ng-disabled="!revision.checked && ctrl.canCompare" |
||||
> |
||||
</gf-form-checkbox> |
||||
</td> |
||||
<td class="text-center">{{revision.version}}</td> |
||||
<td>{{revision.createdDateString}}</td> |
||||
<td>{{revision.createdBy}}</td> |
||||
<td>{{revision.message}}</td> |
||||
<td class="text-right"> |
||||
<a |
||||
class="btn btn-inverse btn-small" |
||||
ng-show="revision.version !== ctrl.dashboard.version" |
||||
ng-click="ctrl.restore(revision.version)" |
||||
> |
||||
<icon name="'history'" size="'xs'" style="margin-bottom: 2px"></icon> Restore |
||||
</a> |
||||
<a class="label label-tag" ng-show="revision.version === ctrl.dashboard.version"> |
||||
Latest |
||||
</a> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
|
||||
<div ng-if="ctrl.appending"> |
||||
<spinner inline="true" /> |
||||
</spinner> |
||||
<em>Fetching more entries…</em> |
||||
</div> |
||||
|
||||
<div class="gf-form-group"> |
||||
<div class="gf-form-button-row"> |
||||
<button |
||||
type="button" |
||||
class="btn gf-form-button btn-inverse" |
||||
ng-if="ctrl.revisions.length >= ctrl.limit" |
||||
ng-click="ctrl.addToLog()" |
||||
ng-disabled="ctrl.isLastPage()" |
||||
> |
||||
Show more versions |
||||
</button> |
||||
<button |
||||
type="button" |
||||
class="btn btn-primary" |
||||
ng-if="ctrl.revisions.length > 1" |
||||
ng-disabled="!ctrl.canCompare" |
||||
ng-click="ctrl.getDiff(ctrl.diff)" |
||||
bs-tooltip="ctrl.canCompare ? '' : 'Select 2 versions to start comparing'" |
||||
data-placement="bottom" |
||||
> |
||||
<icon name="'code-branch'"></icon> Compare versions |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<em>Fetching changes…</em> |
||||
</div> |
||||
|
||||
<div ng-if="ctrl.mode === 'compare'"> |
||||
<div ng-if="ctrl.loading"> |
||||
<spinner inline="true" /> |
||||
</spinner> |
||||
<em>Fetching changes…</em> |
||||
<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 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 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,35 @@ |
||||
import { useEffect } from 'react'; |
||||
import { useSelector, useDispatch } from 'react-redux'; |
||||
import { useAsyncFn } from 'react-use'; |
||||
import { AppEvents, locationUtil } from '@grafana/data'; |
||||
import appEvents from 'app/core/app_events'; |
||||
import { updateLocation } from 'app/core/reducers/location'; |
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; |
||||
import { StoreState } from 'app/types'; |
||||
import { historySrv } from './HistorySrv'; |
||||
import { DashboardModel } from '../../state'; |
||||
|
||||
const restoreDashboard = async (version: number, dashboard: DashboardModel) => { |
||||
return await historySrv.restoreDashboard(dashboard, version); |
||||
}; |
||||
|
||||
export const useDashboardRestore = (version: number) => { |
||||
const dashboard = useSelector((state: StoreState) => state.dashboard.getModel()); |
||||
const dispatch = useDispatch(); |
||||
const [state, onRestoreDashboard] = useAsyncFn(async () => await restoreDashboard(version, dashboard!), []); |
||||
useEffect(() => { |
||||
if (state.value) { |
||||
const newUrl = locationUtil.stripBaseFromUrl(state.value.url); |
||||
dispatch( |
||||
updateLocation({ |
||||
path: newUrl, |
||||
replace: true, |
||||
query: {}, |
||||
}) |
||||
); |
||||
dashboardWatcher.reloadPage(); |
||||
appEvents.emit(AppEvents.alertSuccess, ['Dashboard restored', 'Restored from version ' + version]); |
||||
} |
||||
}, [state]); |
||||
return { state, onRestoreDashboard }; |
||||
}; |
||||
Loading…
Reference in new issue