From 3744644b4b5e5127ae43218bcb41d3395e224c3a Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Tue, 13 Oct 2015 11:48:17 -0700 Subject: [PATCH] Add a Delete Modal Service for deleting entities This provides a reusable modal service for deleting entities. It can be used for deleting single or multiple objects. It requires that the backing API Service allow for suppressing of errors so that success and error messages are shown only once when working with multiple objects. It's used in the patch https://review.openstack.org/#/c/234873/ Change-Id: I9d6410c0d4a692db4044661254a9914eef79e3ed Partially-Implements: blueprint ng-flavors --- .../widgets/modal/delete-modal.service.js | 171 ++++++++++++++++++ .../modal/delete-modal.service.spec.js | 166 +++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 horizon/static/framework/widgets/modal/delete-modal.service.js create mode 100644 horizon/static/framework/widgets/modal/delete-modal.service.spec.js diff --git a/horizon/static/framework/widgets/modal/delete-modal.service.js b/horizon/static/framework/widgets/modal/delete-modal.service.js new file mode 100644 index 0000000000..54dd45f935 --- /dev/null +++ b/horizon/static/framework/widgets/modal/delete-modal.service.js @@ -0,0 +1,171 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use self file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +(function() { + 'use strict'; + + angular + .module('horizon.framework.widgets.modal') + .factory('horizon.framework.widgets.modal.deleteModalService', deleteModalService); + + deleteModalService.$inject = [ + '$q', + 'horizon.framework.widgets.modal.simple-modal.service', + 'horizon.framework.widgets.toast.service' + ]; + + /** + * @ngDoc factory + * @name horizon.framework.widgets.modal.deleteModalService + * + * @Description + * Brings up the delete confirmation modal dialog. + * This provides a reusable modal service for deleting + * entities. It can be used for deleting single or multiple + * objects. + * + * It requires that the backing API Service allow for + * suppressing of errors so that success and error messages + * are shown only once when working with multiple objects. + * + * On submit, call the given deleteEntity method + * and then raise the event. + * On cancel, do nothing. + */ + function deleteModalService($q, simpleModalService, toast) { + var service = { + open: open + }; + + return service; + + ////////////// + + /** + * @ngDoc method + * @name horizon.framework.widgets.modal.deleteModalService.open + * + * @Description + * Brings up the delete confirmation modal dialog for the given + * set of entities. + * + * @param {Object} entities + * The Entities that are to be deleted. + * Each entity MUST have an ID field. They + * could also have a name field which if present is + * used in the confirmation display. If a name + * is not provided, the ID will be displayed. + * + * @param {function} context.deleteEntity + * The function that should be called to delete each entity. + * The first argument is the id of the Entity to delete. + * Note: This callback might need to supressErrors on the alert + * service. + * + * @param {string} context.successEvent + * The name of the event to emit for the entities that have been deleted successfully. + * @param {string} context.failedEvent + * The name of the event to emit when the entities that failed to delete successfully. + * + * On submit, delete given entities. + * On cancel, do nothing. + */ + function open(scope, entities, context) { + var options = { + title: context.labels.title, + body: interpolate(context.labels.message, [entities.map(getName).join("\", \"")]), + submit: context.labels.submit + }; + + simpleModalService.modal(options).result.then(onModalSubmit); + + function onModalSubmit() { + resolveAll(entities.map(deleteEntityPromise)).then(notify); + } + + function deleteEntityPromise(entity) { + return {promise: context.deleteEntity(entity.id), entity: entity}; + } + + function notify(result) { + if (result.pass.length > 0) { + scope.$emit(context.successEvent, result.pass.map(getId)); + toast.add('success', getMessage(context.labels.success, result.pass)); + } + + if (result.fail.length > 0) { + scope.$emit(context.failedEvent, result.fail.map(getId)); + toast.add('error', getMessage(context.labels.error, result.fail)); + } + } + } + + /** + * Helper method to get the displayed message + */ + function getMessage(message, entities) { + return interpolate(message, [entities.map(getName).join(", ")]); + } + + /** + * Helper method to get the name of the entity + */ + function getName(entity) { + return entity.name || entity.id; + } + + /** + * Helper method to get the id of the entity + */ + function getId(entity) { + return entity.id; + } + + /** + * Resolve all promises. + * It asks the backing API Service to suppress errors + * and collect all entities to display one + * success and one error message. + */ + function resolveAll(promiseList) { + var deferred = $q.defer(); + var passList = []; + var failList = []; + var promises = promiseList.map(resolveSingle); + + $q.all(promises).then(onComplete); + return deferred.promise; + + function resolveSingle(singlePromise) { + var deferredInner = $q.defer(); + singlePromise.promise.then(success, error); + return deferredInner.promise; + + function success() { + passList.push(singlePromise.entity); + deferredInner.resolve(); + } + + function error() { + failList.push(singlePromise.entity); + deferredInner.resolve(); + } + } + + function onComplete() { + deferred.resolve({pass: passList, fail: failList}); + } + } + } // end of batchDeleteService +})(); // end of IIFE diff --git a/horizon/static/framework/widgets/modal/delete-modal.service.spec.js b/horizon/static/framework/widgets/modal/delete-modal.service.spec.js new file mode 100644 index 0000000000..c00cd22d87 --- /dev/null +++ b/horizon/static/framework/widgets/modal/delete-modal.service.spec.js @@ -0,0 +1,166 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use self file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +(function() { + 'use strict'; + + describe('horizon.framework.widgets.modal.deleteModalService', function() { + var labels = { + title: gettext('Confirm Delete Foobars'), + message: gettext('selected "%s"'), + submit: gettext('Delete'), + success: gettext('Deleted : %s.'), + error: gettext('Unable to delete: %s.') + }; + + var entityAPI = { + deleteEntity: function(entityId) { + var deferred = $q.defer(); + + if (entityId === 'bad') { + deferred.reject(); + } else { + deferred.resolve(); + } + + return deferred.promise; + } + }; + + var simpleModalService = { + modal: function () { + return { + result: { + then: function (callback) { + callback(); + } + } + }; + } + }; + + var toastService = { + add: function() {} + }; + + var $scope, $q, service, events; + + function getContext() { + return { + labels: labels, + deleteEntity: entityAPI.deleteEntity, + successEvent: 'custom_delete_event_passed', + failedEvent: 'custom_delete_event_failed' + }; + } + + /////////////////////// + + beforeEach(module('horizon.framework.util')); + beforeEach(module('horizon.framework.widgets')); + + beforeEach(module(function($provide) { + $provide.value('horizon.framework.widgets.modal.simple-modal.service', simpleModalService); + $provide.value('horizon.framework.widgets.toast.service', toastService); + })); + + beforeEach(inject(function($injector, _$rootScope_) { + $scope = _$rootScope_.$new(); + $q = $injector.get('$q'); + service = $injector.get('horizon.framework.widgets.modal.deleteModalService'); + })); + + it('should open the modal with correct message', function() { + var fakeModalService = { + result: { + then: function (callback) {} + } + }; + + var entities = [ + {name: 'entity1', id: '1'}, + {id: '2'} + ]; + + spyOn(simpleModalService, 'modal').and.returnValue(fakeModalService); + + service.open($scope, entities, getContext()); + + expect(simpleModalService.modal).toHaveBeenCalled(); + + var args = simpleModalService.modal.calls.argsFor(0)[0]; + expect(args.body).toEqual('selected "entity1", "2"'); + }); + + it('should call entityAPI to delete entities and raise events', function() { + spyOn(toastService, 'add').and.callThrough(); + spyOn($scope, '$emit').and.callThrough(); + spyOn(entityAPI, 'deleteEntity').and.callThrough(); + + var entities = [ + {name: 'entity1', id: '1'}, + {name: 'entity2', id: '2'} + ]; + + service.open($scope, entities, getContext()); + + $scope.$apply(); + + expect(entityAPI.deleteEntity).toHaveBeenCalledWith('1'); + expect(entityAPI.deleteEntity).toHaveBeenCalledWith('2'); + expect(toastService.add).toHaveBeenCalledWith('success', 'Deleted : entity1, entity2.'); + expect($scope.$emit).toHaveBeenCalledWith('custom_delete_event_passed', [ '1', '2' ]); + }); + + it('should raise failed events if Entity is not deleted', function() { + spyOn(toastService, 'add').and.callThrough(); + spyOn($scope, '$emit').and.callThrough(); + spyOn(entityAPI, 'deleteEntity').and.callThrough(); + + var entities = [{name: 'entity1', id: 'bad'}]; + + service.open($scope, entities, getContext()); + + $scope.$apply(); + + expect(entityAPI.deleteEntity).toHaveBeenCalledWith('bad'); + expect(toastService.add).toHaveBeenCalledWith('error', 'Unable to delete: entity1.'); + expect($scope.$emit).toHaveBeenCalledWith('custom_delete_event_failed', ['bad']); + }); + + it('should raise passed and failed events only for deleted entities', function() { + spyOn(toastService, 'add').and.callThrough(); + spyOn(entityAPI, 'deleteEntity').and.callThrough(); + spyOn($scope, '$emit').and.callThrough(); + + var entities = [ + {name: 'bad_entity', id: 'bad'}, + {name: 'entity2', id: '1'} + ]; + + service.open($scope, entities, getContext()); + + $scope.$apply(); + + expect(entityAPI.deleteEntity).toHaveBeenCalledWith('bad'); + expect(entityAPI.deleteEntity).toHaveBeenCalledWith('1'); + expect(toastService.add).toHaveBeenCalledWith('success', 'Deleted : entity2.'); + expect(toastService.add).toHaveBeenCalledWith('error', 'Unable to delete: bad_entity.'); + expect($scope.$emit).toHaveBeenCalledWith('custom_delete_event_passed', ['1']); + expect($scope.$emit).toHaveBeenCalledWith('custom_delete_event_failed', ['bad']); + }); + + }); + +})();