From aa4d3e603d0620b6b688469a4a37a57601bd6e3b Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 10 Aug 2015 15:28:13 -0500 Subject: [PATCH] Support angular workflow extension as a feature plugin This patch does three things: 1) It adds the extensible service which can be used to allow plugins to customize various containers by adding and removing items such as workflow steps, table actions, and form fields. 2) It adds support for a "feature" plugin that can be used to add angular modules that can then be used to customize various panels. The feature plugin is different from a typical plugin in that it does not add a dashboard or panel, it only adds angular modules, JS files, HTML templates, spec files, styles, etc. 3) It updates the workflow service to make each workflow extensible and adds IDs to the launch instance workflow steps so that this workflow instance is now extensible. An example feature plugin is available here: https://drive.google.com/open?id=0Bye7buoZvOxFOXJvMTNNUTdNNUk Documentation will be provided by this commit: https://review.openstack.org/244407 Implements: blueprint angular-workflow-plugin Change-Id: I8b426b1644c26b1af063d570da19a75ac8c97c27 --- .../util/extensible/extensible.module.js | 30 +++ .../util/extensible/extensible.service.js | 247 ++++++++++++++++++ .../util/extensible/extensible.spec.js | 192 ++++++++++++++ horizon/static/framework/util/util.module.js | 3 +- .../util/workflow/workflow.service.js | 13 +- .../framework/util/workflow/workflow.spec.js | 70 ++++- .../widgets/wizard/wizard.controller.js | 3 + .../launch-instance-workflow.service.js | 6 + openstack_dashboard/utils/settings.py | 5 +- 9 files changed, 553 insertions(+), 16 deletions(-) create mode 100644 horizon/static/framework/util/extensible/extensible.module.js create mode 100644 horizon/static/framework/util/extensible/extensible.service.js create mode 100644 horizon/static/framework/util/extensible/extensible.spec.js diff --git a/horizon/static/framework/util/extensible/extensible.module.js b/horizon/static/framework/util/extensible/extensible.module.js new file mode 100644 index 0000000000..0a1ae5e145 --- /dev/null +++ b/horizon/static/framework/util/extensible/extensible.module.js @@ -0,0 +1,30 @@ +/* + * Copyright 2015 IBM Corp. + * + * 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'; + + /** + * @ngdoc overview + * @name horizon.framework.util.extensible + * @description + * + * # horizon.framework.util.extensible + * + * This module provides a service to allow various UI components to be extended by plugins. + */ + angular + .module('horizon.framework.util.extensible', []); +})(); diff --git a/horizon/static/framework/util/extensible/extensible.service.js b/horizon/static/framework/util/extensible/extensible.service.js new file mode 100644 index 0000000000..d9e7046464 --- /dev/null +++ b/horizon/static/framework/util/extensible/extensible.service.js @@ -0,0 +1,247 @@ +/* + * Copyright 2015 IBM Corp. + * + * 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'; + + var $controller; + + angular + .module('horizon.framework.util.extensible') + .factory('horizon.framework.util.extensible.service', extensibleService); + + extensibleService.$inject = [ + '$controller' + ]; + + /** + * @ngdoc service + * @name horizon.framework.util.extensible.service:extensibleService + * @module horizon.framework.util.extensible.service + * @kind function + * @description + * + * Make a container extensible by decorating it with functions that allow items to be added + * or removed. + * + * @returns {Function} A function used to decorate a container to make it extensible. + */ + function extensibleService(_$controller_) { + $controller = _$controller_; + return makeExtensible; + } + + /** + * A decorator function that makes the given container object extensible by allowing items to + * be added or removed. This can be used on any object that contains multiple items where a user + * might want to insert or remove their own items. Examples include workflow steps, table + * actions, and form fields. Each item must have a unique ID within the container. It also adds + * the ability to add controllers in the scope of the container. The following functions are + * added to the container: + * + * append(item, priority) + * prepend(item, priority) + * after(id, item, priority) + * remove(id) + * replace(id, item) + * addController(controller) + * initControllers($scope) + * + * Priorities are optional and determine the priority for multiple items placed at the same + * position. Higher numbers mean lower priority. If not provided the item will have the lowest + * priority (infinity). + * + * @param {Object} container - The container object to make extensible. + * @param {Object} items - An array of all items in the container in display order. Each item + * should be an object and must have an id property that uniquely identifies the item within + * the container. + * + * For example, to make a workflow extensible: + * + * extensibleService(workflow, workflow.steps); + */ + function makeExtensible(container, items) { + + /** + * Append a new item at the end of the container's items. + * + * @param {Object} item The item to append. + * @param {Number} priority The optional priority for placement at the end of the container. + * Lower priority (higher number) items will be placed at the end but before higher priority + * (lower number) items. + */ + container.append = function(item, priority) { + if (!angular.isNumber(priority)) { + priority = Infinity; + } + var itemsByPosition = getItemsByPosition(items, 'last').reverse(); + var index = items.length; + for (var i = 0; i < itemsByPosition.length; i++) { + if (priority > itemsByPosition[i]._ext.priority) { + index = getItemIndex(items, itemsByPosition[i].id); + break; + } + } + item._ext = {position: 'last', priority: priority}; + items.splice(index, 0, item); + }; + + /** + * Add a new item at the beginning of the container's items. + * + * @param {Object} item The item to add at the front. + * @param {Number} priority The optional priority for placement at the front of the container. + * Lower priority (higher number) items will be placed at the front but after higher priority + * (lower number) items. + */ + container.prepend = function(item, priority) { + if (!angular.isNumber(priority)) { + priority = Infinity; + } + var itemsByPosition = getItemsByPosition(items, 'first'); + var index = itemsByPosition.length; + for (var i = 0; i < itemsByPosition.length; i++) { + if (priority <= itemsByPosition[i]._ext.priority) { + index = getItemIndex(items, itemsByPosition[i].id); + break; + } + } + item._ext = {position: 'first', priority: priority}; + items.splice(index, 0, item); + }; + + /** + * Add a new item after the item with the given id. + * + * @param {String} id The id of an existing item in the container. The new item will be placed + * after this item. + * @param {Object} item The item to insert. + * @param {Number} priority The optional priority for placement in the container at this + * position. Higher priority (lower number) items will be placed more closely after the + * given item id, followed by lower priority (higher number) items. + */ + container.after = function(id, item, priority) { + if (!angular.isNumber(priority)) { + priority = Infinity; + } + var itemsByPosition = getItemsByPosition(items, 'after-' + id); + var index = getItemIndex(items, id) + itemsByPosition.length + 1; + for (var i = 0; i < itemsByPosition.length; i++) { + if (priority <= itemsByPosition[i]._ext.priority) { + index = getItemIndex(items, itemsByPosition[i].id); + break; + } + } + item._ext = {position: 'after-' + id, priority: priority}; + items.splice(index, 0, item); + }; + + /** + * Remove an item from the container and return its index. When removing items from the + * container you will need to account for any data the item might have been contributing to + * the container's model. A custom controller could be used for this purpose and added using + * the addController function. + * + * @param {String} id The id of the item to remove. + * + * @returns {Number} The index of the item being removed. + */ + container.remove = function(id) { + var index = getItemIndex(items, id); + items.splice(index, 1); + return index; + }; + + /** + * Replace an item in the container with the one provided. The new item will need to account + * for any data the original item might have been contributing to the container's model. + * + * @param {String} id The id of an existing item in the container. The item with this id will + * be removed and the new item will be inserted in its place. + * @param {Object} item The item to insert. + */ + container.replace = function(id, item) { + var index = container.remove(id); + items.splice(index, 0, item); + }; + + /** + * The controllers array keeps track of all controllers that should be instantiated with the + * scope of the container. + */ + container.controllers = []; + + /** + * When an extensible container is instantiated, it should call this function to initialize + * any additional controllers added by plugins. A typical plugin itself should not need to + * call this, since any extensible containers created in horizon should be doing this. + */ + container.initControllers = function($scope) { + angular.forEach(container.controllers, function(ctrl) { + $controller(ctrl, {$scope: $scope}); + }); + }; + + /** + * Add a custom controller to be instantiated with the scope of the container when a container + * instance is created. This is useful in cases where a plugin removes an item or otherwise + * wants to make changes to a container without adding any items. For example, to add some + * custom validation to an existing item or react to certain container events. + * + * @param {String} ctrl The controller to add, e.g. 'MyFeatureController'. + */ + container.addController = function(ctrl) { + container.controllers.push(ctrl); + }; + } + + /** + * Get an array of items that have been added at a given position. + * + * @param {Array} items An array of all items in the container. + * @param {String} position The position of the items to return. This can be "first", + * "last", or "after-". + * + * @returns {Array} An array of items. The returned items are sorted by priority. If + * there are no items for the given position an empty array is returned. If two items have + * the same priority then the last one added will "win". This is so the returned items are + * always in the proper order for display purposes. + */ + function getItemsByPosition(items, position) { + return items.filter(function filterItems(item) { + return item._ext && item._ext.position === position; + }).sort(function sortItems(a, b) { + return (a._ext.priority - b._ext.priority) || 1; + }); + } + + /** + * Get the index of a given item. + * + * @param {Array} items An array of all items in the container. + * @param {String} id The id of an item. The index of the item will be returned. + * + * @returns {Number} The index of the item with the given id. + */ + function getItemIndex(items, id) { + for (var i = 0; i < items.length; i++) { + if (items[i].id === id) { + return i; + } + } + throw new Error(interpolate('Item with id %(id)s not found.', {id: id}, true)); + } + +})(); diff --git a/horizon/static/framework/util/extensible/extensible.spec.js b/horizon/static/framework/util/extensible/extensible.spec.js new file mode 100644 index 0000000000..b6c315c809 --- /dev/null +++ b/horizon/static/framework/util/extensible/extensible.spec.js @@ -0,0 +1,192 @@ +(function () { + 'use strict'; + + describe('horizon.framework.util.extensible module', function () { + it('should have been defined', function () { + expect(angular.module('horizon.framework.util.extensible')).toBeDefined(); + }); + }); + + describe('extensible service', function () { + var extensibleService, container, items; + + beforeEach(module('horizon.framework.util.extensible')); + + beforeEach(inject(function ($injector) { + extensibleService = $injector.get('horizon.framework.util.extensible.service'); + container = {}; + items = [ { id: '1' }, { id: '2' }, { id: '3' } ]; + extensibleService(container, items); + })); + + it('can append items', function () { + expect(items.length).toBe(3); + + var item4 = { id: '4' }; + container.append(item4, 1); + expect(items.length).toBe(4); + expect(items[3]).toBe(item4); + + var item5 = { id: '5' }; + container.append(item5); + expect(items.length).toBe(5); + expect(items[3]).toBe(item5); + expect(items[4]).toBe(item4); + + var item6 = { id: '6' }; + container.append(item6, 2); + expect(items.length).toBe(6); + expect(items[3]).toBe(item5); + expect(items[4]).toBe(item6); + expect(items[5]).toBe(item4); + + var item7 = { id: '7' }; + container.append(item7, 1); + expect(items.length).toBe(7); + expect(items[3]).toBe(item5); + expect(items[4]).toBe(item6); + expect(items[5]).toBe(item4); + expect(items[6]).toBe(item7); + + var item8 = { id: '8' }; + container.append(item8); + expect(items.length).toBe(8); + expect(items[3]).toBe(item5); + expect(items[4]).toBe(item8); + expect(items[5]).toBe(item6); + expect(items[6]).toBe(item4); + expect(items[7]).toBe(item7); + }); + + it('can prepend items', function () { + expect(items.length).toBe(3); + + var item4 = { id: '4' }; + container.prepend(item4, 1); + expect(items.length).toBe(4); + expect(items[0]).toBe(item4); + + var item5 = { id: '5' }; + container.prepend(item5); + expect(items.length).toBe(5); + expect(items[0]).toBe(item4); + expect(items[1]).toBe(item5); + + var item6 = { id: '6' }; + container.prepend(item6, 2); + expect(items.length).toBe(6); + expect(items[0]).toBe(item4); + expect(items[1]).toBe(item6); + expect(items[2]).toBe(item5); + + var item7 = { id: '7' }; + container.prepend(item7, 1); + expect(items.length).toBe(7); + expect(items[0]).toBe(item7); + expect(items[1]).toBe(item4); + expect(items[2]).toBe(item6); + expect(items[3]).toBe(item5); + + var item8 = { id: '8' }; + container.prepend(item8); + expect(items.length).toBe(8); + expect(items[0]).toBe(item7); + expect(items[1]).toBe(item4); + expect(items[2]).toBe(item6); + expect(items[3]).toBe(item8); + expect(items[4]).toBe(item5); + }); + + it('can insert items', function () { + expect(items.length).toBe(3); + + var item4 = { id: '4' }; + container.after('1', item4, 1); + expect(items.length).toBe(4); + expect(items[1]).toBe(item4); + + var item5 = { id: '5' }; + container.after('1', item5); + expect(items.length).toBe(5); + expect(items[1]).toBe(item4); + expect(items[2]).toBe(item5); + + var item6 = { id: '6' }; + container.after('1', item6, 2); + expect(items.length).toBe(6); + expect(items[1]).toBe(item4); + expect(items[2]).toBe(item6); + expect(items[3]).toBe(item5); + + var item7 = { id: '7' }; + container.after('1', item7, 1); + expect(items.length).toBe(7); + expect(items[1]).toBe(item7); + expect(items[2]).toBe(item4); + expect(items[3]).toBe(item6); + expect(items[4]).toBe(item5); + + var item8 = { id: '8' }; + container.after('1', item8); + expect(items.length).toBe(8); + expect(items[1]).toBe(item7); + expect(items[2]).toBe(item4); + expect(items[3]).toBe(item6); + expect(items[4]).toBe(item8); + expect(items[5]).toBe(item5); + + var last = { id: 'last' }; + container.after('3', last); + expect(items.length).toBe(9); + expect(items[8]).toBe(last); + + var insert = function() { + container.after('foo', { id: 'bar' }); + }; + expect(insert).toThrowError(Error, 'Item with id foo not found.'); + }); + + it('can remove items', function () { + expect(items.length).toBe(3); + + expect(container.remove('2')).toBe(1); + expect(items.length).toBe(2); + + expect(container.remove('1')).toBe(0); + expect(items.length).toBe(1); + + var remove = function() { + container.remove('foo'); + }; + expect(remove).toThrowError(Error, 'Item with id foo not found.'); + }); + + it('can replace items', function () { + expect(items.length).toBe(3); + + var item4 = { id: '4' }; + container.replace('2', item4); + expect(items.length).toBe(3); + expect(items[1]).toBe(item4); + + var item5 = { id: '5' }; + container.replace('1', item5); + expect(items.length).toBe(3); + expect(items[0]).toBe(item5); + expect(items[1]).toBe(item4); + expect(items[2].id).toBe('3'); + + var replace = function() { + container.replace('foo', { id: 'bar' }); + }; + expect(replace).toThrowError(Error, 'Item with id foo not found.'); + }); + + it('can add controllers', function () { + expect(container.controllers.length).toBe(0); + container.addController('MyController'); + expect(container.controllers.length).toBe(1); + }); + }); + +})(); diff --git a/horizon/static/framework/util/util.module.js b/horizon/static/framework/util/util.module.js index 0aa1027870..30ff44e4ec 100644 --- a/horizon/static/framework/util/util.module.js +++ b/horizon/static/framework/util/util.module.js @@ -10,7 +10,8 @@ 'horizon.framework.util.promise-toggle', 'horizon.framework.util.tech-debt', 'horizon.framework.util.workflow', - 'horizon.framework.util.validators' + 'horizon.framework.util.validators', + 'horizon.framework.util.extensible' ]) .config(config); diff --git a/horizon/static/framework/util/workflow/workflow.service.js b/horizon/static/framework/util/workflow/workflow.service.js index fd573387fc..f9061256ac 100644 --- a/horizon/static/framework/util/workflow/workflow.service.js +++ b/horizon/static/framework/util/workflow/workflow.service.js @@ -1,6 +1,7 @@ /* * (c) Copyright 2015 Hewlett-Packard Development Company, L.P. * (c) Copyright 2015 ThoughtWorks, Inc. + * Copyright 2015 IBM Corp. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +22,10 @@ .module('horizon.framework.util.workflow') .factory('horizon.framework.util.workflow.service', workflowService); + workflowService.$inject = [ + 'horizon.framework.util.extensible.service' + ]; + /** * @ngdoc factory * @name horizon.framework.util.workflow.factory:workflow @@ -87,12 +92,14 @@ } ``` */ - function workflowService() { - return function workflow(spec, decorators) { - angular.forEach(decorators, function service(decorator) { + function workflowService(extensibleService) { + return function createWorkflow(spec, decorators) { + angular.forEach(decorators, function decorate(decorator) { decorator(spec); }); + extensibleService(spec, spec.steps); return spec; }; } + })(); diff --git a/horizon/static/framework/util/workflow/workflow.spec.js b/horizon/static/framework/util/workflow/workflow.spec.js index f693a61225..51ff588af7 100644 --- a/horizon/static/framework/util/workflow/workflow.spec.js +++ b/horizon/static/framework/util/workflow/workflow.spec.js @@ -8,7 +8,7 @@ }); describe('workflow factory', function () { - var workflow, spec; + var workflowService, spec; var decorators = [ function (spec) { angular.forEach(spec.steps, function (step) { @@ -22,26 +22,26 @@ beforeEach(module('horizon.framework')); beforeEach(inject(function ($injector) { - workflow = $injector.get('horizon.framework.util.workflow.service'); + workflowService = $injector.get('horizon.framework.util.workflow.service'); spec = { steps: [ - { requireSomeServices: true }, - { }, - { requireSomeServices: true } + { id: 'base_step_1', requireSomeServices: true }, + { id: 'base_step_2' }, + { id: 'base_step_3', requireSomeServices: true } ] }; })); - it('workflow is defined', function () { - expect(workflow).toBeDefined(); + it('workflowService is defined', function () { + expect(workflowService).toBeDefined(); }); - it('workflow is a function', function () { - expect(angular.isFunction(workflow)).toBe(true); + it('workflowService is a function', function () { + expect(angular.isFunction(workflowService)).toBe(true); }); it('can be decorated', function () { - workflow(spec, decorators); + workflowService(spec, decorators); var steps = spec.steps; expect(steps[0].checkReadiness).toBeDefined(); @@ -52,5 +52,55 @@ expect(steps[2].checkReadiness).toBeDefined(); expect(angular.isFunction(steps[2].checkReadiness)).toBe(true); }); + + it('can be customized', function () { + var workflow = workflowService(spec, decorators); + expect(workflow.steps.length).toBe(3); + expect(workflow.append).toBeDefined(); + expect(workflow.prepend).toBeDefined(); + expect(workflow.after).toBeDefined(); + expect(workflow.replace).toBeDefined(); + expect(workflow.remove).toBeDefined(); + expect(workflow.controllers).toBeDefined(); + expect(workflow.addController).toBeDefined(); + expect(workflow.initControllers).toBeDefined(); + expect(workflow.controllers.length).toBe(0); + + var last = { id: 'last' }; + workflow.append(last); + expect(workflow.steps.length).toBe(4); + expect(workflow.steps[3]).toBe(last); + + var first = { id: 'first' }; + workflow.prepend(first); + expect(workflow.steps.length).toBe(5); + expect(workflow.steps[0]).toBe(first); + expect(workflow.steps[4]).toBe(last); + + var after = { id: 'after' }; + workflow.after('base_step_2', after); + expect(workflow.steps.length).toBe(6); + expect(workflow.steps[0]).toBe(first); + expect(workflow.steps[3]).toBe(after); + expect(workflow.steps[5]).toBe(last); + + var replace = { id: 'replace' }; + workflow.replace('base_step_1', replace); + expect(workflow.steps.length).toBe(6); + expect(workflow.steps[0]).toBe(first); + expect(workflow.steps[1]).toBe(replace); + expect(workflow.steps[3]).toBe(after); + expect(workflow.steps[5]).toBe(last); + + workflow.remove('base_step_2'); + expect(workflow.steps.length).toBe(5); + expect(workflow.steps[0]).toBe(first); + expect(workflow.steps[1]).toBe(replace); + expect(workflow.steps[2]).toBe(after); + expect(workflow.steps[4]).toBe(last); + + workflow.addController('MyController'); + expect(workflow.controllers.length).toBe(1); + }); }); })(); diff --git a/horizon/static/framework/widgets/wizard/wizard.controller.js b/horizon/static/framework/widgets/wizard/wizard.controller.js index 3474ad3183..972d91326b 100644 --- a/horizon/static/framework/widgets/wizard/wizard.controller.js +++ b/horizon/static/framework/widgets/wizard/wizard.controller.js @@ -44,6 +44,9 @@ $scope.initPromise = initTask.promise; $scope.currentIndex = -1; $scope.workflow = $scope.workflow || {}; + if ($scope.workflow.initControllers) { + $scope.workflow.initControllers($scope); + } var steps = $scope.steps = $scope.workflow.steps || []; $scope.wizardForm = {}; diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.js b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.js index 926136f8ba..7f6e19969e 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.js @@ -31,18 +31,21 @@ steps: [ { + id: 'source', title: gettext('Select Source'), templateUrl: basePath + 'source/source.html', helpUrl: basePath + 'source/source.help.html', formName: 'launchInstanceSourceForm' }, { + id: 'flavor', title: gettext('Flavor'), templateUrl: basePath + 'flavor/flavor.html', helpUrl: basePath + 'flavor/flavor.help.html', formName: 'launchInstanceFlavorForm' }, { + id: 'networks', title: gettext('Networks'), templateUrl: basePath + 'network/network.html', helpUrl: basePath + 'network/network.help.html', @@ -50,18 +53,21 @@ requiredServiceTypes: ['network'] }, { + id: 'secgroups', title: gettext('Security Groups'), templateUrl: basePath + 'security-groups/security-groups.html', helpUrl: basePath + 'security-groups/security-groups.help.html', formName: 'launchInstanceAccessAndSecurityForm' }, { + id: 'keypair', title: gettext('Key Pair'), templateUrl: basePath + 'keypair/keypair.html', helpUrl: basePath + 'keypair/keypair.help.html', formName: 'launchInstanceKeypairForm' }, { + id: 'configuration', title: gettext('Configuration'), templateUrl: basePath + 'configuration/configuration.html', helpUrl: basePath + 'configuration/configuration.help.html', diff --git a/openstack_dashboard/utils/settings.py b/openstack_dashboard/utils/settings.py index f0b3e02846..227334d334 100644 --- a/openstack_dashboard/utils/settings.py +++ b/openstack_dashboard/utils/settings.py @@ -47,11 +47,12 @@ def import_dashboard_config(modules): dashboard = submodule.DASHBOARD config[dashboard].update(submodule.__dict__) elif (hasattr(submodule, 'PANEL') - or hasattr(submodule, 'PANEL_GROUP')): + or hasattr(submodule, 'PANEL_GROUP') + or hasattr(submodule, 'FEATURE')): config[submodule.__name__] = submodule.__dict__ else: logging.warning("Skipping %s because it doesn't have DASHBOARD" - ", PANEL or PANEL_GROUP defined.", + ", PANEL, PANEL_GROUP, or FEATURE defined.", submodule.__name__) return sorted(six.iteritems(config), key=lambda c: c[1]['__name__'].rsplit('.', 1))