From 1e1d2b7b0e6d4ec49c8f4fb750e230cb7cd11625 Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Fri, 9 Oct 2015 23:24:44 -0700 Subject: [PATCH] Actions directive for dynamic actions Actions are sensitive to user permissions and are available based on context. This directive allows for dynamically composing menu actions based on how many actions are provided. Co-Authored-By: Errol Pais Change-Id: I75f0e099a5dea723c4d8786ee810e8499db7305c Partially-Implements: blueprint angularize-images-table --- .../action-list/action-create.mock.html | 3 + .../action-list/action-delete.mock.html | 3 + .../action-list/actions-batch.template.html | 3 + .../action-list/actions-create.template.html | 3 + .../actions-delete-selected.template.html | 5 + .../action-list/actions-delete.template.html | 3 + .../action-list/actions-row.template.html | 3 + .../action-list/actions.batch.mock.html | 2 + .../widgets/action-list/actions.directive.js | 124 ++++++++ .../action-list/actions.directive.spec.js | 265 ++++++++++++++++++ .../widgets/action-list/actions.row.mock.html | 2 + .../widgets/action-list/actions.service.js | 225 +++++++++++++++ 12 files changed, 641 insertions(+) create mode 100644 horizon/static/framework/widgets/action-list/action-create.mock.html create mode 100644 horizon/static/framework/widgets/action-list/action-delete.mock.html create mode 100644 horizon/static/framework/widgets/action-list/actions-batch.template.html create mode 100644 horizon/static/framework/widgets/action-list/actions-create.template.html create mode 100644 horizon/static/framework/widgets/action-list/actions-delete-selected.template.html create mode 100644 horizon/static/framework/widgets/action-list/actions-delete.template.html create mode 100644 horizon/static/framework/widgets/action-list/actions-row.template.html create mode 100644 horizon/static/framework/widgets/action-list/actions.batch.mock.html create mode 100644 horizon/static/framework/widgets/action-list/actions.directive.js create mode 100644 horizon/static/framework/widgets/action-list/actions.directive.spec.js create mode 100644 horizon/static/framework/widgets/action-list/actions.row.mock.html create mode 100644 horizon/static/framework/widgets/action-list/actions.service.js diff --git a/horizon/static/framework/widgets/action-list/action-create.mock.html b/horizon/static/framework/widgets/action-list/action-create.mock.html new file mode 100644 index 0000000000..c64423cbe3 --- /dev/null +++ b/horizon/static/framework/widgets/action-list/action-create.mock.html @@ -0,0 +1,3 @@ + + Create Image + diff --git a/horizon/static/framework/widgets/action-list/action-delete.mock.html b/horizon/static/framework/widgets/action-list/action-delete.mock.html new file mode 100644 index 0000000000..3280bb754e --- /dev/null +++ b/horizon/static/framework/widgets/action-list/action-delete.mock.html @@ -0,0 +1,3 @@ + + Delete Image + diff --git a/horizon/static/framework/widgets/action-list/actions-batch.template.html b/horizon/static/framework/widgets/action-list/actions-batch.template.html new file mode 100644 index 0000000000..8daf14e2ae --- /dev/null +++ b/horizon/static/framework/widgets/action-list/actions-batch.template.html @@ -0,0 +1,3 @@ + + $text$ + diff --git a/horizon/static/framework/widgets/action-list/actions-create.template.html b/horizon/static/framework/widgets/action-list/actions-create.template.html new file mode 100644 index 0000000000..a6213b4262 --- /dev/null +++ b/horizon/static/framework/widgets/action-list/actions-create.template.html @@ -0,0 +1,3 @@ + + $text$ + diff --git a/horizon/static/framework/widgets/action-list/actions-delete-selected.template.html b/horizon/static/framework/widgets/action-list/actions-delete-selected.template.html new file mode 100644 index 0000000000..b4219caf78 --- /dev/null +++ b/horizon/static/framework/widgets/action-list/actions-delete-selected.template.html @@ -0,0 +1,5 @@ + + $text$ + diff --git a/horizon/static/framework/widgets/action-list/actions-delete.template.html b/horizon/static/framework/widgets/action-list/actions-delete.template.html new file mode 100644 index 0000000000..29f9d505f6 --- /dev/null +++ b/horizon/static/framework/widgets/action-list/actions-delete.template.html @@ -0,0 +1,3 @@ + + $text$ + diff --git a/horizon/static/framework/widgets/action-list/actions-row.template.html b/horizon/static/framework/widgets/action-list/actions-row.template.html new file mode 100644 index 0000000000..5907553756 --- /dev/null +++ b/horizon/static/framework/widgets/action-list/actions-row.template.html @@ -0,0 +1,3 @@ + + $text$ + diff --git a/horizon/static/framework/widgets/action-list/actions.batch.mock.html b/horizon/static/framework/widgets/action-list/actions.batch.mock.html new file mode 100644 index 0000000000..b2e7295cd8 --- /dev/null +++ b/horizon/static/framework/widgets/action-list/actions.batch.mock.html @@ -0,0 +1,2 @@ + + diff --git a/horizon/static/framework/widgets/action-list/actions.directive.js b/horizon/static/framework/widgets/action-list/actions.directive.js new file mode 100644 index 0000000000..566d5cc51e --- /dev/null +++ b/horizon/static/framework/widgets/action-list/actions.directive.js @@ -0,0 +1,124 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this 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.action-list') + .directive('actions', actions); + + actions.$inject = [ + '$parse', + 'horizon.framework.widgets.action-list.actions.service' + ]; + + /** + * @ngdoc directive + * @name horizon.framework.widgets.action-list.directive:actions + * @element + * @description + * The `actions` directive represents the actions to be + * displayed in a Bootstrap button group or button + * dropdown. + * + * + * Attributes: + * + * allowedActions: actions allowed that can be displayed + * actionListType: allow the buttons to be shown as a list or doropdown + * + * `allowedActions` is a list of allowed actions on the service. + * It's an array of objects of the form: + * { template: {}, permissions: , callback: 'callback'} + * + * `template` is an object that can be + * + * {url: 'template.html'} the location of the template for the action button. + * Use this for complete extensibility and control over what is rendered. + * The template will be responsible for binding the callback and styling. + * + * {type: 'type', item: 'item'} use a known action button type. + * Currently supported values are 'delete', 'delete-selected' and 'create'. + * `item` is optional and if provided will be used in the callback. + * The styling and binding of the callback is done by the template. + * + * {text: 'text', item: 'item'} use an unstyled button with given text. + * `item` is optional and if provided will be used in the callback. + * The styling of the button will vary per the `actionListType` chosen. + * For custom styling of the button, `actionClasses` can be included in + * the template. + * + * `permissions` is expected to be a promise that resolves + * if permitted and is rejected if not. + * `callback` is the method to call when the button is clicked. + * + * `actionListType` can be only be `row` or `batch` + * `batch` is rendered as buttons, `row` is rendered as dropdown menu. + * + * @restrict E + * @scope + * @example + * + * $scope.actions = [{ + * template: { + * text: gettext('Delete Image'), + * type: 'delete', + * item: 'image' + * }, + * permissions: policy.ifAllowed({ rules: [['image', 'delete_image']] }), + * callback: deleteModalService.open + * }, { + * template: { + * text: gettext('Create Volume'), + * item: 'image' + * }, + * permissions: policy.ifAllowed({rules: [['volume', 'volume:create']]}), + * callback: createVolumeModalService.open + * }, { + * template: { + * url: basePath + 'actions/my-custom-action.html' + * }, + * permissions: policy.ifAllowed({ rules: [['image', 'custom']] }), + * callback: customModalService.open + * }] + * + * ``` + * + * + * + * + * + * ``` + */ + function actions( + $parse, + actionsService + ) { + var directive = { + link: link, + restrict: 'E', + template: '' + }; + + return directive; + + function link(scope, element, attrs) { + var listType = attrs.actionListType; + var allowedActions = $parse(attrs.allowedActions)(scope); + + actionsService({scope: scope, element: element, listType: listType}) + .renderActions(allowedActions); + } + } +})(); diff --git a/horizon/static/framework/widgets/action-list/actions.directive.spec.js b/horizon/static/framework/widgets/action-list/actions.directive.spec.js new file mode 100644 index 0000000000..f0a6c2a487 --- /dev/null +++ b/horizon/static/framework/widgets/action-list/actions.directive.spec.js @@ -0,0 +1,265 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this 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('actions directive', function () { + var $scope, $compile, $q, $templateCache, basePath; + + beforeEach(module('templates')); + beforeEach(module('horizon.framework')); + + beforeEach(inject(function ($injector) { + $compile = $injector.get('$compile'); + basePath = $injector.get('horizon.framework.widgets.basePath'); + $scope = $injector.get('$rootScope').$new(); + $q = $injector.get('$q'); + $templateCache = $injector.get('$templateCache'); + })); + + it('should have no buttons if there are no actions', function () { + var element = angular.element(getTemplate('actions.batch')); + $scope.actions = []; + $compile(element)($scope); + $scope.$apply(); + expect(element.children().length).toBe(0); + }); + + it('should allow for specifying action text', function () { + var element = angular.element(getTemplate('actions.batch')); + $scope.actions = [permittedActionWithText('Create Image', 'image')]; + $compile(element)($scope); + $scope.$apply(); + + expect(element.children().length).toBe(1); + var actionList = element.find('action-list'); + expect(actionList.length).toBe(1); + expect(actionList.attr('class').indexOf('btn-addon')).toBeGreaterThan(-1); + expect(actionList.find('button').attr('class')).toEqual('btn btn-default btn-sm'); + expect(actionList.find('button').attr('ng-click')).toEqual('disabled || callback(item)'); + expect(actionList.text().trim()).toEqual('Create Image'); + }); + + it('should allow for specifying by template for create', function () { + var element = angular.element(getTemplate('actions.batch')); + $scope.actions = [permittedActionWithType('create', 'Create Image')]; + $compile(element)($scope); + $scope.$apply(); + + expect(element.children().length).toBe(1); + var actionList = element.find('action-list'); + expect(actionList.length).toBe(1); + expect(actionList.attr('class').indexOf('btn-addon')).toBeGreaterThan(-1); + expect(actionList.find('button').attr('class')).toEqual('btn btn-default btn-sm pull-right'); + expect(actionList.find('button').attr('ng-click')).toEqual('disabled || callback(item)'); + expect(actionList.text().trim()).toEqual('Create Image'); + }); + + it('should allow for specifying by template for delete-selected', function () { + var element = angular.element(getTemplate('actions.batch')); + $scope.actions = [permittedActionWithType('delete-selected', 'Delete Images')]; + $compile(element)($scope); + $scope.$apply(); + + expect(element.children().length).toBe(1); + var actionList = element.find('action-list'); + expect(actionList.length).toBe(1); + expect(actionList.attr('class').indexOf('btn-addon')).toBeGreaterThan(-1); + expect(actionList.find('button').attr('class')) + .toEqual('btn btn-default btn-sm btn-danger pull-right'); + expect(actionList.find('button').attr('ng-click')).toEqual('disabled || callback(item)'); + expect(actionList.text().trim()).toEqual('Delete Images'); + }); + + it('should allow for specifying by template for delete', function () { + var element = angular.element(getTemplate('actions.row')); + $scope.actions = [permittedActionWithType('delete', 'Delete Image')]; + $compile(element)($scope); + $scope.$apply(); + + expect(element.children().length).toBe(1); + var actionList = element.find('action-list'); + expect(actionList.length).toBe(1); + expect(actionList.attr('class').indexOf('btn-addon')).toBeGreaterThan(-1); + expect(actionList.find('button').attr('class')).toEqual('text-danger'); + expect(actionList.find('button').attr('ng-click')).toEqual('disabled || callback(item)'); + expect(actionList.text().trim()).toEqual('Delete Image'); + }); + + it('should have one button if there is one action', function () { + var action = getTemplatePath('action-create', getTemplate()); + + var element = angular.element(getTemplate('actions.batch')); + $scope.actions = [permittedActionWithUrl(action)]; + $compile(element)($scope); + $scope.$apply(); + + expect(element.children().length).toBe(1); + var actionList = element.find('action-list'); + expect(actionList.length).toBe(1); + expect(actionList.attr('class').indexOf('btn-addon')).toBeGreaterThan(-1); + expect(actionList.find('button').attr('class')).toEqual('btn btn-default btn-sm btn-create'); + expect(actionList.find('button').attr('ng-click')).toEqual('disabled || callback(item)'); + expect(actionList.text().trim()).toEqual('Create Image'); + }); + + it('should have no buttons if not permitted', function () { + var element = angular.element(getTemplate('actions.batch')); + $scope.actions = [notPermittedAction()]; + $compile(element)($scope); + $scope.$apply(); + + expect(element.children().length).toBe(0); + }); + + it('should have multiple buttons for multiple actions as a list', function () { + var action1 = getTemplatePath('action-create'); + var action2 = getTemplatePath('action-delete'); + + var element = angular.element(getTemplate('actions.batch')); + $scope.actions = [permittedActionWithUrl(action1), permittedActionWithUrl(action2)]; + $compile(element)($scope); + $scope.$apply(); + + expect(element.children().length).toBe(2); + var actionList = element.find('action-list'); + expect(actionList.length).toBe(2); + expect(actionList.attr('class').indexOf('btn-addon')).toBeGreaterThan(-1); + expect(actionList.find('button.btn-create').text().trim()).toEqual('Create Image'); + expect(actionList.find('button.text-danger').text().trim()).toEqual('Delete Image'); + }); + + it('should have as many buttons as permitted', function () { + var actionTemplate1 = getTemplatePath('action-create'); + + var element = angular.element(getTemplate('actions.batch')); + $scope.actions = [permittedActionWithUrl(actionTemplate1), notPermittedAction()]; + $compile(element)($scope); + $scope.$apply(); + + expect(element.children().length).toBe(1); + var actionList = element.find('action-list'); + expect(actionList.length).toBe(1); + expect(actionList.attr('class').indexOf('btn-addon')).toBeGreaterThan(-1); + expect(actionList.find('button.btn-default').text().trim()).toEqual('Create Image'); + }); + + it('should have multiple buttons as a dropdown', function () { + var action1 = getTemplatePath('action-create'); + var action2 = getTemplatePath('action-delete'); + + var element = angular.element(getTemplate('actions.row')); + $scope.actions = [permittedActionWithUrl(action1), permittedActionWithUrl(action2)]; + $compile(element)($scope); + $scope.$apply(); + + expect(element.children().length).toBe(1); + var actionList = element.find('action-list'); + expect(actionList.length).toBe(1); + expect(actionList.attr('class').indexOf('btn-addon')).toEqual(-1); + expect(actionList.find('button .fa-user-plus').text().trim()).toEqual('Create Image'); + expect(actionList.find('li a.text-danger').text().trim()).toEqual('Delete Image'); + }); + + it('should have multiple buttons as a dropdown for actions text', function () { + var element = angular.element(getTemplate('actions.row')); + $scope.actions = [ + permittedActionWithText('Create Image', 'image'), + permittedActionWithText('Delete Image', 'image', 'text-danger') + ]; + $compile(element)($scope); + $scope.$apply(); + + expect(element.children().length).toBe(1); + var actionList = element.find('action-list'); + expect(actionList.length).toBe(1); + expect(actionList.attr('class').indexOf('btn-addon')).toEqual(-1); + expect(actionList.find('button .fa').text().trim()).toEqual('Create Image'); + expect(actionList.find('li a.text-danger').text().trim()).toEqual('Delete Image'); + }); + + it('should have one button if only one permitted for dropdown', function () { + var element = angular.element(getTemplate('actions.row')); + $scope.actions = [ + permittedActionWithUrl(getTemplatePath('action-create')), + notPermittedAction() + ]; + $compile(element)($scope); + $scope.$apply(); + + expect(element.children().length).toBe(1); + var actionList = element.find('action-list'); + expect(actionList.length).toBe(1); + expect(actionList.attr('class').indexOf('btn-addon')).toBeGreaterThan(-1); + expect(actionList.find('button .fa-user-plus').text().trim()).toEqual('Create Image'); + }); + + function permittedActionWithUrl(templateUrl) { + return { + template: {url: templateUrl}, + permissions: getPermission(true), + callback: 'callback' + }; + } + + function permittedActionWithText(text, item, actionClasses) { + return { + template: { + text: text, + item: item, + actionClasses: actionClasses + }, + permissions: getPermission(true), + callback: 'callback' + }; + } + + function permittedActionWithType(templateType, text, item) { + return { + template: { + type: templateType, + text: text, + item: item + }, + permissions: getPermission(true), + callback: 'callback' + }; + } + + function notPermittedAction() { + return {template: 'dummy', permissions: getPermission(false), callback: 'callback'}; + } + + function getTemplate(templateName) { + return $templateCache.get(getTemplatePath(templateName)); + } + + function getTemplatePath(templateName) { + return basePath + 'action-list/' + templateName + '.mock.html'; + } + + function getPermission(allowed) { + var deferred = $q.defer(); + + if (allowed) { + deferred.resolve(); + } else { + deferred.reject(); + } + + return deferred.promise; + } + + }); +})(); diff --git a/horizon/static/framework/widgets/action-list/actions.row.mock.html b/horizon/static/framework/widgets/action-list/actions.row.mock.html new file mode 100644 index 0000000000..a4366a5542 --- /dev/null +++ b/horizon/static/framework/widgets/action-list/actions.row.mock.html @@ -0,0 +1,2 @@ + + diff --git a/horizon/static/framework/widgets/action-list/actions.service.js b/horizon/static/framework/widgets/action-list/actions.service.js new file mode 100644 index 0000000000..0fb77c51e0 --- /dev/null +++ b/horizon/static/framework/widgets/action-list/actions.service.js @@ -0,0 +1,225 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this 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.action-list') + .factory('horizon.framework.widgets.action-list.actions.service', actionsService); + + actionsService.$inject = [ + '$compile', + '$http', + '$q', + '$templateCache', + 'horizon.framework.widgets.basePath' + ]; + + function actionsService($compile, $http, $q, $templateCache, basePath) { + return function(spec) { + return createService(spec.scope, spec.element, spec.listType); + }; + + /////////////// + + function createService(scope, element, listType) { + var service = { + renderActions: renderActions + }; + + return service; + + function renderActions(allowedActions) { + getPermittedActions(allowedActions).then(renderPermittedActions); + } + + /** + * Get the permitted actions from the list of allowed actions + * by resolving the promises in the permissions object. + */ + function getPermittedActions(allowedActions) { + var deferred = $q.defer(); + var permittedActions = []; + var promises = allowedActions.map(actionPermitted); + + $q.all(promises).then(onResolved); + + return deferred.promise; + + function actionPermitted(action) { + var deferredInner = $q.defer(); + action.permissions.then(onSuccess, onError); + return deferredInner.promise; + + function onSuccess() { + permittedActions.push(action); + deferredInner.resolve(); + } + + function onError() { + deferredInner.resolve(); + } + } + + function onResolved() { + deferred.resolve(permittedActions); + } + } + + /** + * Render permitted actions as per the list type + */ + function renderPermittedActions(permittedActions) { + if (permittedActions.length > 0) { + var templateFetch = $q.all(permittedActions.map(getTemplate)); + + if (listType === 'batch' || permittedActions.length === 1) { + element.addClass('btn-addon'); + templateFetch.then(addButtons); + } else { + templateFetch.then(addDropdown); + } + } + } + + /** + * Add all the buttons as a list of buttons + */ + function addButtons(templates) { + templates.forEach(addTemplate); + } + + /** + * Add the button template as a button + */ + function addTemplate(template) { + element.append(renderButton(template, scope)); + } + + /** + * Add all the buttons as a dropdown button group + */ + function addDropdown(templates) { + var splitButton = getSplitButton(templates[0]); + var actionList = []; + + for (var iCnt = 1; iCnt < templates.length; iCnt++) { + actionList.push(getMenuButton(templates[iCnt])); + } + + var actionListElem = renderList(actionList, splitButton, scope); + element.append($compile(actionListElem)(scope)); + } + + /** + * Render buttons each inside the element + */ + function renderButton(actionTemplate, scope) { + var actionElement = angular.element(actionTemplate.template); + actionElement.attr('callback', actionTemplate.callback); + + var actionListElem = angular.element(''); + actionListElem.addClass('btn-addon'); + actionListElem.append(actionElement); + + return $compile(actionListElem)(scope); + } + + /** + * Render buttons inside a single element + * with the first being a `split-button` and the rest as + * `menu-item` buttons + */ + function renderList(actionList, splitButton, scope) { + var actionListElem = angular.element(''); + actionListElem.attr('dropdown', 'true'); + actionListElem.append(splitButton); + actionListElem.append(getMenu(actionList, scope)); + return actionListElem; + } + + /** + * Get the HTML for a `split-button` + */ + function getSplitButton(actionTemplate) { + var actionElement = angular.element(actionTemplate.template); + actionElement.attr('button-type', 'split-button'); + actionElement.attr('action-classes', '"btn btn-default"'); + actionElement.attr('callback', actionTemplate.callback); + return actionElement; + } + + /** + * Get the HTML for a `menu` + */ + function getMenu(actionList, scope) { + var menuElem = angular.element(''); + menuElem.append(actionList); + return menuElem; + } + + /** + * Get the HTML for a `menu-item` button + */ + function getMenuButton(actionTemplate) { + var actionElement = angular.element(actionTemplate.template); + actionElement.attr('button-type', 'menu-item'); + actionElement.attr('callback', actionTemplate.callback); + return actionElement; + } + + /** + * Fetch the HTML Template for the Action + */ + function getTemplate(action) { + var defered = $q.defer(); + $http.get(getTemplateUrl(action), {cache: $templateCache}).then(onTemplateGet); + return defered.promise; + + function onTemplateGet(response) { + var template = response.data + .replace('$action-classes$', action.template.actionClasses || '') + .replace('$text$', action.template.text) + .replace('$item$', action.template.item); + defered.resolve({template: template, callback: action.callback}); + } + } + + /** + * Gets the Template URL for the Action + * The template can be + * 1. Explicit URL + * 2. Based of a list of known templates + * 3. Based of the type of List + * + * Uses the `listType` which can either be `row` or `batch`. + */ + function getTemplateUrl(action) { + if (angular.isDefined(action.template.url)) { + // use the given URL + return action.template.url; + } else if (angular.isDefined(action.template.type)) { + // determine the template by the given type + return basePath + 'action-list/actions-' + action.template.type + '.template.html'; + } else { + // determine the template by `listType` which can be row or batch + return basePath + 'action-list/actions-' + listType + '.template.html'; + } + } + + } + + } // end of service +})(); // end of IIFE