From 0e957dd41ac027adc55f8a06434a53f173ae9a52 Mon Sep 17 00:00:00 2001 From: Rob Cresswell Date: Wed, 22 Jun 2016 11:52:53 +0100 Subject: [PATCH] Add Angular Schema Form This patch adds Angular Schema Form[1] and its requirements to Horizon. There are a number of advantages to this over the current methods of defining forms and workflows: - All fields have an individual template, making theming improvements, bug fixes, and bootstrap conformity easier. - The file and line count, especially for workflows, is dramatically reduced. The Create Net workflow, for example, goes from 12+ files to 2, with a big reduction in boilerplate HTML. - All field validation messages are standardised, so we can match them across Horizon and plugins What this patch contains: - Many common form fields, including things like the themable checkboxes and selects. - A basic modal template that can be passed with ui-bootstraps $modal service to take advantage of schema-form Next steps: - Remove the other modal templates so we can standardise. A single template opened from the $modal service is fine, and we shouldn't need several directives. In this case, we should deprecate them, as the modal forms will be used elsewhere. - Map commonly used form items, like transfer tables, to a schema form type like array (they serve similar purposes, so maybe thats what should be replaced) - Use themable selects instead of regular ones 1. http://schemaform.io/ Co-Authored-By: Tyr Johanson Implements: blueprint angular-schema-form Change-Id: Ib22b2d0db2c4d4775fdef62a180cc994e8ae6280 --- horizon/karma.conf.js | 3 + horizon/static/framework/util/util.module.js | 1 + horizon/static/framework/util/uuid/uuid.js | 45 +++++ .../static/framework/util/uuid/uuid.spec.js | 70 ++++++++ .../widgets/form/builders.provider.js | 66 +++++++ .../widgets/form/builders.provider.spec.js | 64 +++++++ .../framework/widgets/form/decorator.js | 162 ++++++++++++++++++ .../framework/widgets/form/decorator.spec.js | 60 +++++++ .../framework/widgets/form/fields/array.html | 39 +++++ .../widgets/form/fields/checkbox.html | 18 ++ .../widgets/form/fields/checkboxes.html | 22 +++ .../widgets/form/fields/default.html | 55 ++++++ .../widgets/form/fields/fieldset.html | 4 + .../framework/widgets/form/fields/help.html | 1 + .../widgets/form/fields/radio-buttons.html | 23 +++ .../widgets/form/fields/radios-inline.html | 19 ++ .../framework/widgets/form/fields/radios.html | 19 ++ .../widgets/form/fields/section.html | 1 + .../framework/widgets/form/fields/select.html | 16 ++ .../framework/widgets/form/fields/submit.html | 15 ++ .../widgets/form/fields/tabarray.html | 70 ++++++++ .../framework/widgets/form/fields/tabs.html | 41 +++++ .../widgets/form/fields/textarea.html | 39 +++++ .../framework/widgets/form/form.module.js | 25 +++ .../widgets/form/modal-form.controller.js | 64 +++++++ .../form/modal-form.controller.spec.js | 71 ++++++++ .../framework/widgets/form/modal-form.html | 51 ++++++ .../widgets/form/modal-form.service.js | 72 ++++++++ .../widgets/form/modal-form.service.spec.js | 74 ++++++++ .../help-panel/help-panel.directive.js | 12 +- .../help-panel/help-panel.directive.spec.js | 6 +- .../widgets/help-panel/help-panel.html | 11 +- .../framework/widgets/widgets.module.js | 1 + .../framework/widgets/wizard/wizard.spec.js | 2 +- openstack_dashboard/karma.conf.js | 3 + openstack_dashboard/static/app/app.module.js | 1 + .../dashboard/scss/components/_forms.scss | 1 - .../scss/components/_help_panel.scss | 4 +- openstack_dashboard/static_settings.py | 12 ++ .../templates/horizon/_scripts.html | 3 + .../horizon/components/_help_panel.scss | 4 +- ...-angular-schema-form-bbe1aedf644b53db.yaml | 7 + requirements.txt | 3 + 43 files changed, 1264 insertions(+), 16 deletions(-) create mode 100644 horizon/static/framework/util/uuid/uuid.js create mode 100644 horizon/static/framework/util/uuid/uuid.spec.js create mode 100644 horizon/static/framework/widgets/form/builders.provider.js create mode 100644 horizon/static/framework/widgets/form/builders.provider.spec.js create mode 100644 horizon/static/framework/widgets/form/decorator.js create mode 100644 horizon/static/framework/widgets/form/decorator.spec.js create mode 100644 horizon/static/framework/widgets/form/fields/array.html create mode 100644 horizon/static/framework/widgets/form/fields/checkbox.html create mode 100644 horizon/static/framework/widgets/form/fields/checkboxes.html create mode 100644 horizon/static/framework/widgets/form/fields/default.html create mode 100644 horizon/static/framework/widgets/form/fields/fieldset.html create mode 100644 horizon/static/framework/widgets/form/fields/help.html create mode 100644 horizon/static/framework/widgets/form/fields/radio-buttons.html create mode 100644 horizon/static/framework/widgets/form/fields/radios-inline.html create mode 100644 horizon/static/framework/widgets/form/fields/radios.html create mode 100644 horizon/static/framework/widgets/form/fields/section.html create mode 100644 horizon/static/framework/widgets/form/fields/select.html create mode 100644 horizon/static/framework/widgets/form/fields/submit.html create mode 100644 horizon/static/framework/widgets/form/fields/tabarray.html create mode 100644 horizon/static/framework/widgets/form/fields/tabs.html create mode 100644 horizon/static/framework/widgets/form/fields/textarea.html create mode 100644 horizon/static/framework/widgets/form/form.module.js create mode 100644 horizon/static/framework/widgets/form/modal-form.controller.js create mode 100644 horizon/static/framework/widgets/form/modal-form.controller.spec.js create mode 100644 horizon/static/framework/widgets/form/modal-form.html create mode 100644 horizon/static/framework/widgets/form/modal-form.service.js create mode 100644 horizon/static/framework/widgets/form/modal-form.service.spec.js create mode 100644 releasenotes/notes/bp-angular-schema-form-bbe1aedf644b53db.yaml diff --git a/horizon/karma.conf.js b/horizon/karma.conf.js index 434fbb1d9d..b8eeed9bc0 100644 --- a/horizon/karma.conf.js +++ b/horizon/karma.conf.js @@ -82,6 +82,9 @@ module.exports = function (config) { xstaticPath + 'angular_fileupload/data/ng-file-upload-all.js', xstaticPath + 'spin/data/spin.js', xstaticPath + 'spin/data/spin.jquery.js', + xstaticPath + 'tv4/data/tv4.js', + xstaticPath + 'objectpath/data/ObjectPath.js', + xstaticPath + 'angular_schema_form/data/schema-form.js', // from jasmine_tests.py; only those that are deps for others 'horizon/js/horizon.js', diff --git a/horizon/static/framework/util/util.module.js b/horizon/static/framework/util/util.module.js index 6d95727f44..4de682f073 100644 --- a/horizon/static/framework/util/util.module.js +++ b/horizon/static/framework/util/util.module.js @@ -12,6 +12,7 @@ 'horizon.framework.util.promise-toggle', 'horizon.framework.util.q', 'horizon.framework.util.tech-debt', + 'horizon.framework.util.uuid', 'horizon.framework.util.workflow', 'horizon.framework.util.validators', 'horizon.framework.util.extensible' diff --git a/horizon/static/framework/util/uuid/uuid.js b/horizon/static/framework/util/uuid/uuid.js new file mode 100644 index 0000000000..a728934f59 --- /dev/null +++ b/horizon/static/framework/util/uuid/uuid.js @@ -0,0 +1,45 @@ +/* + * (c) Copyright 2016 Cisco Systems + * + * 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.util.uuid', []) + .factory('horizon.framework.util.uuid.service', uuidService); + + /** + * @name horizon.framework.util.uuid + * @description + * Generates a UUID. This is useful for ensuring HTML components + * have unique IDs for interactions. + */ + function uuidService() { + var service = { + generate: generate + }; + return service; + + function generate() { + var d = new Date().getTime(); + var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = (d + Math.random() * 16) % 16 | 0; + d = Math.floor(d / 16); + return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); + }); + return uuid; + } + } +})(); diff --git a/horizon/static/framework/util/uuid/uuid.spec.js b/horizon/static/framework/util/uuid/uuid.spec.js new file mode 100644 index 0000000000..5b57294394 --- /dev/null +++ b/horizon/static/framework/util/uuid/uuid.spec.js @@ -0,0 +1,70 @@ +/* + * (c) Copyright 2016 Cisco Systems + * + * 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('horizon.framework.util.uuid module', function() { + it('should have been defined', function () { + expect(angular.module('horizon.framework.util.uuid')).toBeDefined(); + }); + }); + + describe('uuid', function () { + var uuid; + + beforeEach(module('horizon.framework')); + beforeEach(inject(function ($injector) { + uuid = $injector.get('horizon.framework.util.uuid.service'); + })); + + it('should be defined', function () { + expect(uuid).toBeDefined(); + }); + + it('should generate multiple unique IDs', function() { + var unique = []; + var ids = []; + var i, j, potentialUUID, uniqueLen, isUnique; + + // Generate 10 IDs + for (i = 0; i < 10; i += 1) { + ids.push(uuid.generate()); + } + + // Check that the IDs are unique + // Iterate through the IDs, check that it isn't part of our unique list, + // then append + for (i -= 1; i >= 0; i -= 1) { + potentialUUID = ids[i]; + isUnique = true; + for (j = 0, uniqueLen = unique.length; j < uniqueLen; j += 1) { + if (potentialUUID === unique[j]) { + isUnique = false; + } + } + if (isUnique) { + unique.push(potentialUUID); + } + } + + // Reverse the array, because Jasmine's "toEqual" won't work otherwise. + unique.reverse(); + + expect(ids).toEqual(unique); + }); + }); +}()); diff --git a/horizon/static/framework/widgets/form/builders.provider.js b/horizon/static/framework/widgets/form/builders.provider.js new file mode 100644 index 0000000000..a502ff4037 --- /dev/null +++ b/horizon/static/framework/widgets/form/builders.provider.js @@ -0,0 +1,66 @@ +/** + * (c) Copyright 2016 Cisco Systems + * + * 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('schemaForm') + .provider('hzBuilder', provider); + + /** + * @ngDoc provider + * @name horizon.framework.widgets.form.builders + */ + function provider() { + var builders = { + tabsBuilder: tabsBuilder + }; + + this.$get = function() { + return builders; + }; + + function tabsBuilder(args) { + if (args.form.tabs && args.form.tabs.length > 0) { + var tabLi = args.fieldFrag.querySelector('li'); + /* eslint-disable max-len */ + tabLi.setAttribute('ng-if', '!tab.condition ? true : evalExpr(tab.condition, { model: model, "arrayIndex": $index })'); + /* eslint-enable max-len */ + var tabContent = args.fieldFrag.querySelector('.tab-content'); + + args.form.tabs.forEach(function(tab, index) { + tab.items.forEach(function(item) { + if (item.required) { + tab.required = true; + } + }); + var div = document.createElement('div'); + div.setAttribute('ng-show', 'model.tabs.selected === ' + index); + div.setAttribute('ng-if', tab.condition || true); + + var childFrag = args.build( + tab.items, + args.path + '.tabs[' + index + '].items', + args.state + ); + div.appendChild(childFrag); + tabContent.appendChild(div); + }); + } + } + } +})(); diff --git a/horizon/static/framework/widgets/form/builders.provider.spec.js b/horizon/static/framework/widgets/form/builders.provider.spec.js new file mode 100644 index 0000000000..7ebaec27e4 --- /dev/null +++ b/horizon/static/framework/widgets/form/builders.provider.spec.js @@ -0,0 +1,64 @@ +/* + * (c) Copyright 2016 Cisco Systems + * + * 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('hzBuilderProvider', function() { + var provider, $compile, $scope, args, element, elementTwo; + + var html = "
"; + var htmlTwo = "
"; + + beforeEach(module('schemaForm')); + + beforeEach(inject(function($injector) { + $scope = $injector.get('$rootScope').$new(); + $compile = $injector.get('$compile'); + provider = $injector.get('hzBuilder'); + element = $compile(html)($scope); + elementTwo = $compile(htmlTwo)($scope); + $scope.$apply(); + + args = { form: + { tabs: [ + { "title": "tabZero", "items": [ { "title": "item", "required": true } ] }, + { "title": "tabOne", "condition": true, "items": [] }, + { "title": "tabTwo", "condition": false, "items": [] } + ]}, + fieldFrag: element[0], + build: function() { + return elementTwo[0]; + } + }; + })); + + it('should correctly build tabs', function() { + provider.tabsBuilder(args); + + expect(element[0].querySelector('li').getAttribute("ng-if")).toBeDefined(); + expect( + element[0].querySelector('.tab-content').querySelector('div').getAttribute("ng-show") + ).toBe('model.tabs.selected === 0'); + expect( + element[0].querySelector('.tab-content').querySelector('div').getAttribute("ng-if") + ).toBe('true'); + expect(args.form.tabs[0].required).toBe(true); + expect(args.form.tabs[1].required).not.toBeDefined(); + expect(args.form.tabs[2].required).not.toBeDefined(); + }); + }); +})(); diff --git a/horizon/static/framework/widgets/form/decorator.js b/horizon/static/framework/widgets/form/decorator.js new file mode 100644 index 0000000000..d77d06635a --- /dev/null +++ b/horizon/static/framework/widgets/form/decorator.js @@ -0,0 +1,162 @@ +(function() { + 'use strict'; + + // Horizon custom decorator for angular schema form + angular + .module('schemaForm') + .config(config); + + config.$inject = [ + 'schemaFormDecoratorsProvider', + 'sfBuilderProvider', + 'sfPathProvider', + 'sfErrorMessageProvider', + '$windowProvider', + 'hzBuilderProvider' + ]; + + function config( + decoratorsProvider, + sfBuilderProvider, + sfPathProvider, + sfErrorMessageProvider, + $windowProvider, + hzBuilderProvider + ) { + var base = $windowProvider.$get().STATIC_URL + 'framework/widgets/form/fields/'; + var simpleTransclusion = sfBuilderProvider.builders.simpleTransclusion; + var ngModelOptions = sfBuilderProvider.builders.ngModelOptions; + var ngModel = sfBuilderProvider.builders.ngModel; + var sfField = sfBuilderProvider.builders.sfField; + var condition = sfBuilderProvider.builders.condition; + var array = sfBuilderProvider.builders.array; + var tabs = hzBuilderProvider.$get().tabsBuilder; + var defaults = [sfField, ngModel, ngModelOptions, condition]; + + // Define all our templates + decoratorsProvider.defineDecorator('bootstrapDecorator', { + textarea: { + template: base + 'textarea.html', + builder: defaults + }, + fieldset: { + template: base + 'fieldset.html', + builder: [sfField, simpleTransclusion, condition] + }, + array: { + template: base + 'array.html', + builder: [sfField, ngModelOptions, ngModel, array, condition] + }, + tabarray: { + template: base + 'tabarray.html', + builder: [sfField, ngModelOptions, ngModel, array, condition] + }, + tabs: { + template: base + 'tabs.html', + builder: [sfField, ngModelOptions, tabs, condition] + }, + section: { + template: base + 'section.html', + builder: [sfField, simpleTransclusion, condition] + }, + conditional: { + template: base + 'section.html', + builder: [sfField, simpleTransclusion, condition] + }, + select: { + template: base + 'select.html', + builder: defaults + }, + checkbox: { + template: base + 'checkbox.html', + builder: defaults + }, + checkboxes: { + template: base + 'checkboxes.html', + builder: [sfField, ngModelOptions, ngModel, array, condition] + }, + number: { + template: base + 'default.html', + builder: defaults + }, + password: { + template: base + 'default.html', + builder: defaults + }, + submit: { + template: base + 'submit.html', + builder: defaults + }, + button: { + template: base + 'submit.html', + builder: defaults + }, + radios: { + template: base + 'radios.html', + builder: defaults + }, + 'radios-inline': { + template: base + 'radios-inline.html', + builder: defaults + }, + radiobuttons: { + template: base + 'radio-buttons.html', + builder: defaults + }, + help: { + template: base + 'help.html', + builder: defaults + }, + 'default': { + template: base + 'default.html', + builder: defaults + } + }, []); + + // Define and register our validation messages + // These are the error codes provided by the tv4 validator: + // https://github.com/geraintluff/tv4/blob/master/source/api.js + var defaultMessages = { + "default": gettext("The data in this field is invalid"), + 0: gettext("Invalid type, expected {$schema.type$}"), + 1: gettext("No enum match for: {$viewValue$}"), + 10: gettext("Data does not match any schemas from 'anyOf'"), + 11: gettext("Data does not match any schemas from 'oneOf'"), + 12: gettext("Data is valid against more than one schema from 'oneOf'"), + 13: gettext("Data matches schema from 'not'"), + // Numeric errors + 100: gettext("{$viewValue$} is not a multiple of {$schema.multipleOf$}"), + 101: gettext("{$viewValue$} is less than the allowed minimum of {$schema.minimum$}"), + 102: gettext("{$viewValue$} is equal to the exclusive minimum {$schema.minimum$}"), + 103: gettext("{$viewValue$} is greater than the allowed maximum of {$schema.maximum$}"), + 104: gettext("{$viewValue$} is equal to the exclusive maximum {$schema.maximum$}"), + 105: gettext("{$viewValue$} is not a valid number"), + // String errors + /* eslint-disable max-len */ + 200: gettext("{$schema.title$} is too short ({$viewValue.length$} characters), minimum {$schema.minLength$}"), + 201: gettext("{$schema.title$} is too long ({$viewValue.length$} characters), maximum {$schema.maxLength$}"), + /* eslint-enable max-len */ + 202: gettext("{$schema.title$} is formatted incorrectly"), + // Object errors + 300: gettext("Too few properties defined, minimum {$schema.minProperties$}"), + 301: gettext("Too many properties defined, maximum {$schema.maxProperties$}"), + 302: gettext("{$schema.title$} is a required field"), + 303: gettext("Additional properties not allowed"), + 304: gettext("Dependency failed - key must exist"), + // Array errors + 400: gettext("Array is too short ({$value.length$} items), minimum {$schema.minItems$}"), + 401: gettext("Array is too long ({$value.length$} items), maximum {$schema.maxItems$}"), + 402: gettext("Array items must be unique"), + 403: gettext("Additional items not allowed"), + // Format errors + 500: gettext("Format validation failed"), + 501: gettext("Keyword failed: '{$title$}'"), + // Schema structure + 600: gettext("Circular $refs"), + // Non-standard validation options + 1000: gettext("Unknown property (not in schema)") + }; + + sfErrorMessageProvider.setDefaultMessages(defaultMessages); + } +})(); diff --git a/horizon/static/framework/widgets/form/decorator.spec.js b/horizon/static/framework/widgets/form/decorator.spec.js new file mode 100644 index 0000000000..39072c45ed --- /dev/null +++ b/horizon/static/framework/widgets/form/decorator.spec.js @@ -0,0 +1,60 @@ +/* + * (c) Copyright 2016 Cisco Systems + * + * 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('schemaForm decorator', function() { + var decoratorsProvider, sfErrorMessageProvider; + + beforeEach(module('schemaForm')); + beforeEach(module('templates')); + beforeEach(inject(function($injector) { + decoratorsProvider = $injector.get('schemaFormDecorators'); + sfErrorMessageProvider = $injector.get('sfErrorMessage'); + })); + + it('should be defined', function() { + expect(angular.module('schemaForm')).toBeDefined(); + }); + + it('should build tabs correctly', function() { + }); + + it('should define messages for all the error codes', function() { + // We don't need to check the specifics of each message in a test, + // but we should check they all exist + var messageCodes = Object.keys(sfErrorMessageProvider.defaultMessages); + var expectedMessageCodes = [ + '0', '1', '10', '11', '12', '13', '100', '101', '102', '103', '104', + '105', '200', '201', '202', '300', '301', '302', '303', '304', '400', + '401', '402', '403', '500', '501', '600', '1000', 'default' + ]; + expect(messageCodes).toEqual(expectedMessageCodes); + }); + + it('should define all the fields', function() { + var fields = Object.keys(decoratorsProvider.decorator('bootstrapDecorator')); + var expectedFields = [ + '__name', 'textarea', 'fieldset', 'array', 'tabarray', 'tabs', 'section', + 'conditional', 'select', 'checkbox', 'checkboxes', 'number', + 'password', 'submit', 'button', 'radios', 'radios-inline', 'radiobuttons', + 'help', 'default' + ]; + expect(fields).toEqual(expectedFields); + }); + }); +})(); diff --git a/horizon/static/framework/widgets/form/fields/array.html b/horizon/static/framework/widgets/form/fields/array.html new file mode 100644 index 0000000000..0af7b93da3 --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/array.html @@ -0,0 +1,39 @@ +
+ +
    +
  1. + +
  2. +
+
+
+
+ + +
+
diff --git a/horizon/static/framework/widgets/form/fields/checkbox.html b/horizon/static/framework/widgets/form/fields/checkbox.html new file mode 100644 index 0000000000..bb7a0a9755 --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/checkbox.html @@ -0,0 +1,18 @@ +
+
+ + +
+
+
diff --git a/horizon/static/framework/widgets/form/fields/checkboxes.html b/horizon/static/framework/widgets/form/fields/checkboxes.html new file mode 100644 index 0000000000..f82a063547 --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/checkboxes.html @@ -0,0 +1,22 @@ +
+ + +
+ +
+
+
diff --git a/horizon/static/framework/widgets/form/fields/default.html b/horizon/static/framework/widgets/form/fields/default.html new file mode 100644 index 0000000000..78c9f52e31 --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/default.html @@ -0,0 +1,55 @@ +
+ + + + +
+ + + + +
+ + + + {$ hasSuccess() ? '(success)' : '(error)' $} + +
+
diff --git a/horizon/static/framework/widgets/form/fields/fieldset.html b/horizon/static/framework/widgets/form/fields/fieldset.html new file mode 100644 index 0000000000..5c46d8118f --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/fieldset.html @@ -0,0 +1,4 @@ +
+ {$:: form.title $} +
+
diff --git a/horizon/static/framework/widgets/form/fields/help.html b/horizon/static/framework/widgets/form/fields/help.html new file mode 100644 index 0000000000..2f2e0fd35f --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/help.html @@ -0,0 +1 @@ +

diff --git a/horizon/static/framework/widgets/form/fields/radio-buttons.html b/horizon/static/framework/widgets/form/fields/radio-buttons.html new file mode 100644 index 0000000000..8695452f97 --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/radio-buttons.html @@ -0,0 +1,23 @@ +
+
+ +
+
+ +
+
+
diff --git a/horizon/static/framework/widgets/form/fields/radios-inline.html b/horizon/static/framework/widgets/form/fields/radios-inline.html new file mode 100644 index 0000000000..5faee56427 --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/radios-inline.html @@ -0,0 +1,19 @@ +
+ +
+ +
+
+
diff --git a/horizon/static/framework/widgets/form/fields/radios.html b/horizon/static/framework/widgets/form/fields/radios.html new file mode 100644 index 0000000000..ac323e65b1 --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/radios.html @@ -0,0 +1,19 @@ +
+ +
+ +
+
+
diff --git a/horizon/static/framework/widgets/form/fields/section.html b/horizon/static/framework/widgets/form/fields/section.html new file mode 100644 index 0000000000..b75845e4d8 --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/section.html @@ -0,0 +1 @@ +
diff --git a/horizon/static/framework/widgets/form/fields/select.html b/horizon/static/framework/widgets/form/fields/select.html new file mode 100644 index 0000000000..9c5e865f2a --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/select.html @@ -0,0 +1,16 @@ +
+ + +
+
diff --git a/horizon/static/framework/widgets/form/fields/submit.html b/horizon/static/framework/widgets/form/fields/submit.html new file mode 100644 index 0000000000..f9a60594b2 --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/submit.html @@ -0,0 +1,15 @@ +
+ + +
diff --git a/horizon/static/framework/widgets/form/fields/tabarray.html b/horizon/static/framework/widgets/form/fields/tabarray.html new file mode 100644 index 0000000000..08f20f1bd6 --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/tabarray.html @@ -0,0 +1,70 @@ + diff --git a/horizon/static/framework/widgets/form/fields/tabs.html b/horizon/static/framework/widgets/form/fields/tabs.html new file mode 100644 index 0000000000..9082ec4659 --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/tabs.html @@ -0,0 +1,41 @@ +
+
+ + + +
+ +
+ + + + + +
diff --git a/horizon/static/framework/widgets/form/fields/textarea.html b/horizon/static/framework/widgets/form/fields/textarea.html new file mode 100644 index 0000000000..5c4742bd20 --- /dev/null +++ b/horizon/static/framework/widgets/form/fields/textarea.html @@ -0,0 +1,39 @@ +
+ + + + +
+ + + +
+ + +
diff --git a/horizon/static/framework/widgets/form/form.module.js b/horizon/static/framework/widgets/form/form.module.js new file mode 100644 index 0000000000..c5cd50212b --- /dev/null +++ b/horizon/static/framework/widgets/form/form.module.js @@ -0,0 +1,25 @@ +/* + * Copyright 2016 Cisco Systems, Inc. + * + * 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.widgets.form + */ + angular + .module('horizon.framework.widgets.form', []); +})(); diff --git a/horizon/static/framework/widgets/form/modal-form.controller.js b/horizon/static/framework/widgets/form/modal-form.controller.js new file mode 100644 index 0000000000..14c8aff38e --- /dev/null +++ b/horizon/static/framework/widgets/form/modal-form.controller.js @@ -0,0 +1,64 @@ +/** + * (c) Copyright 2016 Hewlett Packard Enterprise Development Company LP + * + * 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 controller + * @name horizon.framework.widgets.form.ModalFormController + * + * @param(object) modal instance from angular-bootstrap + * @param(object) context object provided by the user + * + * @description + * Controller for a schema-form based modal. + * If user presses cancel button or closes dialog, modal gets dismissed. + * If user presses submit button, form input is validated then the modal + * is closed and the context object is passed back so that the caller can + * use any of the inputs. + */ + angular + .module('horizon.framework.widgets.form') + .controller('horizon.framework.widgets.form.ModalFormController', controller); + + controller.$inject = [ + '$modalInstance', + 'context' + ]; + + function controller($modalInstance, context) { + var ctrl = this; + ctrl.formTitle = context.title; + ctrl.form = context.form; + ctrl.schema = context.schema; + ctrl.model = context.model; + ctrl.submit = submit; + ctrl.submitText = context.submitText; + ctrl.submitIcon = context.submitIcon; + ctrl.cancel = cancel; + + function submit() { + return $modalInstance.close(context); + } + + function cancel() { + return $modalInstance.dismiss(context); + } + + return ctrl; + } +})(); diff --git a/horizon/static/framework/widgets/form/modal-form.controller.spec.js b/horizon/static/framework/widgets/form/modal-form.controller.spec.js new file mode 100644 index 0000000000..09884e0fce --- /dev/null +++ b/horizon/static/framework/widgets/form/modal-form.controller.spec.js @@ -0,0 +1,71 @@ +/* + * (c) Copyright 2016 Hewlett Packard Enterprise Development LP + * + * 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('modal-form controller', function () { + var ctrl, modalInstance, context; + + beforeEach(module('horizon.framework.widgets.form')); + + beforeEach(inject(function ($controller) { + modalInstance = { + close: angular.noop, + dismiss: angular.noop + }; + context = { + title: "title", + form: "form", + schema: "schema", + model: "model" + }; + ctrl = $controller( + 'horizon.framework.widgets.form.ModalFormController', + { + $modalInstance: modalInstance, + context: context + }); + })); + + it('sets formTitle on scope', function() { + expect(ctrl.formTitle).toEqual('title'); + }); + + it('sets form on scope', function() { + expect(ctrl.form).toEqual('form'); + }); + + it('sets schema on scope', function() { + expect(ctrl.schema).toEqual('schema'); + }); + + it('sets model on scope', function() { + expect(ctrl.model).toEqual('model'); + }); + + it('calls modalInstance close on submit', function() { + spyOn(modalInstance, 'close'); + ctrl.submit(); + expect(modalInstance.close.calls.count()).toBe(1); + }); + + it('calls modalInstance dismiss on cancel', function() { + spyOn(modalInstance, 'dismiss'); + ctrl.cancel(); + expect(modalInstance.dismiss.calls.count()).toBe(1); + }); + }); +}()); diff --git a/horizon/static/framework/widgets/form/modal-form.html b/horizon/static/framework/widgets/form/modal-form.html new file mode 100644 index 0000000000..f07594ee55 --- /dev/null +++ b/horizon/static/framework/widgets/form/modal-form.html @@ -0,0 +1,51 @@ + + + + + diff --git a/horizon/static/framework/widgets/form/modal-form.service.js b/horizon/static/framework/widgets/form/modal-form.service.js new file mode 100644 index 0000000000..71e88f3864 --- /dev/null +++ b/horizon/static/framework/widgets/form/modal-form.service.js @@ -0,0 +1,72 @@ +/** + * + * (c) Copyright 2016 Hewlett Packard Enterprise Development Company LP + * + * 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.form') + .factory('horizon.framework.widgets.form.ModalFormService', service); + + service.$inject = [ + '$modal', + 'horizon.framework.widgets.basePath' + ]; + + /** + * @ngDoc factory + * @name horizon.framework.widgets.form.ModalFormService + * + * @Description + * Loads a Schema-Form (see modal-form.html) in a modal and returns the modal result promise. + */ + function service( + $modal, + widgetsBasePath + ) { + + var service = { + open: open + }; + + return service; + + ///////////////// + + function open(config) { + var modalConfig = { + backdrop: 'static', + resolve: { + context: function() { + return { + title: config.title, + submitText: config.submitText || gettext("Submit"), + submitIcon: config.submitIcon || "check", + schema: config.schema, + form: config.form, + model: config.model + }; + } + }, + controller: 'horizon.framework.widgets.form.ModalFormController as ctrl', + templateUrl: widgetsBasePath + 'form/modal-form.html' + }; + + return $modal.open(modalConfig).result; + } + } +})(); diff --git a/horizon/static/framework/widgets/form/modal-form.service.spec.js b/horizon/static/framework/widgets/form/modal-form.service.spec.js new file mode 100644 index 0000000000..f001c411b8 --- /dev/null +++ b/horizon/static/framework/widgets/form/modal-form.service.spec.js @@ -0,0 +1,74 @@ +/* + * (c) Copyright 2016 Hewlett Packard Enterprise Development LP + * + * 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('modal-form service', function () { + var service, $modal; + + beforeEach(module('horizon.framework.widgets')); + beforeEach(module('horizon.framework.widgets.form')); + + beforeEach(inject(function ($injector, _$modal_) { + $modal = _$modal_; + service = $injector.get( + 'horizon.framework.widgets.form.ModalFormService' + ); + })); + + it('sets open parameters to modal resolve.context', function() { + spyOn($modal, 'open').and.callFake(function(config) { + return { + result: config + }; + }); + var modalConfig = { + "title": "title", + "schema": "schema", + "form": "form", + "model": "model", + "submitIcon": "icon", + "submitText": "save" + }; + var modalService = service.open(modalConfig); + var context = modalService.resolve.context(); + expect(context.title).toEqual('title'); + expect(context.schema).toEqual('schema'); + expect(context.form).toEqual('form'); + expect(context.model).toEqual('model'); + expect(context.submitIcon).toEqual('icon'); + expect(context.submitText).toEqual('save'); + }); + + it('sets default values for optional parameters', function() { + spyOn($modal, 'open').and.callFake(function(config) { + return { + result: config + }; + }); + var modalConfig = { + "title": "title", + "schema": "schema", + "form": "form", + "model": "model" + }; + var modalService = service.open(modalConfig); + var context = modalService.resolve.context(); + expect(context.submitIcon).toEqual('check'); + expect(context.submitText).toEqual('Submit'); + }); + }); +}()); diff --git a/horizon/static/framework/widgets/help-panel/help-panel.directive.js b/horizon/static/framework/widgets/help-panel/help-panel.directive.js index 852d96b1c0..5d114dd6bb 100644 --- a/horizon/static/framework/widgets/help-panel/help-panel.directive.js +++ b/horizon/static/framework/widgets/help-panel/help-panel.directive.js @@ -19,7 +19,10 @@ .module('horizon.framework.widgets.help-panel') .directive('helpPanel', helpPanel); - helpPanel.$inject = [ 'horizon.framework.widgets.basePath' ]; + helpPanel.$inject = [ + 'horizon.framework.widgets.basePath', + 'horizon.framework.util.uuid.service' + ]; /** * @ngdoc directive @@ -38,9 +41,14 @@ * * ``` */ - function helpPanel(path) { + function helpPanel(path, uuid) { + var link = function(scope) { + scope.uuid = uuid.generate(); + }; + var directive = { templateUrl: path + 'help-panel/help-panel.html', + link: link, transclude: true }; diff --git a/horizon/static/framework/widgets/help-panel/help-panel.directive.spec.js b/horizon/static/framework/widgets/help-panel/help-panel.directive.spec.js index 47ae15e5a0..f890a85209 100644 --- a/horizon/static/framework/widgets/help-panel/help-panel.directive.spec.js +++ b/horizon/static/framework/widgets/help-panel/help-panel.directive.spec.js @@ -35,13 +35,13 @@ }); it('should be closed by default', function () { - expect(element[0].querySelector('#help-panel').className).toBe('collapse width'); + expect(element[0].querySelector('.help-panel').className).toContain('collapse width'); }); it('should add "in" to class name if $scope.openHelp is true', function () { $scope.openHelp = true; $scope.$apply(); - expect(element[0].querySelector('#help-panel').className).toBe('collapse width in'); + expect(element[0].querySelector('.help-panel').className).toContain('collapse width in'); }); it('should remove "in" from class name if $scope.openHelp is false', function () { @@ -49,7 +49,7 @@ $scope.$apply(); $scope.openHelp = false; $scope.$apply(); - expect(element[0].querySelector('#help-panel').className).toBe('collapse width'); + expect(element[0].querySelector('.help-panel').className).toContain('collapse width'); }); }); diff --git a/horizon/static/framework/widgets/help-panel/help-panel.html b/horizon/static/framework/widgets/help-panel/help-panel.html index 4c411182d8..e870a51943 100644 --- a/horizon/static/framework/widgets/help-panel/help-panel.html +++ b/horizon/static/framework/widgets/help-panel/help-panel.html @@ -1,9 +1,10 @@ - -
-
+
+
+
diff --git a/horizon/static/framework/widgets/widgets.module.js b/horizon/static/framework/widgets/widgets.module.js index ca8ff9b12e..73d7da4cdb 100644 --- a/horizon/static/framework/widgets/widgets.module.js +++ b/horizon/static/framework/widgets/widgets.module.js @@ -21,6 +21,7 @@ .module('horizon.framework.widgets', [ 'horizon.framework.widgets.headers', 'horizon.framework.widgets.details', + 'horizon.framework.widgets.form', 'horizon.framework.widgets.help-panel', 'horizon.framework.widgets.wizard', 'horizon.framework.widgets.property', diff --git a/horizon/static/framework/widgets/wizard/wizard.spec.js b/horizon/static/framework/widgets/wizard/wizard.spec.js index a38e1e2954..97df17ff90 100644 --- a/horizon/static/framework/widgets/wizard/wizard.spec.js +++ b/horizon/static/framework/widgets/wizard/wizard.spec.js @@ -61,7 +61,7 @@ $scope.workflow = {}; $scope.workflow.title = "doesn't matter"; $scope.$apply(); - expect(element[0].querySelectorAll('#help-panel').length).toBe(1); + expect(element[0].querySelectorAll('.help-panel').length).toBe(1); }); it('should toggle help icon button', function () { diff --git a/openstack_dashboard/karma.conf.js b/openstack_dashboard/karma.conf.js index 7d1cb7af9f..0b91931f0a 100644 --- a/openstack_dashboard/karma.conf.js +++ b/openstack_dashboard/karma.conf.js @@ -100,6 +100,9 @@ module.exports = function (config) { xstaticPath + 'angular_lrdragndrop/data/lrdragndrop.js', xstaticPath + 'spin/data/spin.js', xstaticPath + 'spin/data/spin.jquery.js', + xstaticPath + 'tv4/data/tv4.js', + xstaticPath + 'objectpath/data/ObjectPath.js', + xstaticPath + 'angular_schema_form/data/schema-form.js', // TODO: These should be mocked. However, that could be complex // and there's less harm in exposing these directly. These are diff --git a/openstack_dashboard/static/app/app.module.js b/openstack_dashboard/static/app/app.module.js index c4c2a8ca70..c376688be1 100644 --- a/openstack_dashboard/static/app/app.module.js +++ b/openstack_dashboard/static/app/app.module.js @@ -26,6 +26,7 @@ 'lrDragNDrop', 'ngCookies', 'ngSanitize', + 'schemaForm', 'smart-table', 'ngFileUpload', 'ui.bootstrap' diff --git a/openstack_dashboard/static/dashboard/scss/components/_forms.scss b/openstack_dashboard/static/dashboard/scss/components/_forms.scss index 5a3c4dd0fd..c7adc851a8 100644 --- a/openstack_dashboard/static/dashboard/scss/components/_forms.scss +++ b/openstack_dashboard/static/dashboard/scss/components/_forms.scss @@ -8,7 +8,6 @@ /* Fix for input icon alignment */ input + .fa { line-height: $input-height-base; - top: 0; } .password-icon { diff --git a/openstack_dashboard/static/dashboard/scss/components/_help_panel.scss b/openstack_dashboard/static/dashboard/scss/components/_help_panel.scss index d7f0d1bfb5..af80a5ba3c 100644 --- a/openstack_dashboard/static/dashboard/scss/components/_help_panel.scss +++ b/openstack_dashboard/static/dashboard/scss/components/_help_panel.scss @@ -2,14 +2,14 @@ $help-panel-width: 400px; .help-toggle, .wizard-help, -#help-panel { +.help-panel { position: absolute; top: $padding-xs-horizontal; right: 0; z-index: 2; // TODO(robcresswell) untangle the need for this sorcery } -#help-panel > div { +.help-panel > div { width: $help-panel-width; } diff --git a/openstack_dashboard/static_settings.py b/openstack_dashboard/static_settings.py index f68d882260..d6ac1c1c6c 100644 --- a/openstack_dashboard/static_settings.py +++ b/openstack_dashboard/static_settings.py @@ -25,6 +25,7 @@ import xstatic.pkg.angular_bootstrap import xstatic.pkg.angular_fileupload import xstatic.pkg.angular_gettext import xstatic.pkg.angular_lrdragndrop +import xstatic.pkg.angular_schema_form import xstatic.pkg.angular_smart_table import xstatic.pkg.bootstrap_datepicker import xstatic.pkg.bootstrap_scss @@ -40,10 +41,12 @@ import xstatic.pkg.jquery_tablesorter import xstatic.pkg.jquery_ui import xstatic.pkg.jsencrypt import xstatic.pkg.mdi +import xstatic.pkg.objectpath import xstatic.pkg.rickshaw import xstatic.pkg.roboto_fontface import xstatic.pkg.spin import xstatic.pkg.termjs +import xstatic.pkg.tv4 from horizon.utils import file_discovery @@ -67,6 +70,9 @@ def get_staticfiles_dirs(webroot='/'): ('horizon/lib/angular', xstatic.main.XStatic(xstatic.pkg.angular_lrdragndrop, root_url=webroot).base_dir), + ('horizon/lib/angular', + xstatic.main.XStatic(xstatic.pkg.angular_schema_form, + root_url=webroot).base_dir), ('horizon/lib/angular', xstatic.main.XStatic(xstatic.pkg.angular_smart_table, root_url=webroot).base_dir), @@ -109,6 +115,9 @@ def get_staticfiles_dirs(webroot='/'): ('horizon/lib/mdi', xstatic.main.XStatic(xstatic.pkg.mdi, root_url=webroot).base_dir), + ('horizon/lib/objectpath', + xstatic.main.XStatic(xstatic.pkg.objectpath, + root_url=webroot).base_dir), ('horizon/lib', xstatic.main.XStatic(xstatic.pkg.rickshaw, root_url=webroot).base_dir), @@ -121,6 +130,9 @@ def get_staticfiles_dirs(webroot='/'): ('horizon/lib', xstatic.main.XStatic(xstatic.pkg.termjs, root_url=webroot).base_dir), + ('horizon/lib/tv4', + xstatic.main.XStatic(xstatic.pkg.tv4, + root_url=webroot).base_dir), ] if xstatic.main.XStatic(xstatic.pkg.jquery_ui, diff --git a/openstack_dashboard/templates/horizon/_scripts.html b/openstack_dashboard/templates/horizon/_scripts.html index 4093123371..f211f80dbc 100644 --- a/openstack_dashboard/templates/horizon/_scripts.html +++ b/openstack_dashboard/templates/horizon/_scripts.html @@ -66,6 +66,9 @@ + + + {% for file in HORIZON_CONFIG.js_files %} diff --git a/openstack_dashboard/themes/material/static/horizon/components/_help_panel.scss b/openstack_dashboard/themes/material/static/horizon/components/_help_panel.scss index 2bcb60c493..3aa11c3ec9 100644 --- a/openstack_dashboard/themes/material/static/horizon/components/_help_panel.scss +++ b/openstack_dashboard/themes/material/static/horizon/components/_help_panel.scss @@ -12,9 +12,9 @@ } } -#help-panel { +.help-panel { // Material is all about depth, lets add some & > .well { @extend .panel; } -} \ No newline at end of file +} diff --git a/releasenotes/notes/bp-angular-schema-form-bbe1aedf644b53db.yaml b/releasenotes/notes/bp-angular-schema-form-bbe1aedf644b53db.yaml new file mode 100644 index 0000000000..ca1b845586 --- /dev/null +++ b/releasenotes/notes/bp-angular-schema-form-bbe1aedf644b53db.yaml @@ -0,0 +1,7 @@ +--- +features: + - > + [`blueprint angular-schema-form `_] + Added the Angular Schema Form library to Horizon. This + allows developers to build angular forms and workflows + from JSON. Read more at ``_ diff --git a/requirements.txt b/requirements.txt index 42351963a3..c438f0432a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,6 +42,7 @@ XStatic-Angular-Bootstrap>=0.11.0.2 # MIT License XStatic-Angular-FileUpload>=12.0.4.0 # MIT License XStatic-Angular-Gettext>=2.1.0.2 # MIT License XStatic-Angular-lrdragndrop>=1.0.2.2 # MIT License +XStatic-Angular-Schema-Form>=0.8.13.0 # MIT XStatic-Bootstrap-Datepicker>=1.3.1.0 # Apache 2.0 License XStatic-Bootstrap-SCSS>=3 # Apache 2.0 License XStatic-bootswatch>=3.3.5.3 # MIT License @@ -56,8 +57,10 @@ XStatic-JQuery.TableSorter>=2.14.5.1 # MIT License XStatic-jquery-ui>=1.10.1 # MIT License XStatic-JSEncrypt>=2.0.0.2 # MIT License XStatic-mdi>=1.4.57.0 # SIL OPEN FONT LICENSE Version 1.1 +XStatic-objectpath>=1.2.1.0 # MIT XStatic-Rickshaw>=1.5.0 # BSD License (prior) XStatic-roboto-fontface>=0.4.3.2 # Apache 2.0 License XStatic-smart-table>=1.4.5.3 # MIT License XStatic-Spin>=1.2.5.2 # MIT License XStatic-term.js>=0.0.4 # MIT License +XStatic-tv4>=1.2.7.0 # MIT