diff --git a/src/core_plugins/kibana/public/dashboard/dashboard.js b/src/core_plugins/kibana/public/dashboard/dashboard.js
index 7efc687537ed..7527daf2ab19 100644
--- a/src/core_plugins/kibana/public/dashboard/dashboard.js
+++ b/src/core_plugins/kibana/public/dashboard/dashboard.js
@@ -23,6 +23,7 @@ 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',
@@ -30,7 +31,7 @@ const app = uiModules.get('app/dashboard', [
'kibana/courier',
'kibana/config',
'kibana/notify',
- 'kibana/typeahead'
+ 'kibana/typeahead',
]);
uiRoutes
@@ -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' });
@@ -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');
@@ -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());
diff --git a/src/core_plugins/kibana/public/dashboard/styles/index.less b/src/core_plugins/kibana/public/dashboard/styles/index.less
index 47bfd8196c21..24d0d8d67965 100644
--- a/src/core_plugins/kibana/public/dashboard/styles/index.less
+++ b/src/core_plugins/kibana/public/dashboard/styles/index.less
@@ -6,6 +6,10 @@
background-color: @dashboard-bg;
}
+.dashboardCloneModal {
+ width: 450px;
+}
+
dashboard-grid {
display: block;
margin: 0;
diff --git a/src/core_plugins/kibana/public/dashboard/top_nav/__snapshots__/clone_modal.test.js.snap b/src/core_plugins/kibana/public/dashboard/top_nav/__snapshots__/clone_modal.test.js.snap
new file mode 100644
index 000000000000..95dcfe48e628
--- /dev/null
+++ b/src/core_plugins/kibana/public/dashboard/top_nav/__snapshots__/clone_modal.test.js.snap
@@ -0,0 +1,69 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders DashboardCloneModal 1`] = `
+
+
+
+
+
+ Please enter a new name for your dashboard.
+
+
+
+
+
+
+
+
+`;
diff --git a/src/core_plugins/kibana/public/dashboard/top_nav/clone_modal.js b/src/core_plugins/kibana/public/dashboard/top_nav/clone_modal.js
new file mode 100644
index 000000000000..cc04147f4295
--- /dev/null
+++ b/src/core_plugins/kibana/public/dashboard/top_nav/clone_modal.js
@@ -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 (
+
+
+
+
+ Clone Dashboard
+
+
+
+
+ Please enter a new name for your dashboard.
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ Confirm Clone
+
+
+
+
+ );
+ }
+}
+
+DashboardCloneModal.propTypes = {
+ onClone: PropTypes.func,
+ onClose: PropTypes.func,
+ title: PropTypes.string
+};
diff --git a/src/core_plugins/kibana/public/dashboard/top_nav/clone_modal.test.js b/src/core_plugins/kibana/public/dashboard/top_nav/clone_modal.test.js
new file mode 100644
index 000000000000..2f9e78bacd1d
--- /dev/null
+++ b/src/core_plugins/kibana/public/dashboard/top_nav/clone_modal.test.js
@@ -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();
+ expect(component).toMatchSnapshot(); // eslint-disable-line
+});
+
+test('onClone', () => {
+ const component = mount();
+ component.find('[data-test-subj="cloneConfirmButton"]').simulate('click');
+ sinon.assert.calledWith(onClone, 'dash title');
+ sinon.assert.notCalled(onClose);
+});
+
+test('onClose', () => {
+ const component = mount();
+ component.find('[data-test-subj="cloneCancelButton"]').simulate('click');
+ sinon.assert.calledOnce(onClose);
+ sinon.assert.notCalled(onClone);
+});
+
+test('title', () => {
+ const component = mount();
+ const event = { target: { value: 'a' } };
+ component.find('input').simulate('change', event);
+ component.find('[data-test-subj="cloneConfirmButton"]').simulate('click');
+ sinon.assert.calledWith(onClone, 'a');
+});
diff --git a/src/core_plugins/kibana/public/dashboard/top_nav/get_top_nav_config.js b/src/core_plugins/kibana/public/dashboard/top_nav/get_top_nav_config.js
index 75f11edf6aed..81e8d3f7c570 100644
--- a/src/core_plugins/kibana/public/dashboard/top_nav/get_top_nav_config.js
+++ b/src/core_plugins/kibana/public/dashboard/top_nav/get_top_nav_config.js
@@ -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(),
@@ -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')
};
}
@@ -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}
*/
diff --git a/src/core_plugins/kibana/public/dashboard/top_nav/show_clone_modal.js b/src/core_plugins/kibana/public/dashboard/top_nav/show_clone_modal.js
new file mode 100644
index 000000000000..5badb1ca9851
--- /dev/null
+++ b/src/core_plugins/kibana/public/dashboard/top_nav/show_clone_modal.js
@@ -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 = (
+
+ );
+ ReactDOM.render(element, container);
+}
diff --git a/src/core_plugins/kibana/public/dashboard/top_nav/top_nav_ids.js b/src/core_plugins/kibana/public/dashboard/top_nav/top_nav_ids.js
index ed6a3cf474e2..f629759f6546 100644
--- a/src/core_plugins/kibana/public/dashboard/top_nav/top_nav_ids.js
+++ b/src/core_plugins/kibana/public/dashboard/top_nav/top_nav_ids.js
@@ -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'
};
diff --git a/src/jest/config.js b/src/jest/config.js
index ff927b9e0a92..da566c16c362 100644
--- a/src/jest/config.js
+++ b/src/jest/config.js
@@ -1,7 +1,10 @@
import { resolve } from 'path';
export const config = {
- roots: ['/ui_framework/'],
+ roots: [
+ '/src/core_plugins/kibana/public/dashboard',
+ '/ui_framework/',
+ ],
collectCoverageFrom: [
'ui_framework/services/**/*.js',
'!ui_framework/services/index.js',
@@ -10,6 +13,9 @@ export const config = {
'!ui_framework/components/index.js',
'!ui_framework/components/**/*/index.js',
],
+ moduleNameMapper: {
+ '^ui_framework/components': '/ui_framework/components',
+ },
coverageDirectory: '/target/jest-coverage',
coverageReporters: ['html'],
moduleFileExtensions: ['js', 'json'],
diff --git a/src/ui/public/modals/index.js b/src/ui/public/modals/index.js
index 8052ced3b514..d2f1a2427b56 100644
--- a/src/ui/public/modals/index.js
+++ b/src/ui/public/modals/index.js
@@ -2,3 +2,4 @@ import './confirm_modal';
import './confirm_modal_promise';
export { ConfirmationButtonTypes } from './confirm_modal';
+export { ModalOverlay } from './modal_overlay';
diff --git a/test/functional/apps/dashboard/_dashboard_clone.js b/test/functional/apps/dashboard/_dashboard_clone.js
new file mode 100644
index 000000000000..15afc27c478a
--- /dev/null
+++ b/test/functional/apps/dashboard/_dashboard_clone.js
@@ -0,0 +1,75 @@
+import expect from 'expect.js';
+import { bdd } from '../../../support';
+
+import PageObjects from '../../../support/page_objects';
+
+bdd.describe('dashboard save', function describeIndexTests() {
+ const dashboardName = 'Dashboard Clone Test';
+ const clonedDashboardName = dashboardName + ' copy';
+
+ bdd.before(async function () {
+ return PageObjects.dashboard.initTests();
+ });
+
+ bdd.it('Clone saves a copy', async function () {
+ await PageObjects.dashboard.clickNewDashboard();
+ await PageObjects.dashboard.addVisualizations(PageObjects.dashboard.getTestVisualizationNames());
+ await PageObjects.dashboard.enterDashboardTitleAndClickSave(dashboardName);
+
+ await PageObjects.dashboard.clickClone();
+
+ const countOfDashboards = await PageObjects.dashboard.getDashboardCountWithName(clonedDashboardName);
+ expect(countOfDashboards).to.equal(1);
+ });
+
+ bdd.it('the copy should have all the same visualizations', async function () {
+ await PageObjects.dashboard.loadSavedDashboard(clonedDashboardName);
+ return PageObjects.common.try(async function () {
+ const panelTitles = await PageObjects.dashboard.getPanelTitles();
+ expect(panelTitles).to.eql(PageObjects.dashboard.getTestVisualizationNames());
+ });
+ });
+
+ bdd.it('clone warns on duplicate name', async function() {
+ await PageObjects.dashboard.loadSavedDashboard(dashboardName);
+ await PageObjects.dashboard.clickClone();
+
+ await PageObjects.dashboard.confirmClone();
+ const isConfirmOpen = await PageObjects.common.isConfirmModalOpen();
+ expect(isConfirmOpen).to.equal(true);
+ });
+
+ bdd.it('preserves the original title on cancel', async function() {
+ await PageObjects.common.clickCancelOnModal();
+ await PageObjects.dashboard.confirmClone();
+
+ // Should see the same confirmation if the title is the same.
+ const isConfirmOpen = await PageObjects.common.isConfirmModalOpen();
+ expect(isConfirmOpen).to.equal(true);
+ });
+
+ bdd.it('and doesn\'t save', async () => {
+ await PageObjects.common.clickCancelOnModal();
+ await PageObjects.dashboard.cancelClone();
+
+ const countOfDashboards = await PageObjects.dashboard.getDashboardCountWithName(dashboardName);
+ expect(countOfDashboards).to.equal(1);
+ });
+
+ bdd.it('Clones on confirm duplicate title warning', async function() {
+ await PageObjects.dashboard.loadSavedDashboard(dashboardName);
+ await PageObjects.dashboard.clickClone();
+
+ await PageObjects.dashboard.confirmClone();
+ await PageObjects.common.clickConfirmOnModal();
+
+ // This is important since saving a new dashboard will cause a refresh of the page. We have to
+ // wait till it finishes reloading or it might reload the url after simulating the
+ // dashboard landing page click.
+ await PageObjects.header.waitUntilLoadingHasFinished();
+
+ const countOfDashboards =
+ await PageObjects.dashboard.getDashboardCountWithName(dashboardName + ' copy');
+ expect(countOfDashboards).to.equal(2);
+ });
+});
diff --git a/test/functional/page_objects/dashboard_page.js b/test/functional/page_objects/dashboard_page.js
index 2d00799498c4..b4832270f974 100644
--- a/test/functional/page_objects/dashboard_page.js
+++ b/test/functional/page_objects/dashboard_page.js
@@ -74,6 +74,25 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
return testSubjects.click('dashboardQueryFilterButton');
}
+ async clickClone() {
+ log.debug('Clicking clone');
+ await testSubjects.click('dashboardClone');
+ }
+
+ async confirmClone() {
+ log.debug('Confirming clone');
+ await testSubjects.click('cloneConfirmButton');
+ }
+
+ async cancelClone() {
+ log.debug('Canceling clone');
+ await testSubjects.click('cloneCancelButton');
+ }
+
+ async setClonedDashboardTitle(title) {
+ await testSubjects.setValue('clonedDashboardTitle', title);
+ }
+
clickEdit() {
log.debug('Clicking edit');
return testSubjects.click('dashboardEditMode');
@@ -224,7 +243,7 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
* @param saveOptions {{storeTimeWithDashboard: boolean, saveAsNew: boolean}}
*/
async enterDashboardTitleAndClickSave(dashboardTitle, saveOptions = {}) {
- await testSubjects.click('dashboardSaveButton');
+ await testSubjects.click('dashboardSaveMenuItem');
await PageObjects.header.waitUntilLoadingHasFinished();
diff --git a/test/functional/services/test_subjects.js b/test/functional/services/test_subjects.js
index 45c6b502d5bb..b45619830560 100644
--- a/test/functional/services/test_subjects.js
+++ b/test/functional/services/test_subjects.js
@@ -50,6 +50,13 @@ export function TestSubjectsProvider({ getService }) {
const all = await find.allByCssSelector(testSubjSelector(selector));
return await filterAsync(all, el => el.isDisplayed());
}
+
+ async setValue(selector, value) {
+ const input = await retry.try(() => this.find(selector));
+ await retry.try(() => input.click());
+ await input.clearValue();
+ await input.type(value);
+ }
}
return new TestSubjects();
diff --git a/ui_framework/components/index.js b/ui_framework/components/index.js
index 730213c2f50d..2bdbbc42f17a 100644
--- a/ui_framework/components/index.js
+++ b/ui_framework/components/index.js
@@ -23,7 +23,4 @@ export {
KuiToolBarFooter,
} from './tool_bar';
-export {
- KuiConfirmModal,
- KuiModalOverlay
-} from './modal';
+export * from './modal';
diff --git a/ui_framework/components/modal/index.js b/ui_framework/components/modal/index.js
index 5484b93dd8a4..e1601d386ba3 100644
--- a/ui_framework/components/modal/index.js
+++ b/ui_framework/components/modal/index.js
@@ -3,3 +3,6 @@ export { KuiModal } from './modal';
export { KuiModalFooter } from './modal_footer';
export { KuiModalHeader } from './modal_header';
export { KuiModalOverlay } from './modal_overlay';
+export { KuiModalBody } from './modal_body';
+export { KuiModalBodyText } from './modal_body_text';
+export { KuiModalHeaderTitle } from './modal_header_title';