mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
0fceca5f73
commit
c0dd1b6d11
@ -93,7 +93,7 @@ export const getCheckboxStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
});
|
||||
|
||||
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ label, description, value, onChange, disabled, ...inputProps }, ref) => {
|
||||
({ label, description, value, onChange, disabled, className, ...inputProps }, ref) => {
|
||||
const theme = useTheme();
|
||||
const handleOnChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@ -106,7 +106,7 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
const styles = getCheckboxStyles(theme);
|
||||
|
||||
return (
|
||||
<label className={styles.wrapper}>
|
||||
<label className={cx(styles.wrapper, className)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.input}
|
||||
|
@ -329,14 +329,14 @@ $json-explorer-url-color: #027bff;
|
||||
|
||||
// Changelog and diff
|
||||
// -------------------------
|
||||
$diff-label-bg: $dark-3;
|
||||
$diff-label-bg: ${theme.colors.bg3};
|
||||
$diff-label-fg: $white;
|
||||
|
||||
$diff-group-bg: $dark-9;
|
||||
$diff-group-bg: ${theme.colors.bg2};
|
||||
$diff-arrow-color: $white;
|
||||
|
||||
$diff-json-bg: $dark-9;
|
||||
$diff-json-fg: $gray-5;
|
||||
$diff-json-bg: ${theme.colors.bg2};
|
||||
$diff-json-fg: ${theme.colors.text};
|
||||
|
||||
$diff-json-added: $blue-shade;
|
||||
$diff-json-deleted: $red-shade;
|
||||
|
@ -322,14 +322,14 @@ $json-explorer-url-color: $blue-base;
|
||||
|
||||
// Changelog and diff
|
||||
// -------------------------
|
||||
$diff-label-bg: $gray-7;
|
||||
$diff-label-bg: ${theme.colors.bg3};
|
||||
$diff-label-fg: $gray-2;
|
||||
|
||||
$diff-arrow-color: $dark-2;
|
||||
$diff-group-bg: $gray-6;
|
||||
$diff-group-bg: ${theme.colors.bg2};
|
||||
|
||||
$diff-json-bg: $gray-6;
|
||||
$diff-json-fg: $gray-1;
|
||||
$diff-json-bg: ${theme.colors.bg2};
|
||||
$diff-json-fg: ${theme.colors.text};
|
||||
|
||||
$diff-json-added: $blue-shade;
|
||||
$diff-json-deleted: $red-shade;
|
||||
|
@ -24,10 +24,6 @@ import { LokiAnnotationsQueryEditor } from '../plugins/datasource/loki/component
|
||||
import { HelpModal } from './components/help/HelpModal';
|
||||
import { Footer } from './components/Footer/Footer';
|
||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||
import {
|
||||
SaveDashboardAsButtonConnected,
|
||||
SaveDashboardButtonConnected,
|
||||
} from '../features/dashboard/components/SaveDashboard/SaveDashboardButton';
|
||||
import { SearchField, SearchResults, SearchResultsFilter, SearchWrapper } from '../features/search';
|
||||
import { TimePickerSettings } from 'app/features/dashboard/components/DashboardSettings/TimePickerSettings';
|
||||
|
||||
@ -189,16 +185,6 @@ export function registerAngularDirectives() {
|
||||
['onLoad', { watchDepth: 'reference', wrapApply: true }],
|
||||
['onChange', { watchDepth: 'reference', wrapApply: true }],
|
||||
]);
|
||||
react2AngularDirective('saveDashboardButton', SaveDashboardButtonConnected, [
|
||||
['getDashboard', { watchDepth: 'reference', wrapApply: true }],
|
||||
['onSaveSuccess', { watchDepth: 'reference', wrapApply: true }],
|
||||
['dashboard', { watchDepth: 'reference', wrapApply: true }],
|
||||
]);
|
||||
react2AngularDirective('saveDashboardAsButton', SaveDashboardAsButtonConnected, [
|
||||
'variant',
|
||||
['getDashboard', { watchDepth: 'reference', wrapApply: true }],
|
||||
['onSaveSuccess', { watchDepth: 'reference', wrapApply: true }],
|
||||
]);
|
||||
react2AngularDirective('timePickerSettings', TimePickerSettings, [
|
||||
'renderCount',
|
||||
'refreshIntervals',
|
||||
|
@ -154,7 +154,6 @@ export class DashboardSettings extends PureComponent<Props> {
|
||||
<span>{dashboard.title} / Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<CustomScrollbar>
|
||||
<div className="dashboard-settings__body">
|
||||
<aside className="dashboard-settings__aside">
|
||||
{pages.map(page => (
|
||||
@ -175,10 +174,13 @@ export class DashboardSettings extends PureComponent<Props> {
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="dashboard-settings__scroll">
|
||||
<CustomScrollbar autoHeightMin="100%">
|
||||
<div className="dashboard-settings__content">{currentPage.render()}</div>
|
||||
</div>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
||||
export type DecoratedRevisionModel = RevisionsModel & {
|
||||
createdDateString: string;
|
||||
ageString: string;
|
||||
checked: boolean;
|
||||
};
|
||||
|
||||
export const VERSIONS_FETCH_LIMIT = 10;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const loader = getAngularLoader();
|
||||
|
||||
const template = '<gf-dashboard-history dashboard="dashboard" />';
|
||||
const scopeProps = { dashboard: this.props.dashboard };
|
||||
this.angularCmp = loader.load(this.element, scopeProps, template);
|
||||
this.getVersions();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.angularCmp) {
|
||||
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() {
|
||||
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: '',
|
||||
},
|
||||
];
|
@ -1,23 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Button, ButtonVariant, ModalsController, FullWidthButtonContainer } from '@grafana/ui';
|
||||
import { DashboardModel } from 'app/features/dashboard/state';
|
||||
import { connectWithProvider } from 'app/core/utils/connectWithReduxStore';
|
||||
import { provideModalsContext } from 'app/routes/ReactContainer';
|
||||
import { SaveDashboardAsModal } from './SaveDashboardAsModal';
|
||||
import { SaveDashboardModalProxy } from './SaveDashboardModalProxy';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
interface SaveDashboardButtonProps {
|
||||
dashboard: DashboardModel;
|
||||
/**
|
||||
* Added for being able to render this component as Angular directive!
|
||||
* TODO[angular-migrations]: Remove when we migrate Dashboard Settings view to React
|
||||
*/
|
||||
getDashboard?: () => DashboardModel;
|
||||
onSaveSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const SaveDashboardButton: React.FC<SaveDashboardButtonProps> = ({ dashboard, onSaveSuccess, getDashboard }) => {
|
||||
export const SaveDashboardButton: React.FC<SaveDashboardButtonProps> = ({ dashboard, onSaveSuccess }) => {
|
||||
return (
|
||||
<ModalsController>
|
||||
{({ showModal, hideModal }) => {
|
||||
@ -25,8 +18,7 @@ export const SaveDashboardButton: React.FC<SaveDashboardButtonProps> = ({ dashbo
|
||||
<Button
|
||||
onClick={() => {
|
||||
showModal(SaveDashboardModalProxy, {
|
||||
// TODO[angular-migrations]: Remove tenary op when we migrate Dashboard Settings view to React
|
||||
dashboard: getDashboard ? getDashboard() : dashboard,
|
||||
dashboard,
|
||||
onSaveSuccess,
|
||||
onDismiss: hideModal,
|
||||
});
|
||||
@ -44,7 +36,6 @@ export const SaveDashboardButton: React.FC<SaveDashboardButtonProps> = ({ dashbo
|
||||
export const SaveDashboardAsButton: React.FC<SaveDashboardButtonProps & { variant?: ButtonVariant }> = ({
|
||||
dashboard,
|
||||
onSaveSuccess,
|
||||
getDashboard,
|
||||
variant,
|
||||
}) => {
|
||||
return (
|
||||
@ -55,16 +46,12 @@ export const SaveDashboardAsButton: React.FC<SaveDashboardButtonProps & { varian
|
||||
<Button
|
||||
onClick={() => {
|
||||
showModal(SaveDashboardAsModal, {
|
||||
// TODO[angular-migrations]: Remove tenary op when we migrate Dashboard Settings view to React
|
||||
dashboard: getDashboard ? getDashboard() : dashboard,
|
||||
dashboard,
|
||||
onSaveSuccess,
|
||||
onDismiss: hideModal,
|
||||
});
|
||||
}}
|
||||
// TODO[angular-migrations]: Hacking the different variants for this single button
|
||||
// In Dashboard Settings in sidebar we need to use new form but with inverse variant to make it look like it should
|
||||
// Everywhere else we use old button component :(
|
||||
variant={variant as ButtonVariant}
|
||||
variant={variant}
|
||||
aria-label={selectors.pages.Dashboard.Settings.General.saveAsDashBoard}
|
||||
>
|
||||
Save As...
|
||||
@ -75,8 +62,3 @@ export const SaveDashboardAsButton: React.FC<SaveDashboardButtonProps & { varian
|
||||
</ModalsController>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: this is an ugly solution for the save button to have access to Redux and Modals controller
|
||||
// When we migrate dashboard settings to Angular it won't be necessary.
|
||||
export const SaveDashboardButtonConnected = connectWithProvider(provideModalsContext(SaveDashboardButton));
|
||||
export const SaveDashboardAsButtonConnected = connectWithProvider(provideModalsContext(SaveDashboardAsButton));
|
||||
|
@ -4,16 +4,6 @@ import { IScope } from 'angular';
|
||||
import { HistoryListCtrl } from './HistoryListCtrl';
|
||||
import { compare, restore, versions } from './__mocks__/history';
|
||||
import { CoreEvents } from 'app/types';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
|
||||
jest.mock('app/core/app_events', () => {
|
||||
return {
|
||||
appEvents: {
|
||||
emit: jest.fn(),
|
||||
on: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('HistoryListCtrl', () => {
|
||||
const RESTORE_ID = 4;
|
||||
@ -38,9 +28,8 @@ describe('HistoryListCtrl', () => {
|
||||
});
|
||||
|
||||
describe('when the history list component is loaded', () => {
|
||||
beforeEach(() => {
|
||||
historySrv.getHistoryList = jest.fn(() => Promise.resolve({}));
|
||||
|
||||
beforeEach(async () => {
|
||||
historySrv.getHistoryList = jest.fn(() => Promise.resolve(versionsResponse));
|
||||
historyListCtrl = new HistoryListCtrl({}, $rootScope, {} as any, historySrv, $scope);
|
||||
|
||||
historyListCtrl.dashboard = {
|
||||
@ -49,173 +38,38 @@ describe('HistoryListCtrl', () => {
|
||||
formatDate: jest.fn(() => 'date'),
|
||||
getRelativeTime: jest.fn(() => 'time ago'),
|
||||
};
|
||||
});
|
||||
|
||||
it('should immediately attempt to fetch the history list', () => {
|
||||
expect(historySrv.getHistoryList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('and the history list is successfully fetched', () => {
|
||||
beforeEach(async () => {
|
||||
historySrv.getHistoryList = jest.fn(() => Promise.resolve(versionsResponse));
|
||||
await historyListCtrl.getLog();
|
||||
});
|
||||
|
||||
it("should reset the controller's state", async () => {
|
||||
expect(historyListCtrl.mode).toBe('list');
|
||||
expect(historyListCtrl.delta).toEqual({ basic: '', json: '' });
|
||||
|
||||
expect(historyListCtrl.canCompare).toBe(false);
|
||||
expect(_.find(historyListCtrl.revisions, rev => rev.checked)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should indicate loading has finished', () => {
|
||||
expect(historyListCtrl.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should store the revisions sorted desc by version id', () => {
|
||||
expect(historyListCtrl.revisions[0].version).toBe(4);
|
||||
expect(historyListCtrl.revisions[1].version).toBe(3);
|
||||
expect(historyListCtrl.revisions[2].version).toBe(2);
|
||||
expect(historyListCtrl.revisions[3].version).toBe(1);
|
||||
});
|
||||
|
||||
it('should add a checked property to each revision', () => {
|
||||
const actual = _.filter(historyListCtrl.revisions, rev => rev.hasOwnProperty('checked'));
|
||||
expect(actual.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should set all checked properties to false on reset', () => {
|
||||
historyListCtrl.revisions[0].checked = true;
|
||||
historyListCtrl.revisions[2].checked = true;
|
||||
historyListCtrl.reset();
|
||||
const actual = _.filter(historyListCtrl.revisions, rev => !rev.checked);
|
||||
expect(actual.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and fetching the history list fails', () => {
|
||||
beforeEach(async () => {
|
||||
historySrv.getHistoryList = jest.fn(() => Promise.reject(new Error('HistoryListError')));
|
||||
|
||||
historyListCtrl = new HistoryListCtrl({}, $rootScope, {} as any, historySrv, $scope);
|
||||
|
||||
await historyListCtrl.getLog();
|
||||
});
|
||||
|
||||
it("should reset the controller's state", () => {
|
||||
expect(historyListCtrl.mode).toBe('list');
|
||||
expect(historyListCtrl.delta).toEqual({ basic: '', json: '' });
|
||||
expect(_.find(historyListCtrl.revisions, rev => rev.checked)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should indicate loading has finished', () => {
|
||||
expect(historyListCtrl.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should have an empty revisions list', () => {
|
||||
expect(historyListCtrl.revisions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should update the history list when the dashboard is saved', () => {
|
||||
beforeEach(() => {
|
||||
historyListCtrl.dashboard = { version: 3 };
|
||||
historyListCtrl.resetFromSource = jest.fn();
|
||||
});
|
||||
|
||||
it('should listen for the `dashboardSaved` appEvent', () => {
|
||||
// @ts-ignore
|
||||
expect(appEvents.on.mock.calls[0][0]).toBe(CoreEvents.dashboardSaved);
|
||||
});
|
||||
|
||||
it('should call `onDashboardSaved` when the appEvent is received', () => {
|
||||
// @ts-ignore
|
||||
expect(appEvents.on.mock.calls[0][1]).not.toBe(historyListCtrl.onDashboardSaved);
|
||||
// @ts-ignore
|
||||
expect(appEvents.on.mock.calls[0][1].toString).toBe(historyListCtrl.onDashboardSaved.toString);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user wants to compare two revisions', () => {
|
||||
beforeEach(async () => {
|
||||
historySrv.getHistoryList = jest.fn(() => Promise.resolve(versionsResponse));
|
||||
historySrv.calculateDiff = jest.fn(() => Promise.resolve({}));
|
||||
|
||||
historyListCtrl = new HistoryListCtrl({}, $rootScope, {} as any, historySrv, $scope);
|
||||
|
||||
historyListCtrl.dashboard = {
|
||||
id: 2,
|
||||
version: 3,
|
||||
formatDate: jest.fn(() => 'date'),
|
||||
getRelativeTime: jest.fn(() => 'time ago'),
|
||||
};
|
||||
|
||||
historySrv.calculateDiff = jest.fn(() => Promise.resolve(versionsResponse));
|
||||
await historyListCtrl.getLog();
|
||||
});
|
||||
|
||||
it('should have already fetched the history list', () => {
|
||||
expect(historySrv.getHistoryList).toHaveBeenCalled();
|
||||
expect(historyListCtrl.revisions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should check that two valid versions are selected', () => {
|
||||
// []
|
||||
expect(historyListCtrl.canCompare).toBe(false);
|
||||
|
||||
// single value
|
||||
historyListCtrl.revisions = [{ checked: true }];
|
||||
historyListCtrl.revisionSelectionChanged();
|
||||
expect(historyListCtrl.canCompare).toBe(false);
|
||||
|
||||
// both values in range
|
||||
historyListCtrl.revisions = [{ checked: true }, { checked: true }];
|
||||
historyListCtrl.revisionSelectionChanged();
|
||||
expect(historyListCtrl.canCompare).toBe(true);
|
||||
});
|
||||
|
||||
describe('and the basic diff is successfully fetched', () => {
|
||||
beforeEach(async () => {
|
||||
historySrv.calculateDiff = jest.fn(() => Promise.resolve(compare('basic')));
|
||||
historyListCtrl.revisions[1].checked = true;
|
||||
historyListCtrl.revisions[3].checked = true;
|
||||
await historyListCtrl.getDiff('basic');
|
||||
historyListCtrl.delta = {
|
||||
basic: '<div></div>',
|
||||
json: '',
|
||||
};
|
||||
historyListCtrl.baseInfo = { version: 1 };
|
||||
historyListCtrl.newInfo = { version: 2 };
|
||||
historyListCtrl.isNewLatest = false;
|
||||
});
|
||||
|
||||
it('should fetch the basic diff if two valid versions are selected', () => {
|
||||
expect(historySrv.calculateDiff).toHaveBeenCalledTimes(1);
|
||||
it('should have basic diff state', () => {
|
||||
expect(historyListCtrl.delta.basic).toBe('<div></div>');
|
||||
expect(historyListCtrl.delta.json).toBe('');
|
||||
});
|
||||
|
||||
it('should set the basic diff view as active', () => {
|
||||
expect(historyListCtrl.mode).toBe('compare');
|
||||
expect(historyListCtrl.diff).toBe('basic');
|
||||
});
|
||||
|
||||
it('should indicate loading has finished', () => {
|
||||
expect(historyListCtrl.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and the json diff is successfully fetched', () => {
|
||||
beforeEach(async () => {
|
||||
historySrv.calculateDiff = jest.fn(() => Promise.resolve(compare('json')));
|
||||
historyListCtrl.revisions[1].checked = true;
|
||||
historyListCtrl.revisions[3].checked = true;
|
||||
await historyListCtrl.getDiff('json');
|
||||
});
|
||||
|
||||
it('should fetch the json diff if two valid versions are selected', () => {
|
||||
it('should fetch the json diff', () => {
|
||||
expect(historySrv.calculateDiff).toHaveBeenCalledTimes(1);
|
||||
expect(historyListCtrl.delta.basic).toBe('');
|
||||
expect(historyListCtrl.delta.json).toBe('<pre><code></code></pre>');
|
||||
});
|
||||
|
||||
it('should set the json diff view as active', () => {
|
||||
expect(historyListCtrl.mode).toBe('compare');
|
||||
expect(historyListCtrl.diff).toBe('json');
|
||||
});
|
||||
|
||||
@ -227,9 +81,6 @@ describe('HistoryListCtrl', () => {
|
||||
describe('and diffs have already been fetched', () => {
|
||||
beforeEach(async () => {
|
||||
historySrv.calculateDiff = jest.fn(() => Promise.resolve(compare('basic')));
|
||||
|
||||
historyListCtrl.revisions[3].checked = true;
|
||||
historyListCtrl.revisions[1].checked = true;
|
||||
historyListCtrl.delta.basic = 'cached basic';
|
||||
historyListCtrl.getDiff('basic');
|
||||
await historySrv.calculateDiff();
|
||||
@ -248,26 +99,28 @@ describe('HistoryListCtrl', () => {
|
||||
describe('and fetching the diff fails', () => {
|
||||
beforeEach(async () => {
|
||||
historySrv.calculateDiff = jest.fn(() => Promise.reject());
|
||||
|
||||
historyListCtrl.revisions[3].checked = true;
|
||||
historyListCtrl.revisions[1].checked = true;
|
||||
await historyListCtrl.getDiff('basic');
|
||||
historyListCtrl.onFetchFail = jest.fn();
|
||||
historyListCtrl.delta = {
|
||||
basic: '<div></div>',
|
||||
json: '',
|
||||
};
|
||||
await historyListCtrl.getDiff('json');
|
||||
});
|
||||
|
||||
it('should fetch the diff if two valid versions are selected', () => {
|
||||
it('should call calculateDiff', () => {
|
||||
expect(historySrv.calculateDiff).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return to the history list view', () => {
|
||||
expect(historyListCtrl.mode).toBe('list');
|
||||
it('should call onFetchFail', () => {
|
||||
expect(historyListCtrl.onFetchFail).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should indicate loading has finished', () => {
|
||||
expect(historyListCtrl.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should have an empty delta/changeset', () => {
|
||||
expect(historyListCtrl.delta).toEqual({ basic: '', json: '' });
|
||||
it('should have a default delta/changeset', () => {
|
||||
expect(historyListCtrl.delta).toEqual({ basic: '<div></div>', json: '' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -284,7 +137,6 @@ describe('HistoryListCtrl', () => {
|
||||
};
|
||||
historyListCtrl.restore();
|
||||
historySrv.restoreDashboard = jest.fn(() => Promise.resolve(versionsResponse));
|
||||
await historyListCtrl.getLog();
|
||||
});
|
||||
|
||||
it('should display a modal allowing the user to restore or cancel', () => {
|
||||
@ -299,7 +151,6 @@ describe('HistoryListCtrl', () => {
|
||||
historyListCtrl = new HistoryListCtrl({}, $rootScope, {} as any, historySrv, $scope);
|
||||
historySrv.restoreDashboard = jest.fn(() => Promise.reject(new Error('RestoreError')));
|
||||
historyListCtrl.restoreConfirm(RESTORE_ID);
|
||||
await historyListCtrl.getLog();
|
||||
});
|
||||
|
||||
it('should indicate loading has finished', () => {
|
||||
|
@ -2,28 +2,22 @@ import _ from 'lodash';
|
||||
import angular, { ILocationService, IScope } from 'angular';
|
||||
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
import { CalculateDiffOptions, HistoryListOpts, HistorySrv, RevisionsModel } from './HistorySrv';
|
||||
import { AppEvents, DateTimeInput, locationUtil } from '@grafana/data';
|
||||
import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings';
|
||||
import { CalculateDiffOptions, HistorySrv } from './HistorySrv';
|
||||
import { AppEvents, locationUtil } from '@grafana/data';
|
||||
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
||||
import { CoreEvents } from 'app/types';
|
||||
import { promiseToDigest } from '../../../../core/utils/promiseToDigest';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
|
||||
export class HistoryListCtrl {
|
||||
appending: boolean;
|
||||
dashboard: DashboardModel;
|
||||
delta: { basic: string; json: string };
|
||||
diff: string;
|
||||
limit: number;
|
||||
loading: boolean;
|
||||
max: number;
|
||||
mode: string;
|
||||
revisions: RevisionsModel[];
|
||||
start: number;
|
||||
newInfo: RevisionsModel;
|
||||
baseInfo: RevisionsModel;
|
||||
canCompare: boolean;
|
||||
newInfo: DecoratedRevisionModel;
|
||||
baseInfo: DecoratedRevisionModel;
|
||||
isNewLatest: boolean;
|
||||
onFetchFail: () => void;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(
|
||||
@ -33,65 +27,17 @@ export class HistoryListCtrl {
|
||||
private historySrv: HistorySrv,
|
||||
public $scope: IScope
|
||||
) {
|
||||
this.appending = false;
|
||||
this.diff = 'basic';
|
||||
this.limit = 10;
|
||||
this.loading = false;
|
||||
this.max = 2;
|
||||
this.mode = 'list';
|
||||
this.start = 0;
|
||||
this.canCompare = false;
|
||||
|
||||
appEvents.on(CoreEvents.dashboardSaved, this.onDashboardSaved.bind(this), $scope);
|
||||
this.resetFromSource();
|
||||
}
|
||||
|
||||
onDashboardSaved() {
|
||||
this.resetFromSource();
|
||||
}
|
||||
|
||||
switchMode(mode: string) {
|
||||
this.mode = mode;
|
||||
if (this.mode === 'list') {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
dismiss() {}
|
||||
|
||||
addToLog() {
|
||||
this.start = this.start + this.limit;
|
||||
this.getLog(true);
|
||||
}
|
||||
|
||||
revisionSelectionChanged() {
|
||||
const selected = _.filter(this.revisions, { checked: true }).length;
|
||||
this.canCompare = selected === 2;
|
||||
}
|
||||
|
||||
formatDate(date: DateTimeInput) {
|
||||
return this.dashboard.formatDate(date);
|
||||
}
|
||||
|
||||
formatBasicDate(date: DateTimeInput) {
|
||||
return this.dashboard.getRelativeTime(date);
|
||||
}
|
||||
|
||||
getDiff(diff: 'basic' | 'json') {
|
||||
this.diff = diff;
|
||||
this.mode = 'compare';
|
||||
|
||||
// has it already been fetched?
|
||||
if (this.delta[diff]) {
|
||||
return Promise.resolve(this.delta[diff]);
|
||||
}
|
||||
|
||||
const selected = _.filter(this.revisions, { checked: true });
|
||||
|
||||
this.newInfo = selected[0];
|
||||
this.baseInfo = selected[1];
|
||||
this.isNewLatest = this.newInfo.version === this.dashboard.version;
|
||||
|
||||
this.loading = true;
|
||||
const options: CalculateDiffOptions = {
|
||||
new: {
|
||||
@ -112,65 +58,13 @@ export class HistoryListCtrl {
|
||||
// @ts-ignore
|
||||
this.delta[this.diff] = response;
|
||||
})
|
||||
.catch(() => {
|
||||
this.mode = 'list';
|
||||
})
|
||||
.catch(this.onFetchFail)
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getLog(append = false) {
|
||||
this.loading = !append;
|
||||
this.appending = append;
|
||||
const options: HistoryListOpts = {
|
||||
limit: this.limit,
|
||||
start: this.start,
|
||||
};
|
||||
|
||||
return promiseToDigest(this.$scope)(
|
||||
this.historySrv
|
||||
.getHistoryList(this.dashboard, options)
|
||||
.then((revisions: any) => {
|
||||
// set formatted dates & default values
|
||||
for (const rev of revisions) {
|
||||
rev.createdDateString = this.formatDate(rev.created);
|
||||
rev.ageString = this.formatBasicDate(rev.created);
|
||||
rev.checked = false;
|
||||
}
|
||||
|
||||
this.revisions = append ? this.revisions.concat(revisions) : revisions;
|
||||
})
|
||||
.catch((err: any) => {
|
||||
this.loading = false;
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
this.appending = false;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
isLastPage() {
|
||||
return _.find(this.revisions, rev => rev.version === 1);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.delta = { basic: '', json: '' };
|
||||
this.diff = 'basic';
|
||||
this.mode = 'list';
|
||||
this.revisions = _.map(this.revisions, rev => _.extend({}, rev, { checked: false }));
|
||||
this.canCompare = false;
|
||||
this.start = 0;
|
||||
this.isNewLatest = false;
|
||||
}
|
||||
|
||||
resetFromSource() {
|
||||
this.revisions = [];
|
||||
return this.getLog().then(this.reset.bind(this));
|
||||
}
|
||||
|
||||
restore(version: number) {
|
||||
this.$rootScope.appEvent(CoreEvents.showConfirmModal, {
|
||||
title: 'Restore version',
|
||||
@ -193,7 +87,6 @@ export class HistoryListCtrl {
|
||||
this.$rootScope.appEvent(AppEvents.alertSuccess, ['Dashboard restored', 'Restored from version ' + version]);
|
||||
})
|
||||
.catch(() => {
|
||||
this.mode = 'list';
|
||||
this.loading = false;
|
||||
})
|
||||
);
|
||||
@ -209,6 +102,11 @@ export function dashboardHistoryDirective() {
|
||||
controllerAs: 'ctrl',
|
||||
scope: {
|
||||
dashboard: '=',
|
||||
delta: '=',
|
||||
baseInfo: '=baseinfo',
|
||||
newInfo: '=newinfo',
|
||||
isNewLatest: '=isnewlatest',
|
||||
onFetchFail: '=onfetchfail',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -49,4 +49,7 @@ export class HistorySrv {
|
||||
}
|
||||
}
|
||||
|
||||
const historySrv = new HistorySrv();
|
||||
export { historySrv };
|
||||
|
||||
coreModule.service('historySrv', HistorySrv);
|
||||
|
@ -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,109 +1,9 @@
|
||||
<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>
|
||||
|
||||
<div ng-if="!ctrl.loading">
|
||||
<div ng-if="!ctrl.loading">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger pull-right"
|
||||
@ -136,5 +36,4 @@
|
||||
</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 };
|
||||
};
|
@ -331,14 +331,14 @@ $json-explorer-url-color: #027bff;
|
||||
|
||||
// Changelog and diff
|
||||
// -------------------------
|
||||
$diff-label-bg: $dark-3;
|
||||
$diff-label-bg: #2c3235;
|
||||
$diff-label-fg: $white;
|
||||
|
||||
$diff-group-bg: $dark-9;
|
||||
$diff-group-bg: #202226;
|
||||
$diff-arrow-color: $white;
|
||||
|
||||
$diff-json-bg: $dark-9;
|
||||
$diff-json-fg: $gray-5;
|
||||
$diff-json-bg: #202226;
|
||||
$diff-json-fg: #c7d0d9;
|
||||
|
||||
$diff-json-added: $blue-shade;
|
||||
$diff-json-deleted: $red-shade;
|
||||
|
@ -324,14 +324,14 @@ $json-explorer-url-color: $blue-base;
|
||||
|
||||
// Changelog and diff
|
||||
// -------------------------
|
||||
$diff-label-bg: $gray-7;
|
||||
$diff-label-bg: #dce1e6;
|
||||
$diff-label-fg: $gray-2;
|
||||
|
||||
$diff-arrow-color: $dark-2;
|
||||
$diff-group-bg: $gray-6;
|
||||
$diff-group-bg: #f1f5f9;
|
||||
|
||||
$diff-json-bg: $gray-6;
|
||||
$diff-json-fg: $gray-1;
|
||||
$diff-json-bg: #f1f5f9;
|
||||
$diff-json-fg: #464c54;
|
||||
|
||||
$diff-json-added: $blue-shade;
|
||||
$diff-json-deleted: $red-shade;
|
||||
|
@ -20,6 +20,12 @@
|
||||
background: $panel-bg;
|
||||
}
|
||||
|
||||
.dashboard-settings__scroll {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dashboard-settings__content {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
|
Loading…
Reference in New Issue
Block a user