Dashboard: Migrate general settings to react (#30914)

* feat(dashboard): initial commit of general settings migration to react

* fix(dashboardsettings): force update of general settings when selects change

* feat(dashboardsettings): initial commit of delete dashboard button and modal

* feat(dashboardsettings): introduce useDashboardDelete hook

* feat(dashboardsettings): add tags and editable inputs

* refactor(dashboardsettings): fix typescript error in general settings

* refactor(dashboardsettings): use grafana-ui form components for general settings

* refactor(dashboardsettings): use ConfirmModal and move provisioned modal to own component

* refactor(dashboardsettings): revertDashboardModal uses ConfirmModal

* test(autorefreshintervals): remove renderCount prop to fix test

* test(dashboardsettings): put back aria-label for e2e tests

* chore(dashboardsettings): remove redundant generl settings angular code

* test: change references to now deleted SettingsCtrl to GeneralSettings

* refactor(dashboardsettings): swap out switch for inlineswitch component

* chore(timepickersettings): remove timePickerSettings angular directive definition

* feat(dashboardsettings): add tooltips, fix description field name

* refactor(dashboardsettings): remove redundant await

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* refactor(usedashboarddelete): clean up

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
This commit is contained in:
Jack Westbrook 2021-02-09 12:04:03 +01:00 committed by GitHub
parent a2cca8d488
commit ef8a5b760f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 267 additions and 257 deletions

View File

@ -25,7 +25,6 @@ import { HelpModal } from './components/help/HelpModal';
import { Footer } from './components/Footer/Footer';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { SearchField, SearchResults, SearchResultsFilter, SearchWrapper } from '../features/search';
import { TimePickerSettings } from 'app/features/dashboard/components/DashboardSettings/TimePickerSettings';
const { SecretFormField } = LegacyForms;
@ -185,15 +184,4 @@ export function registerAngularDirectives() {
['onLoad', { watchDepth: 'reference', wrapApply: true }],
['onChange', { watchDepth: 'reference', wrapApply: true }],
]);
react2AngularDirective('timePickerSettings', TimePickerSettings, [
'renderCount',
'refreshIntervals',
'timePickerHidden',
'nowDelay',
'timezone',
['onTimeZoneChange', { watchDepth: 'reference', wrapApply: true }],
['onRefreshIntervalChange', { watchDepth: 'reference', wrapApply: true }],
['onNowDelayChange', { watchDepth: 'reference', wrapApply: true }],
['onHideTimePickerChange', { watchDepth: 'reference', wrapApply: true }],
]);
}

View File

@ -8,7 +8,6 @@ import { TimeSrv } from '../../services/TimeSrv';
const setupTestContext = (options: Partial<Props>) => {
const defaults: Props = {
renderCount: 0,
refreshIntervals: ['1s', '5s', '10s'],
onRefreshIntervalChange: jest.fn(),
getIntervalsFunc: (intervals) => intervals,

View File

@ -4,7 +4,6 @@ import { Input, Tooltip, defaultIntervals } from '@grafana/ui';
import { getTimeSrv } from '../../services/TimeSrv';
export interface Props {
renderCount: number; // hack to make sure Angular changes are propagated properly, please remove when DashboardSettings are migrated to React
refreshIntervals: string[];
onRefreshIntervalChange: (interval: string[]) => void;
getIntervalsFunc?: typeof getValidIntervals;
@ -12,7 +11,6 @@ export interface Props {
}
export const AutoRefreshIntervals: FC<Props> = ({
renderCount,
refreshIntervals,
onRefreshIntervalChange,
getIntervalsFunc = getValidIntervals,
@ -24,7 +22,7 @@ export const AutoRefreshIntervals: FC<Props> = ({
useEffect(() => {
const intervals = getIntervalsFunc(refreshIntervals ?? defaultIntervals);
setIntervals(intervals);
}, [renderCount, refreshIntervals]);
}, [refreshIntervals]);
const intervalsString = useMemo(() => {
if (!Array.isArray(intervals)) {

View File

@ -1,30 +1,122 @@
import React, { PureComponent } from 'react';
import React, { useState } from 'react';
import { SelectableValue, TimeZone } from '@grafana/data';
import { Select, InlineSwitch, TagsInput, InlineField, Input } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { DashboardModel } from '../../state/DashboardModel';
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
import { DeleteDashboardButton } from '../DeleteDashboard/DeleteDashboardButton';
import { TimePickerSettings } from './TimePickerSettings';
interface Props {
dashboard: DashboardModel;
}
export class GeneralSettings extends PureComponent<Props> {
element?: HTMLElement | null;
angularCmp?: AngularComponent;
const GRAPH_TOOLTIP_OPTIONS = [
{ value: 0, label: 'Default' },
{ value: 1, label: 'Shared crosshair' },
{ value: 2, label: 'Shared Tooltip' },
];
componentDidMount() {
const loader = getAngularLoader();
export const GeneralSettings: React.FC<Props> = ({ dashboard }) => {
const [renderCounter, setRenderCounter] = useState(0);
const template = '<dashboard-settings dashboard="dashboard" />';
const scopeProps = { dashboard: this.props.dashboard };
this.angularCmp = loader.load(this.element, scopeProps, template);
}
const onFolderChange = (folder: { id: number; title: string }) => {
dashboard.meta.folderId = folder.id;
dashboard.meta.folderTitle = folder.title;
dashboard.meta.hasUnsavedFolderChange = true;
};
componentWillUnmount() {
if (this.angularCmp) {
this.angularCmp.destroy();
}
}
const onBlur = (event: React.FocusEvent<HTMLInputElement>) => {
dashboard[event.currentTarget.name as 'title' | 'description'] = event.currentTarget.value;
};
render() {
return <div ref={(ref) => (this.element = ref)} />;
}
}
const onTooltipChange = (graphTooltip: SelectableValue<number>) => {
dashboard.graphTooltip = graphTooltip.value;
setRenderCounter(renderCounter + 1);
};
const onRefreshIntervalChange = (intervals: string[]) => {
dashboard.timepicker.refresh_intervals = intervals.filter((i) => i.trim() !== '');
};
const onNowDelayChange = (nowDelay: string) => {
dashboard.timepicker.nowDelay = nowDelay;
};
const onHideTimePickerChange = (hide: boolean) => {
dashboard.timepicker.hidden = hide;
setRenderCounter(renderCounter + 1);
};
const onTimeZoneChange = (timeZone: TimeZone) => {
dashboard.timezone = timeZone;
setRenderCounter(renderCounter + 1);
};
const onTagsChange = (tags: string[]) => {
dashboard.tags = tags;
};
const onEditableChange = (ev: React.FormEvent<HTMLInputElement>) => {
dashboard.editable = ev.currentTarget.checked;
setRenderCounter(renderCounter + 1);
};
return (
<>
<h3 className="dashboard-settings__header" aria-label={selectors.pages.Dashboard.Settings.General.title}>
General
</h3>
<div className="gf-form-group">
<InlineField label="Name" labelWidth={14}>
<Input name="title" onBlur={onBlur} defaultValue={dashboard.title} width={60} />
</InlineField>
<InlineField label="Description" labelWidth={14}>
<Input name="description" onBlur={onBlur} defaultValue={dashboard.description} width={60} />
</InlineField>
<InlineField label="Tags" tooltip="Press enter to add a tag" labelWidth={14}>
<TagsInput tags={dashboard.tags} onChange={onTagsChange} />
</InlineField>
<FolderPicker
initialTitle={dashboard.meta.folderTitle}
initialFolderId={dashboard.meta.folderId}
onChange={onFolderChange}
enableCreateNew={true}
dashboardId={dashboard.id}
/>
<InlineField
label="Editable"
tooltip="Uncheck, then save and reload to disable all dashboard editing"
labelWidth={14}
>
<InlineSwitch value={dashboard.editable} onChange={onEditableChange} />
</InlineField>
</div>
<TimePickerSettings
onTimeZoneChange={onTimeZoneChange}
onRefreshIntervalChange={onRefreshIntervalChange}
onNowDelayChange={onNowDelayChange}
onHideTimePickerChange={onHideTimePickerChange}
refreshIntervals={dashboard.timepicker.refresh_intervals}
timePickerHidden={dashboard.timepicker.hidden}
nowDelay={dashboard.timepicker.nowDelay}
timezone={dashboard.timezone}
/>
<h5 className="section-heading">Panel Options</h5>
<div className="gf-form">
<InlineField label="Graph Tooltip" labelWidth={14}>
<Select
onChange={onTooltipChange}
options={GRAPH_TOOLTIP_OPTIONS}
width={40}
value={dashboard.graphTooltip}
/>
</InlineField>
</div>
<div className="gf-form-button-row">
{dashboard.meta.canSave && <DeleteDashboardButton dashboard={dashboard} />}
</div>
</>
);
};

View File

@ -1,125 +0,0 @@
import _ from 'lodash';
import { ILocationService, IScope } from 'angular';
import { selectors } from '@grafana/e2e-selectors';
import { appEvents, coreModule } from 'app/core/core';
import { DashboardModel } from '../../state/DashboardModel';
import { CoreEvents } from 'app/types';
import { AppEvents, TimeZone } from '@grafana/data';
import { promiseToDigest } from '../../../../core/utils/promiseToDigest';
import { deleteDashboard } from 'app/features/manage-dashboards/state/actions';
export class SettingsCtrl {
dashboard: DashboardModel;
canSaveAs: boolean;
canSave?: boolean;
canDelete?: boolean;
selectors: typeof selectors.pages.Dashboard.Settings.General;
renderCount: number; // hack to update React when Angular changes
/** @ngInject */
constructor(private $scope: IScope & Record<string, any>, private $location: ILocationService) {
// temp hack for annotations and variables editors
// that rely on inherited scope
$scope.dashboard = this.dashboard;
this.canDelete = this.dashboard.meta.canSave;
this.selectors = selectors.pages.Dashboard.Settings.General;
this.renderCount = 0;
}
deleteDashboard() {
let confirmText = '';
let text2 = this.dashboard.title;
if (this.dashboard.meta.provisioned) {
appEvents.emit(CoreEvents.showConfirmModal, {
title: 'Cannot delete provisioned dashboard',
text: `
This dashboard is managed by Grafanas provisioning and cannot be deleted. Remove the dashboard from the
config file to delete it.
`,
text2: `
<i>See <a class="external-link" href="http://docs.grafana.org/administration/provisioning/#dashboards" target="_blank">
documentation</a> for more information about provisioning.</i>
</br>
File path: ${this.dashboard.meta.provisionedExternalId}
`,
text2htmlBind: true,
icon: 'trash-alt',
noText: 'OK',
});
return;
}
const alerts = _.sumBy(this.dashboard.panels, (panel) => {
return panel.alert ? 1 : 0;
});
if (alerts > 0) {
confirmText = 'DELETE';
text2 = `This dashboard contains ${alerts} alerts. Deleting this dashboard will also delete those alerts`;
}
appEvents.emit(CoreEvents.showConfirmModal, {
title: 'Delete',
text: 'Do you want to delete this dashboard?',
text2: text2,
icon: 'trash-alt',
confirmText: confirmText,
yesText: 'Delete',
onConfirm: () => {
this.dashboard.meta.canSave = false;
this.deleteDashboardConfirmed();
},
});
}
deleteDashboardConfirmed() {
promiseToDigest(this.$scope)(
deleteDashboard(this.dashboard.uid, false).then(() => {
appEvents.emit(AppEvents.alertSuccess, ['Dashboard Deleted', this.dashboard.title + ' has been deleted']);
this.$location.url('/');
})
);
}
onFolderChange = (folder: { id: number; title: string }) => {
this.dashboard.meta.folderId = folder.id;
this.dashboard.meta.folderTitle = folder.title;
this.dashboard.meta.hasUnsavedFolderChange = true;
};
onRefreshIntervalChange = (intervals: string[]) => {
this.dashboard.timepicker.refresh_intervals = intervals.filter((i) => i.trim() !== '');
this.renderCount++;
};
onNowDelayChange = (nowDelay: string) => {
this.dashboard.timepicker.nowDelay = nowDelay;
this.renderCount++;
};
onHideTimePickerChange = (hide: boolean) => {
this.dashboard.timepicker.hidden = hide;
this.renderCount++;
};
onTimeZoneChange = (timeZone: TimeZone) => {
this.dashboard.timezone = timeZone;
this.renderCount++;
};
}
export function dashboardSettings() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/components/DashboardSettings/template.html',
controller: SettingsCtrl,
bindToController: true,
controllerAs: 'ctrl',
transclude: true,
scope: { dashboard: '=' },
};
}
coreModule.directive('dashboardSettings', dashboardSettings);

View File

@ -1,5 +1,5 @@
import React, { PureComponent } from 'react';
import { InlineField, Input, Switch, TimeZonePicker, Tooltip } from '@grafana/ui';
import { InlineField, Input, InlineSwitch, TimeZonePicker, Tooltip } from '@grafana/ui';
import { rangeUtil, TimeZone } from '@grafana/data';
import isEmpty from 'lodash/isEmpty';
import { selectors } from '@grafana/e2e-selectors';
@ -10,7 +10,6 @@ interface Props {
onRefreshIntervalChange: (interval: string[]) => void;
onNowDelayChange: (nowDelay: string) => void;
onHideTimePickerChange: (hide: boolean) => void;
renderCount: number; // hack to make sure Angular changes are propagated properly, please remove when DashboardSettings are migrated to React
refreshIntervals: string[];
timePickerHidden: boolean;
nowDelay: string;
@ -66,7 +65,6 @@ export class TimePickerSettings extends PureComponent<Props, State> {
/>
</div>
<AutoRefreshIntervals
renderCount={this.props.renderCount}
refreshIntervals={this.props.refreshIntervals}
onRefreshIntervalChange={this.props.onRefreshIntervalChange}
/>
@ -88,7 +86,7 @@ export class TimePickerSettings extends PureComponent<Props, State> {
<div className="gf-form">
<InlineField labelWidth={14} label="Hide time picker">
<Switch value={!!this.props.timePickerHidden} onChange={this.onHideTimePickerChange} />
<InlineSwitch value={!!this.props.timePickerHidden} onChange={this.onHideTimePickerChange} />
</InlineField>
</div>
</div>

View File

@ -1,3 +1 @@
export { SettingsCtrl } from './SettingsCtrl';
export { DashboardSettings } from './DashboardSettings';
export { TimePickerSettings } from './TimePickerSettings';

View File

@ -1,64 +0,0 @@
<h3 class="dashboard-settings__header" aria-label="{{::ctrl.selectors.title}}">
General
</h3>
<div class="gf-form-group">
<div class="gf-form">
<label class="gf-form-label width-7">Name</label>
<input type="text" class="gf-form-input width-30" ng-model='ctrl.dashboard.title'></input>
</div>
<div class="gf-form">
<label class="gf-form-label width-7">Description</label>
<input type="text" class="gf-form-input width-30" ng-model='ctrl.dashboard.description'></input>
</div>
<div class="gf-form">
<label class="gf-form-label width-7">
Tags
<info-popover mode="right-normal">Press enter to add a tag</info-popover>
</label>
<bootstrap-tagsinput ng-model="ctrl.dashboard.tags" tagclass="label label-tag" placeholder="add tags">
</bootstrap-tagsinput>
</div>
<folder-picker initial-title="ctrl.dashboard.meta.folderTitle" initial-folder-id="ctrl.dashboard.meta.folderId"
on-change="ctrl.onFolderChange" enable-create-new="true" is-valid-selection="true" label-class="width-7"
dashboard-id="ctrl.dashboard.id">
</folder-picker>
<gf-form-switch class="gf-form" label="Editable"
tooltip="Uncheck, then save and reload to disable all dashboard editing" checked="ctrl.dashboard.editable"
label-class="width-7">
</gf-form-switch>
</div>
<time-picker-settings
onTimeZoneChange="ctrl.onTimeZoneChange"
onRefreshIntervalChange="ctrl.onRefreshIntervalChange"
onNowDelayChange="ctrl.onNowDelayChange"
onHideTimePickerChange="ctrl.onHideTimePickerChange"
renderCount="ctrl.renderCount"
refreshIntervals="ctrl.dashboard.timepicker.refresh_intervals"
timePickerHidden="ctrl.dashboard.timepicker.hidden"
nowDelay="ctrl.dashboard.timepicker.nowDelay"
timezone="ctrl.dashboard.timezone"
>
</time-picker-settings>
<h5 class="section-heading">Panel Options</h5>
<div class="gf-form">
<label class="gf-form-label width-11">
Graph Tooltip
<info-popover mode="right-normal">
Cycle between options using Shortcut: CTRL+O or CMD+O
</info-popover>
</label>
<div class="gf-form-select-wrapper">
<select ng-model="ctrl.dashboard.graphTooltip" class='gf-form-input'
ng-options="f.value as f.text for f in [{value: 0, text: 'Default'}, {value: 1, text: 'Shared crosshair'},{value: 2, text: 'Shared Tooltip'}]"></select>
</div>
</div>
<div class="gf-form-button-row">
<button class="btn btn-danger" ng-click="ctrl.deleteDashboard()" ng-show="ctrl.canDelete"
aria-label="Dashboard settings page delete dashboard button">
Delete Dashboard
</button>
</div>

View File

@ -0,0 +1,27 @@
import React from 'react';
import { DeleteDashboardModal } from './DeleteDashboardModal';
import { Button, ModalsController } from '@grafana/ui';
import { DashboardModel } from '../../state';
type Props = {
dashboard: DashboardModel;
};
export const DeleteDashboardButton = ({ dashboard }: Props) => (
<ModalsController>
{({ showModal, hideModal }) => (
<Button
variant="destructive"
onClick={() => {
showModal(DeleteDashboardModal, {
dashboard,
hideModal,
});
}}
aria-label="Dashboard settings page delete dashboard button"
>
Delete Dashboard
</Button>
)}
</ModalsController>
);

View File

@ -0,0 +1,90 @@
import React from 'react';
import { css } from 'emotion';
import sumBy from 'lodash/sumBy';
import { Modal, ConfirmModal, HorizontalGroup, Button } from '@grafana/ui';
import { DashboardModel, PanelModel } from '../../state';
import { useDashboardDelete } from './useDashboardDelete';
type DeleteDashboardModalProps = {
hideModal(): void;
dashboard: DashboardModel;
};
export const DeleteDashboardModal: React.FC<DeleteDashboardModalProps> = ({ hideModal, dashboard }) => {
const isProvisioned = dashboard.meta.provisioned;
const { onRestoreDashboard } = useDashboardDelete(dashboard.uid);
const modalBody = getModalBody(dashboard.panels, dashboard.title);
if (isProvisioned) {
return <ProvisionedDeleteModal hideModal={hideModal} provisionedId={dashboard.meta.provisionedExternalId!} />;
}
return (
<ConfirmModal
isOpen={true}
body={modalBody}
onConfirm={onRestoreDashboard}
onDismiss={hideModal}
title="Delete"
icon="trash-alt"
confirmText="Delete"
/>
);
};
const getModalBody = (panels: PanelModel[], title: string) => {
const totalAlerts = sumBy(panels, (panel) => (panel.alert ? 1 : 0));
return totalAlerts > 0 ? (
<>
<p>Do you want to delete this dashboard?</p>
<p>
This dashboard contains {totalAlerts} alert{totalAlerts > 1 ? 's' : ''}. Deleting this dashboard will also
delete those alerts
</p>
</>
) : (
<>
<p>Do you want to delete this dashboard?</p>
<p>{title}</p>
</>
);
};
const ProvisionedDeleteModal = ({ hideModal, provisionedId }: { hideModal(): void; provisionedId: string }) => (
<Modal
isOpen={true}
title="Cannot delete provisioned dashboard"
icon="trash-alt"
onDismiss={hideModal}
className={css`
text-align: center;
width: 500px;
`}
>
<p>
This dashboard is managed by Grafanas provisioning and cannot be deleted. Remove the dashboard from the config
file to delete it.
</p>
<p>
<i>
See{' '}
<a
className="external-link"
href="http://docs.grafana.org/administration/provisioning/#dashboards"
target="_blank"
rel="noreferrer"
>
documentation
</a>{' '}
for more information about provisioning.
</i>
<br />
File path: {provisionedId}
</p>
<HorizontalGroup justify="center">
<Button variant="secondary" onClick={hideModal}>
OK
</Button>
</HorizontalGroup>
</Modal>
);

View File

@ -0,0 +1,25 @@
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useAsyncFn } from 'react-use';
import { AppEvents } from '@grafana/data';
import appEvents from 'app/core/app_events';
import { updateLocation } from 'app/core/reducers/location';
import { deleteDashboard } from 'app/features/manage-dashboards/state/actions';
export const useDashboardDelete = (uid: string) => {
const dispatch = useDispatch();
const [state, onRestoreDashboard] = useAsyncFn(() => deleteDashboard(uid, false), []);
useEffect(() => {
if (state.value) {
dispatch(
updateLocation({
path: '/',
replace: true,
query: {},
})
);
appEvents.emit(AppEvents.alertSuccess, ['Dashboard Deleted', state.value.title + ' has been deleted']);
}
}, [state]);
return { state, onRestoreDashboard };
};

View File

@ -1,6 +1,5 @@
import React from 'react';
import { css } from 'emotion';
import { HorizontalGroup, Modal, Button } from '@grafana/ui';
import { ConfirmModal } from '@grafana/ui';
import { useDashboardRestore } from './useDashboardRestore';
export interface RevertDashboardModalProps {
hideModal: () => void;
@ -12,25 +11,16 @@ export const RevertDashboardModal: React.FC<RevertDashboardModalProps> = ({ hide
const { onRestoreDashboard } = useDashboardRestore(version);
return (
<Modal
<ConfirmModal
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>
onConfirm={onRestoreDashboard}
body={
<p>Are you sure you want to restore the dashboard to version {version}? All unsaved changes will be lost.</p>
}
confirmText={`Yes, restore to version ${version}`}
/>
);
};

View File

@ -7,7 +7,7 @@ import { DashboardInitPhase, DashboardRouteInfo } from 'app/types';
import { notifyApp, updateLocation } from 'app/core/actions';
import { cleanUpDashboardAndVariables } from '../state/actions';
jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({}));
jest.mock('app/features/dashboard/components/DashboardSettings/GeneralSettings', () => ({}));
interface ScenarioContext {
cleanUpDashboardAndVariablesMock: typeof cleanUpDashboardAndVariables;

View File

@ -5,7 +5,7 @@ import { Props as DashboardPanelProps } from '../dashgrid/DashboardPanel';
import { DashboardModel } from '../state';
import { DashboardRouteInfo } from 'app/types';
jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({}));
jest.mock('app/features/dashboard/components/DashboardSettings/GeneralSettings', () => ({}));
jest.mock('app/features/dashboard/dashgrid/DashboardPanel', () => {
class DashboardPanel extends React.Component<DashboardPanelProps> {
render() {

View File

@ -8,9 +8,3 @@ import './components/DashExportModal';
import './components/DashNav';
import './components/VersionHistory';
import './components/DashboardSettings';
import { DashboardPermissions } from './components/DashboardPermissions/DashboardPermissions';
// angular wrappers
import { react2AngularDirective } from 'app/core/utils/react2angular';
react2AngularDirective('dashboardPermissions', DashboardPermissions, ['dashboardId', 'folder']);