Introduce Clone feature in view mode (#10925) (#11923)

* 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
This commit is contained in:
Stacey Gammon 2017-05-19 11:10:43 -04:00 committed by GitHub
parent 9e1c9aea8d
commit 2095fe934a
15 changed files with 417 additions and 18 deletions

View file

@ -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());

View file

@ -6,6 +6,10 @@
background-color: @dashboard-bg;
}
.dashboardCloneModal {
width: 450px;
}
dashboard-grid {
display: block;
margin: 0;

View file

@ -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>
`;

View file

@ -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
};

View file

@ -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');
});

View file

@ -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}
*/

View file

@ -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);
}

View file

@ -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'
};

View file

@ -1,7 +1,10 @@
import { resolve } from 'path';
export const config = {
roots: ['<rootDir>/ui_framework/'],
roots: [
'<rootDir>/src/core_plugins/kibana/public/dashboard',
'<rootDir>/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': '<rootDir>/ui_framework/components',
},
coverageDirectory: '<rootDir>/target/jest-coverage',
coverageReporters: ['html'],
moduleFileExtensions: ['js', 'json'],

View file

@ -2,3 +2,4 @@ import './confirm_modal';
import './confirm_modal_promise';
export { ConfirmationButtonTypes } from './confirm_modal';
export { ModalOverlay } from './modal_overlay';

View file

@ -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);
});
});

View file

@ -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();

View file

@ -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();

View file

@ -23,7 +23,4 @@ export {
KuiToolBarFooter,
} from './tool_bar';
export {
KuiConfirmModal,
KuiModalOverlay
} from './modal';
export * from './modal';

View file

@ -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';