Merge "Actions directive for dynamic actions"

This commit is contained in:
Jenkins 2015-11-09 21:43:27 +00:00 committed by Gerrit Code Review
commit 80fa84bcc9
12 changed files with 641 additions and 0 deletions

View File

@ -0,0 +1,3 @@
<action action-classes="'btn btn-default btn-sm btn-create'">
<span class="fa fa-user-plus">Create Image</span>
</action>

View File

@ -0,0 +1,3 @@
<action action-classes="'text-danger'" item="image">
<span class="fa fa-trash-o text-danger" translate>Delete Image</span>
</action>

View File

@ -0,0 +1,3 @@
<action action-classes="'$action-classes$ btn btn-default btn-sm'">
<span class="fa">$text$</span>
</action>

View File

@ -0,0 +1,3 @@
<action action-classes="'btn btn-default btn-sm pull-right'">
<span class="fa fa-user-plus">$text$</span>
</action>

View File

@ -0,0 +1,5 @@
<action action-classes="'btn btn-default btn-sm btn-danger pull-right'"
disabled="numSelected === 0"
item="selected">
<span class="fa fa-trash-o">$text$</span>
</action>

View File

@ -0,0 +1,3 @@
<action action-classes="'text-danger'" item="$item$">
<span class="fa fa-trash-o">$text$</span>
</action>

View File

@ -0,0 +1,3 @@
<action action-classes="'$action-classes$'" item="$item$">
<span class="fa">$text$</span>
</action>

View File

@ -0,0 +1,2 @@
<actions allowed-actions="actions" action-list-type="batch">
</actions>

View File

@ -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: <promise to determine 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
* }]
*
* ```
* <actions allowed-actions="actions" action-list-type="row">
* </actions>
*
* <actions allowed-actions="actions" action-list-type="batch">
* </actions>
* ```
*/
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);
}
}
})();

View File

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

View File

@ -0,0 +1,2 @@
<actions allowed-actions="actions" action-list-type="row">
</actions>

View File

@ -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 <action-list> element
*/
function renderButton(actionTemplate, scope) {
var actionElement = angular.element(actionTemplate.template);
actionElement.attr('callback', actionTemplate.callback);
var actionListElem = angular.element('<action-list>');
actionListElem.addClass('btn-addon');
actionListElem.append(actionElement);
return $compile(actionListElem)(scope);
}
/**
* Render buttons inside a single <action-list> element
* with the first being a `split-button` and the rest as
* `menu-item` buttons
*/
function renderList(actionList, splitButton, scope) {
var actionListElem = angular.element('<action-list>');
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('<menu>');
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