diff --git a/src/core_plugins/kibana/public/dashboard/dashboard.js b/src/core_plugins/kibana/public/dashboard/dashboard.js index 7efc687537edf..7527daf2ab198 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 47bfd8196c218..24d0d8d679654 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 0000000000000..95dcfe48e6287 --- /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`] = ` +
+
+
+
+ Clone Dashboard +
+
+
+
+ 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 0000000000000..cc04147f42951 --- /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 0000000000000..2f9e78bacd1dd --- /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 75f11edf6aed8..81e8d3f7c5709 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 0000000000000..5badb1ca9851b --- /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 ed6a3cf474e2d..f629759f65463 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 ff927b9e0a92d..da566c16c362e 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 8052ced3b5145..d2f1a2427b566 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 0000000000000..15afc27c478a6 --- /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 490739628fcf2..fe475d44172db 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 45c6b502d5bbf..b45619830560c 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 730213c2f50da..2bdbbc42f17ae 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 5484b93dd8a40..e1601d386ba3b 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';