mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
* 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:
parent
9e1c9aea8d
commit
2095fe934a
15 changed files with 417 additions and 18 deletions
|
@ -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());
|
||||
|
|
|
@ -6,6 +6,10 @@
|
|||
background-color: @dashboard-bg;
|
||||
}
|
||||
|
||||
.dashboardCloneModal {
|
||||
width: 450px;
|
||||
}
|
||||
|
||||
dashboard-grid {
|
||||
display: block;
|
||||
margin: 0;
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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
|
||||
};
|
|
@ -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');
|
||||
});
|
|
@ -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}
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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'
|
||||
};
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -2,3 +2,4 @@ import './confirm_modal';
|
|||
import './confirm_modal_promise';
|
||||
|
||||
export { ConfirmationButtonTypes } from './confirm_modal';
|
||||
export { ModalOverlay } from './modal_overlay';
|
||||
|
|
75
test/functional/apps/dashboard/_dashboard_clone.js
Normal file
75
test/functional/apps/dashboard/_dashboard_clone.js
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -23,7 +23,4 @@ export {
|
|||
KuiToolBarFooter,
|
||||
} from './tool_bar';
|
||||
|
||||
export {
|
||||
KuiConfirmModal,
|
||||
KuiModalOverlay
|
||||
} from './modal';
|
||||
export * from './modal';
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue