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 React, { PureComponent } from 'react'; |
||||||
|
import { Spinner, HorizontalGroup } from '@grafana/ui'; |
||||||
import { DashboardModel } from '../../state/DashboardModel'; |
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 { |
interface Props { |
||||||
dashboard: DashboardModel; |
dashboard: DashboardModel; |
||||||
} |
} |
||||||
|
|
||||||
export class VersionsSettings extends PureComponent<Props> { |
type State = { |
||||||
element?: HTMLElement | null; |
isLoading: boolean; |
||||||
angularCmp?: AngularComponent; |
isAppending: boolean; |
||||||
|
versions: DecoratedRevisionModel[]; |
||||||
|
viewMode: 'list' | 'compare'; |
||||||
|
delta: { basic: string; json: string }; |
||||||
|
newInfo?: DecoratedRevisionModel; |
||||||
|
baseInfo?: DecoratedRevisionModel; |
||||||
|
isNewLatest: boolean; |
||||||
|
}; |
||||||
|
|
||||||
componentDidMount() { |
export type DecoratedRevisionModel = RevisionsModel & { |
||||||
const loader = getAngularLoader(); |
createdDateString: string; |
||||||
|
ageString: string; |
||||||
|
checked: boolean; |
||||||
|
}; |
||||||
|
|
||||||
|
export const VERSIONS_FETCH_LIMIT = 10; |
||||||
|
|
||||||
const template = '<gf-dashboard-history dashboard="dashboard" />'; |
export class VersionsSettings extends PureComponent<Props, State> { |
||||||
const scopeProps = { dashboard: this.props.dashboard }; |
limit: number; |
||||||
this.angularCmp = loader.load(this.element, scopeProps, template); |
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() { |
componentDidMount() { |
||||||
if (this.angularCmp) { |
this.getVersions(); |
||||||
this.angularCmp.destroy(); |
} |
||||||
} |
|
||||||
|
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() { |
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"> |
<div ng-if="ctrl.loading"> |
||||||
<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"> |
|
||||||
<spinner inline="true" /> |
<spinner inline="true" /> |
||||||
</spinner> |
<em>Fetching changes…</em> |
||||||
<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> |
|
||||||
</div> |
</div> |
||||||
|
|
||||||
<div ng-if="ctrl.mode === 'compare'"> |
<div ng-if="!ctrl.loading"> |
||||||
<div ng-if="ctrl.loading"> |
<button |
||||||
<spinner inline="true" /> |
type="button" |
||||||
</spinner> |
class="btn btn-danger pull-right" |
||||||
<em>Fetching changes…</em> |
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> |
||||||
|
|
||||||
<div ng-if="!ctrl.loading"> |
<div class="gf-form-button-row"> |
||||||
<button |
<button class="btn btn-secondary" ng-click="ctrl.getDiff('json')">View JSON Diff</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> |
</div> |
||||||
|
|
||||||
|
<div class="delta-html" ng-show="ctrl.diff === 'json'" compile="ctrl.delta.json"></div> |
||||||
</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