Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce Clone feature in view mode #10925

Merged
merged 8 commits into from
May 19, 2017
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');

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the switch to $injector.get()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@spalger's preference for injector over multiline argument lists. :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want the default value to cause a name collision with the existing dashboard title? Maybe Clone should be appended to the title or something like that, or maybe the default value is blank?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, I originally had it with 'Copy' appended, wasn't sure which version I liked better. I can bring that back.

};
}

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