Skip to content

Commit

Permalink
Introduce Clone feature in view mode (#10925) (#11923)
Browse files Browse the repository at this point in the history
* Introduce Clone feature in view mode

* Use a new react modal for cloning dashboards

* Fix focus issues and tests

Unfortunately can’t run jest tests outside of the ui_framework at this
time.

* Add tests for dashboard clone modal

* move the jest tests out of the __tests__ directory

It’ll cause failures for the normal unit test runs

* use react instead of angular for overlay and loading of dom element

* Append 'Copy' to the title in the clone dialog so by default it doesn't clash

* address code comments
  • Loading branch information
stacey-gammon authored May 19, 2017
1 parent 9e1c9ae commit 2095fe9
Show file tree
Hide file tree
Showing 15 changed files with 417 additions and 18 deletions.
47 changes: 38 additions & 9 deletions src/core_plugins/kibana/public/dashboard/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ import { FilterBarClickHandlerProvider } from 'ui/filter_bar/filter_bar_click_ha
import { DashboardState } from './dashboard_state';
import { notify } from 'ui/notify';
import { documentationLinks } from 'ui/documentation_links/documentation_links';
import { showCloneModal } from './top_nav/show_clone_modal';

const app = uiModules.get('app/dashboard', [
'elasticsearch',
'ngRoute',
'kibana/courier',
'kibana/config',
'kibana/notify',
'kibana/typeahead'
'kibana/typeahead',
]);

uiRoutes
Expand Down Expand Up @@ -70,14 +71,23 @@ uiRoutes
}
});

app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, quickRanges, kbnUrl, confirmModal, Private) {
app.directive('dashboardApp', function ($injector) {
const Notifier = $injector.get('Notifier');
const courier = $injector.get('courier');
const AppState = $injector.get('AppState');
const timefilter = $injector.get('timefilter');
const quickRanges = $injector.get('quickRanges');
const kbnUrl = $injector.get('kbnUrl');
const confirmModal = $injector.get('confirmModal');
const Private = $injector.get('Private');

const brushEvent = Private(UtilsBrushEventProvider);
const filterBarClickHandler = Private(FilterBarClickHandlerProvider);

return {
restrict: 'E',
controllerAs: 'dashboardApp',
controller: function ($scope, $rootScope, $route, $routeParams, $location, Private, getAppState) {
controller: function ($scope, $rootScope, $route, $routeParams, $location, getAppState, $compile) {
const filterBar = Private(FilterBarQueryFilterProvider);
const docTitle = Private(DocTitleProvider);
const notify = new Notifier({ location: 'Dashboard' });
Expand Down Expand Up @@ -238,12 +248,6 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter,
);
};

const navActions = {};
navActions[TopNavIds.EXIT_EDIT_MODE] = () => onChangeViewMode(DashboardViewMode.VIEW);
navActions[TopNavIds.ENTER_EDIT_MODE] = () => onChangeViewMode(DashboardViewMode.EDIT);

updateViewMode(dashboardState.getViewMode());

$scope.save = function () {
return dashboardState.saveDashboard(angular.toJson, timefilter).then(function (id) {
$scope.kbnTopNav.close('save');
Expand All @@ -256,9 +260,34 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter,
updateViewMode(DashboardViewMode.VIEW);
}
}
return id;
}).catch(notify.fatal);
};

const navActions = {};
navActions[TopNavIds.EXIT_EDIT_MODE] = () => onChangeViewMode(DashboardViewMode.VIEW);
navActions[TopNavIds.ENTER_EDIT_MODE] = () => onChangeViewMode(DashboardViewMode.EDIT);
navActions[TopNavIds.CLONE] = () => {
const currentTitle = $scope.model.title;
const onClone = (newTitle) => {
dashboardState.savedDashboard.copyOnSave = true;
dashboardState.setTitle(newTitle);
return $scope.save().then(id => {
// If the save wasn't successful, put the original title back.
if (!id) {
$scope.model.title = currentTitle;
// There is a watch on $scope.model.title that *should* call this automatically but
// angular is failing to trigger it, so do so manually here.
dashboardState.setTitle(currentTitle);
}
return id;
});
};

showCloneModal(onClone, currentTitle, $rootScope, $compile);
};
updateViewMode(dashboardState.getViewMode());

// update root source when filters update
$scope.$listen(filterBar, 'update', function () {
dashboardState.applyFilters($scope.model.query, filterBar.getFilters());
Expand Down
4 changes: 4 additions & 0 deletions src/core_plugins/kibana/public/dashboard/styles/index.less
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
background-color: @dashboard-bg;
}

.dashboardCloneModal {
width: 450px;
}

dashboard-grid {
display: block;
margin: 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`renders DashboardCloneModal 1`] = `
<div
class="kuiModalOverlay"
>
<div
aria-label="Clone a dashboard"
class="kuiModal dashboardCloneModal"
data-tests-subj="dashboardCloneModal"
>
<div
class="kuiModalHeader"
>
<div
class="kuiModalHeader__title"
>
Clone Dashboard
</div>
</div>
<div
class="kuiModalBody"
>
<div
class="kuiModalBodyText kuiVerticalRhythm"
>
Please enter a new name for your dashboard.
</div>
<div
class="kuiModalBodyText kuiVerticalRhythm"
>
<input
class="kuiTextInput kuiTextInput--large"
data-test-subj="clonedDashboardTitle"
value="dash title"
/>
</div>
</div>
<div
class="kuiModalFooter"
>
<button
class="kuiButton kuiButton--hollow"
data-test-subj="cloneCancelButton"
>
<span
class="kuiButton__inner"
>
<span>
Cancel
</span>
</span>
</button>
<button
class="kuiButton kuiButton--primary"
data-test-subj="cloneConfirmButton"
>
<span
class="kuiButton__inner"
>
<span>
Confirm Clone
</span>
</span>
</button>
</div>
</div>
</div>
`;
92 changes: 92 additions & 0 deletions src/core_plugins/kibana/public/dashboard/top_nav/clone_modal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import React from 'react';
import PropTypes from 'prop-types';

import {
KuiModal,
KuiModalHeader,
KuiModalHeaderTitle,
KuiModalBody,
KuiModalBodyText,
KuiModalFooter,
KuiButton,
KuiModalOverlay
} from 'ui_framework/components';

export class DashboardCloneModal extends React.Component {
constructor(props) {
super(props);

this.state = {
newDashboardName: props.title
};
}

cloneDashboard = () => {
this.props.onClone(this.state.newDashboardName);
};

onInputChange = (event) => {
this.setState({ newDashboardName: event.target.value });
};

onKeyDown = (event) => {
if (event.keyCode === 27) { // ESC key
this.props.onClose();
}
};

render() {
return (
<KuiModalOverlay>
<KuiModal
data-tests-subj="dashboardCloneModal"
aria-label="Clone a dashboard"
className="dashboardCloneModal"
onKeyDown={ this.onKeyDown }
>
<KuiModalHeader>
<KuiModalHeaderTitle>
Clone Dashboard
</KuiModalHeaderTitle>
</KuiModalHeader>
<KuiModalBody>
<KuiModalBodyText className="kuiVerticalRhythm">
Please enter a new name for your dashboard.
</KuiModalBodyText>
<KuiModalBodyText className="kuiVerticalRhythm">
<input
autoFocus
data-test-subj="clonedDashboardTitle"
className="kuiTextInput kuiTextInput--large"
value={ this.state.newDashboardName }
onChange={ this.onInputChange } />
</KuiModalBodyText>
</KuiModalBody>

<KuiModalFooter>
<KuiButton
type="hollow"
data-test-subj="cloneCancelButton"
onClick={ this.props.onClose }
>
Cancel
</KuiButton>
<KuiButton
type="primary"
data-test-subj="cloneConfirmButton"
onClick={ this.cloneDashboard }
>
Confirm Clone
</KuiButton>
</KuiModalFooter>
</KuiModal>
</KuiModalOverlay>
);
}
}

DashboardCloneModal.propTypes = {
onClone: PropTypes.func,
onClose: PropTypes.func,
title: PropTypes.string
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react';
import sinon from 'sinon';
import { mount, render } from 'enzyme';

import {
DashboardCloneModal,
} from '../top_nav/clone_modal';

let onClone;
let onClose;

beforeEach(() => {
onClone = sinon.spy();
onClose = sinon.spy();
});

test('renders DashboardCloneModal', () => {
const component = render(<DashboardCloneModal
title="dash title"
onClose={onClose}
onClone={onClone}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});

test('onClone', () => {
const component = mount(<DashboardCloneModal
title="dash title"
onClose={onClose}
onClone={onClone}
/>);
component.find('[data-test-subj="cloneConfirmButton"]').simulate('click');
sinon.assert.calledWith(onClone, 'dash title');
sinon.assert.notCalled(onClose);
});

test('onClose', () => {
const component = mount(<DashboardCloneModal
title="dash title"
onClose={onClose}
onClone={onClone}
/>);
component.find('[data-test-subj="cloneCancelButton"]').simulate('click');
sinon.assert.calledOnce(onClose);
sinon.assert.notCalled(onClone);
});

test('title', () => {
const component = mount(<DashboardCloneModal
title="dash title"
onClose={onClose}
onClone={onClone}
/>);
const event = { target: { value: 'a' } };
component.find('input').simulate('change', event);
component.find('[data-test-subj="cloneConfirmButton"]').simulate('click');
sinon.assert.calledWith(onClone, 'a');
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { TopNavIds } from './top_nav_ids';
export function getTopNavConfig(dashboardMode, actions) {
switch (dashboardMode) {
case DashboardViewMode.VIEW:
return [getShareConfig(), getEditConfig(actions[TopNavIds.ENTER_EDIT_MODE])];
return [
getShareConfig(),
getCloneConfig(actions[TopNavIds.CLONE]),
getEditConfig(actions[TopNavIds.ENTER_EDIT_MODE])];
case DashboardViewMode.EDIT:
return [
getSaveConfig(),
Expand Down Expand Up @@ -43,7 +46,7 @@ function getSaveConfig() {
return {
key: 'save',
description: 'Save your dashboard',
testId: 'dashboardSaveButton',
testId: 'dashboardSaveMenuItem',
template: require('plugins/kibana/dashboard/top_nav/save.html')
};
}
Expand All @@ -60,6 +63,18 @@ function getViewConfig(action) {
};
}

/**
* @returns {kbnTopNavConfig}
*/
function getCloneConfig(action) {
return {
key: 'clone',
description: 'Create a copy of your dashboard',
testId: 'dashboardClone',
run: action
};
}

/**
* @returns {kbnTopNavConfig}
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { DashboardCloneModal } from './clone_modal';
import React from 'react';
import ReactDOM from 'react-dom';

export function showCloneModal(onClone, title) {
const container = document.createElement('div');
const closeModal = () => {
document.body.removeChild(container);
};

const onCloneConfirmed = (newTitle) => {
onClone(newTitle).then(id => {
if (id) {
closeModal();
}
});
};
document.body.appendChild(container);
const element = (
<DashboardCloneModal onClone={onCloneConfirmed} onClose={closeModal} title={title + ' Copy'}></DashboardCloneModal>
);
ReactDOM.render(element, container);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export const TopNavIds = {
OPTIONS: 'options',
SAVE: 'save',
EXIT_EDIT_MODE: 'exitEditMode',
ENTER_EDIT_MODE: 'enterEditMode'
ENTER_EDIT_MODE: 'enterEditMode',
CLONE: 'clone'
};
Loading

0 comments on commit 2095fe9

Please sign in to comment.