Commit c0dd1b6d by Jack Westbrook Committed by GitHub

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>
parent 0fceca5f
......@@ -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,30 +154,32 @@ 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 => (
<a
className={cx('dashboard-settings__nav-item', { active: page.id === editview })}
aria-label={selectors.pages.Dashboard.Settings.General.sectionItems(page.title)}
onClick={() => this.onChangePage(page.id)}
key={page.id}
>
<Icon name={page.icon} style={{ marginRight: '4px' }} />
{page.title}
</a>
))}
<div className="dashboard-settings__aside-actions">
{canSave && <SaveDashboardButton dashboard={dashboard} onSaveSuccess={this.onPostSave} />}
{canSaveAs && (
<SaveDashboardAsButton dashboard={dashboard} onSaveSuccess={this.onPostSave} variant="secondary" />
)}
</div>
</aside>
<div className="dashboard-settings__content">{currentPage.render()}</div>
<div className="dashboard-settings__body">
<aside className="dashboard-settings__aside">
{pages.map(page => (
<a
className={cx('dashboard-settings__nav-item', { active: page.id === editview })}
aria-label={selectors.pages.Dashboard.Settings.General.sectionItems(page.title)}
onClick={() => this.onChangePage(page.id)}
key={page.id}
>
<Icon name={page.icon} style={{ marginRight: '4px' }} />
{page.title}
</a>
))}
<div className="dashboard-settings__aside-actions">
{canSave && <SaveDashboardButton dashboard={dashboard} onSaveSuccess={this.onPostSave} />}
{canSaveAs && (
<SaveDashboardAsButton dashboard={dashboard} onSaveSuccess={this.onPostSave} variant="secondary" />
)}
</div>
</aside>
<div className="dashboard-settings__scroll">
<CustomScrollbar autoHeightMin="100%">
<div className="dashboard-settings__content">{currentPage.render()}</div>
</CustomScrollbar>
</div>
</CustomScrollbar>
</div>
</div>
);
}
......
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());
});
});
import React, { PureComponent } from 'react';
import { Spinner, HorizontalGroup } from '@grafana/ui';
import { DashboardModel } from '../../state/DashboardModel';
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
import { historySrv, RevisionsModel, CalculateDiffOptions } from '../VersionHistory/HistorySrv';
import { VersionHistoryTable } from '../VersionHistory/VersionHistoryTable';
import { VersionHistoryHeader } from '../VersionHistory/VersionHistoryHeader';
import { VersionsHistoryButtons } from '../VersionHistory/VersionHistoryButtons';
import { VersionHistoryComparison } from '../VersionHistory/VersionHistoryComparison';
interface Props {
dashboard: DashboardModel;
}
export class VersionsSettings extends PureComponent<Props> {
element?: HTMLElement | null;
angularCmp?: AngularComponent;
type State = {
isLoading: boolean;
isAppending: boolean;
versions: DecoratedRevisionModel[];
viewMode: 'list' | 'compare';
delta: { basic: string; json: string };
newInfo?: DecoratedRevisionModel;
baseInfo?: DecoratedRevisionModel;
isNewLatest: boolean;
};
componentDidMount() {
const loader = getAngularLoader();
export type DecoratedRevisionModel = RevisionsModel & {
createdDateString: string;
ageString: string;
checked: boolean;
};
export const VERSIONS_FETCH_LIMIT = 10;
const template = '<gf-dashboard-history dashboard="dashboard" />';
const scopeProps = { dashboard: this.props.dashboard };
this.angularCmp = loader.load(this.element, scopeProps, template);
export class VersionsSettings extends PureComponent<Props, State> {
limit: number;
start: number;
constructor(props: Props) {
super(props);
this.limit = VERSIONS_FETCH_LIMIT;
this.start = 0;
this.state = {
delta: {
basic: '',
json: '',
},
isAppending: true,
isLoading: true,
versions: [],
viewMode: 'list',
isNewLatest: false,
};
}
componentWillUnmount() {
if (this.angularCmp) {
this.angularCmp.destroy();
}
componentDidMount() {
this.getVersions();
}
getVersions = (append = false) => {
this.setState({ isAppending: append });
historySrv
.getHistoryList(this.props.dashboard, { limit: this.limit, start: this.start })
.then(res => {
this.setState({
isLoading: false,
versions: [...this.state.versions, ...this.decorateVersions(res)],
});
this.start += this.limit;
})
.catch(err => console.log(err))
.finally(() => this.setState({ isAppending: false }));
};
getDiff = (diff: string) => {
const selectedVersions = this.state.versions.filter(version => version.checked);
const [newInfo, baseInfo] = selectedVersions;
const isNewLatest = newInfo.version === this.props.dashboard.version;
this.setState({
baseInfo,
isLoading: true,
isNewLatest,
newInfo,
viewMode: 'compare',
});
const options: CalculateDiffOptions = {
new: {
dashboardId: this.props.dashboard.id,
version: newInfo.version,
},
base: {
dashboardId: this.props.dashboard.id,
version: baseInfo.version,
},
diffType: diff,
};
return historySrv
.calculateDiff(options)
.then((response: any) => {
this.setState({
// @ts-ignore
delta: {
[diff]: response,
},
});
})
.catch(() => {
this.setState({
viewMode: 'list',
});
})
.finally(() => {
this.setState({
isLoading: false,
});
});
};
decorateVersions = (versions: RevisionsModel[]) =>
versions.map(version => ({
...version,
createdDateString: this.props.dashboard.formatDate(version.created),
ageString: this.props.dashboard.getRelativeTime(version.created),
checked: false,
}));
isLastPage() {
return this.state.versions.find(rev => rev.version === 1);
}
onCheck = (ev: React.FormEvent<HTMLInputElement>, versionId: number) => {
this.setState({
versions: this.state.versions.map(version =>
version.id === versionId ? { ...version, checked: ev.currentTarget.checked } : version
),
});
};
reset = () => {
this.setState({
baseInfo: undefined,
delta: { basic: '', json: '' },
isNewLatest: false,
newInfo: undefined,
versions: this.state.versions.map(version => ({ ...version, checked: false })),
viewMode: 'list',
});
};
render() {
return <div ref={ref => (this.element = ref)} />;
const { versions, viewMode, baseInfo, newInfo, isNewLatest, isLoading, delta } = this.state;
const canCompare = versions.filter(version => version.checked).length !== 2;
const showButtons = versions.length > 1;
const hasMore = versions.length >= this.limit;
if (viewMode === 'compare') {
return (
<div>
<VersionHistoryHeader
isComparing
onClick={this.reset}
baseVersion={baseInfo?.version}
newVersion={newInfo?.version}
isNewLatest={isNewLatest}
/>
{isLoading ? (
<VersionsHistorySpinner msg="Fetching changes&hellip;" />
) : (
<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&hellip;" />
) : (
<VersionHistoryTable versions={versions} onCheck={this.onCheck} />
)}
{this.state.isAppending && <VersionsHistorySpinner msg="Fetching more entries&hellip;" />}
{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>
);
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: '',
},
];
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));
......@@ -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);
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>
);
};
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>
);
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)} />;
}
}
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>
);
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>
);
<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&hellip;</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>&nbsp;&nbsp;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&hellip;</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>&nbsp;&nbsp;Compare versions
</button>
</div>
</div>
</div>
</div>
<em>Fetching changes&hellip;</em>
</div>
<div ng-if="ctrl.mode === 'compare'">
<div ng-if="ctrl.loading">
<spinner inline="true" />
</spinner>
<em>Fetching changes&hellip;</em>
<div ng-if="!ctrl.loading">
<button
type="button"
class="btn btn-danger pull-right"
ng-click="ctrl.restore(ctrl.baseInfo.version)"
ng-if="ctrl.isNewLatest"
>
<icon name="'history'"></icon>&nbsp;&nbsp;Restore to version {{ctrl.baseInfo.version}}
</button>
<section>
<p class="small muted">
<strong>Version {{ctrl.newInfo.version}}</strong> updated by
<span>{{ctrl.newInfo.createdBy}} </span>
<span>{{ctrl.newInfo.ageString}}</span>
<span> - {{ctrl.newInfo.message}}</span>
</p>
<p class="small muted">
<strong>Version {{ctrl.baseInfo.version}}</strong> updated by
<span>{{ctrl.baseInfo.createdBy}} </span>
<span>{{ctrl.baseInfo.ageString}}</span>
<span> - {{ctrl.baseInfo.message}}</span>
</p>
</section>
<div id="delta" diff-delta>
<div class="delta-basic" compile="ctrl.delta.basic"></div>
</div>
<div ng-if="!ctrl.loading">
<button
type="button"
class="btn btn-danger pull-right"
ng-click="ctrl.restore(ctrl.baseInfo.version)"
ng-if="ctrl.isNewLatest"
>
<icon name="'history'"></icon>&nbsp;&nbsp;Restore to version {{ctrl.baseInfo.version}}
</button>
<section>
<p class="small muted">
<strong>Version {{ctrl.newInfo.version}}</strong> updated by
<span>{{ctrl.newInfo.createdBy}} </span>
<span>{{ctrl.newInfo.ageString}}</span>
<span> - {{ctrl.newInfo.message}}</span>
</p>
<p class="small muted">
<strong>Version {{ctrl.baseInfo.version}}</strong> updated by
<span>{{ctrl.baseInfo.createdBy}} </span>
<span>{{ctrl.baseInfo.ageString}}</span>
<span> - {{ctrl.baseInfo.message}}</span>
</p>
</section>
<div id="delta" diff-delta>
<div class="delta-basic" compile="ctrl.delta.basic"></div>
</div>
<div class="gf-form-button-row">
<button class="btn btn-secondary" ng-click="ctrl.getDiff('json')">View JSON Diff</button>
</div>
<div class="delta-html" ng-show="ctrl.diff === 'json'" compile="ctrl.delta.json"></div>
<div class="gf-form-button-row">
<button class="btn btn-secondary" ng-click="ctrl.getDiff('json')">View JSON Diff</button>
</div>
<div class="delta-html" ng-show="ctrl.diff === 'json'" compile="ctrl.delta.json"></div>
</div>
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;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment