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 <tyr@hpe.com>
Implements: blueprint angular-schema-form
Change-Id: Ib22b2d0db2c4d4775fdef62a180cc994e8ae6280
This commit is contained in:
Rob Cresswell 2016-06-22 11:52:53 +01:00
parent 3864fd11a1
commit 0e957dd41a
43 changed files with 1264 additions and 16 deletions

View File

@ -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',

View File

@ -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'

View File

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

View File

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

View File

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

View File

@ -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 = "<div><ul><li></li></ul><div class='tab-content'></div></div>";
var htmlTwo = "<div class='test'></div>";
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();
});
});
})();

View File

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

View File

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

View File

@ -0,0 +1,39 @@
<div class="hz-array {$::form.htmlClass$}"
sf-field-model="sf-new-array"
sf-new-array>
<label class="control-label" ng-show="showTitle()">{$:: form.title $}</label>
<ol class="list-group" sf-field-model ui-sortable="form.sortOptions">
<li class="list-group-item {$::form.fieldHtmlClass$}"
schema-form-array-items
sf-field-model="ng-repeat"
ng-repeat="item in $$value$$ track by $index">
<button ng-hide="form.readonly || form.remove === null"
ng-click="deleteFromArray($index)"
ng-disabled="form.schema.minItems >= modelArray.length"
type="button" class="close pull-right">
<span aria-hidden="true">&times;</span>
<span class="sr-only">
<translate>Close</translate>
</span>
</button>
</li>
</ol>
<div class="clearfix" ng-model="modelArray" schema-validate="form">
<div class="help-block"
ng-show="(hasError() && errorMessage(schemaError())) || form.description"
ng-bind-html="(hasError() && errorMessage(schemaError())) || form.description">
</div>
<button ng-hide="form.readonly || form.add === null"
ng-click="appendToArray()"
ng-disabled="form.schema.maxItems <= modelArray.length"
type="button"
class="btn {$::form.style.add || 'btn-default' $} pull-right">
<span class="fa fa-plus"></span>
{$::form.add$}
<span ng-if="!form.add">
<translate>Add</translate>
</span>
</button>
</div>
</div>

View File

@ -0,0 +1,18 @@
<div class="form-group hz-checkbox">
<div class="themable-checkbox {$::form.htmlClass$}"
ng-class="{'has-error': form.disableErrorState !== true && hasError(), 'has-success': form.disableSuccessState !== true && hasSuccess()}">
<input type="checkbox"
sf-changed="form"
ng-disabled="form.readonly"
sf-field-model
schema-validate="form"
class="themable-checkbox {$::form.fieldHtmlClass$}"
name="{$::form.key.slice(-1)[0]$}"
id="{$::form.key.slice(-1)[0]$}">
<label for="{$::form.key.slice(-1)[0]$}"
class="{$::form.labelHtmlClass$}">
<span>{$::form.title$}</span>
</label>
<div class="help-block" sf-message="form.description"></div>
</div>
</div>

View File

@ -0,0 +1,22 @@
<div sf-field-model="sf-new-array"
sf-new-array
class="hz-checkboxes form-group {$::form.htmlClass$}"
ng-class="{'has-error': form.disableErrorState !== true && hasError(), 'has-success': form.disableSuccessState !== true && hasSuccess()}">
<label class="control-label {$::form.labelHtmlClass$}"
sf-field-model
schema-validate="form"
ng-show="showTitle()">{$::form.title$}</label>
<div class="checkbox" ng-repeat="val in titleMapValues track by $index" >
<label>
<input type="checkbox"
ng-disabled="form.readonly"
sf-changed="form"
class="{$::form.fieldHtmlClass$}"
ng-model="titleMapValues[$index]"
name="{$::form.key.slice(-1)[0]$}">
<span ng-bind-html="form.titleMap[$index].name"></span>
</label>
</div>
<div class="help-block" sf-message="form.description"></div>
</div>

View File

@ -0,0 +1,55 @@
<div class="form-group hz-input {$::form.htmlClass$}"
ng-class="{'has-error': form.disableErrorState !== true && hasError(), 'has-success': form.disableSuccessState !== true && hasSuccess(), 'has-feedback': form.feedback !== false }">
<label class="control-label {$::form.labelHtmlClass$}" ng-class="{'sr-only': !showTitle()}" for="{$::form.key.slice(-1)[0]$}">
<span>{$::form.title$}</span>
<span ng-if="form.required" class="hz-icon-required fa fa-asterisk"></span>
</label>
<input ng-if="!form.fieldAddonLeft && !form.fieldAddonRight"
ng-show="form.key"
type="{$::form.type$}"
step="any"
sf-changed="form"
placeholder="{$::form.placeholder$}"
class="form-control {$::form.fieldHtmlClass$}"
id="{$::form.key.slice(-1)[0]$}"
sf-field-model
ng-disabled="form.readonly"
schema-validate="form"
name="{$::form.key.slice(-1)[0]$}"
aria-describedby="{$::form.key.slice(-1)[0] + 'Status'$}">
<div ng-if="form.fieldAddonLeft || form.fieldAddonRight"
ng-class="{'input-group': (form.fieldAddonLeft || form.fieldAddonRight)}">
<span ng-if="form.fieldAddonLeft"
class="input-group-addon"
ng-bind-html="form.fieldAddonLeft"></span>
<input ng-show="form.key"
type="{$::form.type$}"
step="any"
sf-changed="form"
placeholder="{$::form.placeholder$}"
class="form-control {$::form.fieldHtmlClass$}"
id="{$::form.key.slice(-1)[0]$}"
sf-field-model
ng-disabled="form.readonly"
schema-validate="form"
name="{$::form.key.slice(-1)[0]$}"
aria-describedby="{$::form.key.slice(-1)[0] + 'Status'$}">
<span ng-if="form.fieldAddonRight"
class="input-group-addon"
ng-bind-html="form.fieldAddonRight"></span>
</div>
<span ng-if="form.feedback !== false"
class="form-control-feedback"
ng-class="evalInScope(form.feedback) || {'fa': true, 'fa-check': hasSuccess(), 'fa-times': hasError() }"
aria-hidden="true"></span>
<span ng-if="hasError() || hasSuccess()"
id="{$::form.key.slice(-1)[0] + 'Status'$}"
class="sr-only">{$ hasSuccess() ? '(success)' : '(error)' $}</span>
<div class="help-block" sf-message="form.description"></div>
</div>

View File

@ -0,0 +1,4 @@
<fieldset ng-disabled="form.readonly" class="hz-fieldset {$::form.htmlClass$}">
<legend ng-class="{'sr-only': !showTitle() }">{$:: form.title $}</legend>
<div class="help-block" ng-show="form.description" ng-bind-html="form.description"></div>
</fieldset>

View File

@ -0,0 +1 @@
<p class="hz-help {$::form.htmlClass$}" ng-bind-html="form.helpvalue"></p>

View File

@ -0,0 +1,23 @@
<div class="form-group hz-radio-buttons {$::form.htmlClass$}"
ng-class="{'has-error': form.disableErrorState !== true && hasError(), 'has-success': form.disableSuccessState !== true && hasSuccess()}">
<div>
<label class="{$::form.labelHtmlClass$}" ng-show="showTitle()">{$::form.title$}</label>
</div>
<div class="btn-group">
<label sf-field-model="replaceAll" class="btn {$:: (item.value === $$value$$) ? form.style.selected || 'btn-default' : form.style.unselected || 'btn-default'; $}"
ng-class="{ active: item.value === $$value$$ }"
ng-repeat="item in form.titleMap">
<input type="radio"
class="{$::form.fieldHtmlClass$}"
sf-changed="form"
style="display: none;"
ng-disabled="form.readonly"
sf-field-model
schema-validate="form"
ng-value="item.value"
name="{$::form.key.join('.')$}">
<span>{$::item.name$}</span>
</label>
</div>
<div class="help-block" sf-message="form.description"></div>
</div>

View File

@ -0,0 +1,19 @@
<div class="hz-radios-inline form-group {$::form.htmlClass$}"
ng-class="{'has-error': form.disableErrorState !== true && hasError(), 'has-success': form.disableSuccessState !== true && hasSuccess()}">
<label class="control-label {$::form.labelHtmlClass$}"
ng-show="showTitle()" sf-field-model
schema-validate="form" >{$::form.title$}</label>
<div>
<label class="radio-inline" ng-repeat="item in form.titleMap">
<input type="radio"
class="{$::form.fieldHtmlClass$}"
sf-changed="form"
ng-disabled="form.readonly"
sf-field-model
ng-value="item.value"
name="{$::form.key.join('.')$}">
<span ng-bind-html="item.name"></span>
</label>
</div>
<div class="help-block" sf-message="form.description"></div>
</div>

View File

@ -0,0 +1,19 @@
<div class="hz-radios form-group {$::form.htmlClass$}"
ng-class="{'has-error': form.disableErrorState !== true && hasError(), 'has-success': form.disableSuccessState !== true && hasSuccess()}">
<label class="control-label {$::form.labelHtmlClass$}"
sf-field-model schema-validate="form"
ng-show="showTitle()">{$::form.title$}</label>
<div class="radio" ng-repeat="item in form.titleMap">
<label>
<input type="radio"
class="{$::form.fieldHtmlClass$}"
sf-changed="form"
ng-disabled="form.readonly"
sf-field-model
ng-value="item.value"
name="{$::form.key.join('.')$}">
<span ng-bind-html="item.name"></span>
</label>
</div>
<div class="help-block" sf-message="form.description"></div>
</div>

View File

@ -0,0 +1 @@
<div class="hz-section {$::form.htmlClass$}"></div>

View File

@ -0,0 +1,16 @@
<div class="hz-select form-group {$::form.htmlClass$}"
ng-class="{'has-error': form.disableErrorState !== true && hasError(), 'has-feedback': form.feedback !== false}">
<label class="control-label {$::form.labelHtmlClass$}" ng-show="showTitle()">
<span>{$::form.title$}</span>
<span ng-if="form.required" class="hz-icon-required fa fa-asterisk"></span>
</label>
<select sf-field-model
ng-disabled="form.readonly"
sf-changed="form"
class="form-control {$::form.fieldHtmlClass$}"
schema-validate="form"
ng-options="item.value as item.name group by item.group for item in form.titleMap"
name="{$::form.key.slice(-1)[0]$}">
</select>
<div class="help-block" sf-message="form.description"></div>
</div>

View File

@ -0,0 +1,15 @@
<div class="hz-submit form-group {$::form.htmlClass$}">
<input type="submit"
class="btn {$:: form.style || 'btn-primary' $} {$::form.fieldHtmlClass$}"
value="{$::form.title$}"
ng-disabled="form.readonly"
ng-if="form.type === 'submit'">
<button class="btn {$:: form.style || 'btn-default' $}"
type="button"
ng-click="buttonClick($event,form)"
ng-disabled="form.readonly"
ng-if="form.type !== 'submit'">
<span ng-if="form.icon" class="{$::form.icon$}"></span>
<span class="hz-submit-title">{$::form.title$}</span>
</button>
</div>

View File

@ -0,0 +1,70 @@
<div ng-init="selected = { tab: 0 }"
ng-model="modelArray" schema-validate="form"
sf-field-model="sf-new-array"
sf-new-array
class="clearfix hz-tabarray schema-form-tabarray-{$form.tabType || 'left'$} {$form.htmlClass$}">
<div ng-if="!form.tabType || form.tabType !== 'right'"
ng-class="{'col-xs-3': !form.tabType || form.tabType === 'left'}">
<ul class="nav nav-tabs"
ng-class="{ 'tabs-left': !form.tabType || form.tabType === 'left'}">
<li sf-field-model="ng-repeat"
ng-repeat="item in $$value$$ track by $index"
ng-click="$event.preventDefault() || (selected.tab = $index)"
ng-class="{active: selected.tab === $index}">
<a href="#">{$interp(form.title,{'$index':$index, value: item}) || $index$}</a>
</li>
<li ng-hide="form.readonly"
ng-disabled="form.schema.maxItems <= modelArray.length"
ng-click="$event.preventDefault() || (selected.tab = appendToArray().length - 1)">
<a href="#">
<span class="fa fa-plus"></span>
<span class="hz-tabarray-add">{$ form.add || 'Add'$}</span>
</a>
</li>
</ul>
</div>
<div ng-class="{'col-xs-9': !form.tabType || form.tabType === 'left' || form.tabType === 'right'}">
<div class="tab-content {$form.fieldHtmlClass$}">
<div class="tab-pane clearfix tab{$selected.tab$} index{$$index$}"
sf-field-model="ng-repeat"
ng-repeat="item in $$value$$ track by $index"
ng-show="selected.tab === $index"
ng-class="{active: selected.tab === $index}">
<div schema-form-array-items></div>
<button ng-hide="form.readonly"
ng-click="selected.tab = deleteFromArray($index).length - 1"
ng-disabled="form.schema.minItems >= modelArray.length"
type="button"
class="btn {$ form.style.remove || 'btn-default' $} pull-right">
<span class="fa fa-trash"></span>
<span class="hz-tabarray-remove">{$::form.remove || 'Remove'$}</span>
</button>
</div>
<div class="help-block"
ng-show="(hasError() && errorMessage(schemaError())) || form.description"
ng-bind-html="(hasError() && errorMessage(schemaError())) || form.description">
</div>
</div>
</div>
<div ng-if="form.tabType === 'right'" class="col-xs-3">
<ul class="nav nav-tabs tabs-right">
<li sf-field-model="ng-repeat"
ng-repeat="item in $$value$$ track by $index"
ng-click="$event.preventDefault() || (selected.tab = $index)"
ng-class="{active: selected.tab === $index}">
<a href="#">{$interp(form.title,{'$index':$index, value: item}) || $index$}</a>
</li>
<li ng-hide="form.readonly"
ng-disabled="form.schema.maxItems <= modelArray.length"
ng-click="$event.preventDefault() || (selected.tab = appendToArray().length - 1)">
<a href="#">
<span class="fa fa-plus"></span>
<span class="hz-tabarray-add">{$::form.add || 'Add'$}</span>
</a>
</li>
</ul>
</div>
</div>

View File

@ -0,0 +1,41 @@
<div ng-init="model.tabs = { selected: 0 }" class="row hz-tabs {$::form.htmlClass$}">
<div class="col-xs-12 col-sm-3">
<button type="button"
data-toggle="collapse"
data-target="#wizard-side-nav"
aria-expanded="false"
class="navbar-toggle btn btn-default collapsed wizard-nav-toggle">
<span translate class="sr-only">Toggle navigation</span>
<span class="fa fa-bars"></span>
<span translate>Toggle navigation</span>
</button>
<div class="collapse navbar-collapse wizard-nav" id="wizard-side-nav">
<ul class="nav nav-pills nav-stacked">
<li role="presentation"
class="nav-item"
ng-repeat="tab in form.tabs"
ng-init="model.tabs.length = form.tabs.length"
ng-disabled="form.readonly"
ng-click="$event.preventDefault() || (model.tabs.selected = $index)"
ng-class="{active: model.tabs.selected === $index}">
<a href="#">
<span>{$::tab.title$}</span>
<span class="hz-icon-required fa fa-asterisk"
ng-show="tab.required"
aria-hidden="true"></span>
</a>
</li>
</ul>
</div>
</div>
<div class="tab-content col-xs-12 col-sm-9 {$::form.fieldHtmlClass$}"></div>
<help-panel>
<ng-include src="tab.help"
ng-repeat="tab in form.tabs track by $index"
ng-show="model.tabs.selected === $index">
</ng-include>
</help-panel>
</div>

View File

@ -0,0 +1,39 @@
<div class="form-group hz-textarea has-feedback {$::form.htmlClass$}"
ng-class="{'has-error': form.disableErrorState !== true && hasError(), 'has-success': form.disableSuccessState !== true && hasSuccess()}">
<label class="control-label {$::form.labelHtmlClass$}"
ng-class="{'sr-only': !showTitle()}"
for="{$::form.key.slice(-1)[0]$}">
<span>{$::form.title$}</span>
<span ng-if="form.required" class="hz-icon-required fa fa-asterisk"></span>
</label>
<textarea ng-if="!form.fieldAddonLeft && !form.fieldAddonRight"
class="form-control {$::form.fieldHtmlClass$}"
id="{$::form.key.slice(-1)[0]$}"
sf-changed="form"
placeholder="{$::form.placeholder$}"
ng-disabled="form.readonly"
sf-field-model
schema-validate="form"
name="{$::form.key.slice(-1)[0]$}"></textarea>
<div ng-if="form.fieldAddonLeft || form.fieldAddonRight"
ng-class="{'input-group': (form.fieldAddonLeft || form.fieldAddonRight)}">
<span ng-if="form.fieldAddonLeft"
class="input-group-addon"
ng-bind-html="form.fieldAddonLeft"></span>
<textarea class="form-control {$::form.fieldHtmlClass$}"
id="{$::form.key.slice(-1)[0]$}"
sf-changed="form"
placeholder="{$::form.placeholder$}"
ng-disabled="form.readonly"
sf-field-model
schema-validate="form"
name="{$::form.key.slice(-1)[0]$}"></textarea>
<span ng-if="form.fieldAddonRight"
class="input-group-addon"
ng-bind-html="form.fieldAddonRight"></span>
</div>
<span class="help-block" sf-message="form.description"></span>
</div>

View File

@ -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', []);
})();

View File

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

View File

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

View File

@ -0,0 +1,51 @@
<div class="modal-header">
<a class="close" ng-click="$dismiss()">
<span class="fa fa-times"></span>
</a>
<div class="h4 modal-title">
{$::ctrl.formTitle$}
</div>
</div>
<div class="modal-body">
<form name="schemaForm"
sf-schema="ctrl.schema"
sf-form="ctrl.form"
sf-model="ctrl.model"
sf-options="{ validateOnRender: true,
pristine: { errors: false, success: false } }">
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default pull-left" ng-click="$dismiss()">
<span class="fa fa-close"></span>
<translate>Cancel</translate>
</button>
<button type="button"
class="btn btn-default"
ng-click="ctrl.model.tabs.selected = ctrl.model.tabs.selected - 1"
ng-disabled="ctrl.model.tabs.selected === 0"
ng-if="ctrl.model.tabs.length > 1">
<span class="fa fa-angle-left"></span>
<translate>Back</translate>
</button>
<button type="button"
class="btn btn-default"
ng-click="ctrl.model.tabs.selected = ctrl.model.tabs.selected + 1"
ng-if="ctrl.model.tabs.length > 1"
ng-disabled="ctrl.model.tabs.selected === ctrl.model.tabs.length-1">
<translate>Next</translate>
<span class="fa fa-angle-right"></span>
</button>
<button type="button"
class="btn btn-primary"
ng-disabled="schemaForm.$invalid"
ng-click="ctrl.submit()">
<span class="fa fa-{$::ctrl.submitIcon$}"></span>
{$::ctrl.submitText$}
</button>
</div>

View File

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

View File

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

View File

@ -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 @@
* </div>
* ```
*/
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
};

View File

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

View File

@ -1,9 +1,10 @@
<button class="btn btn-default help-toggle collapsed" data-target="#help-panel"
<button class="btn btn-default help-toggle collapsed" data-target="#{$::uuid$}"
data-toggle="collapse" role="button" aria-expanded="false"
aria-controls="help-panel" ng-hide="hideHelpBtn">
aria-controls="{$::uuid$}" ng-hide="hideHelpBtn" type="button">
<span class="fa"></span>
</button>
<div id="help-panel" class="collapse width" ng-class="{'in': openHelp}">
<div class="well" ng-transclude></div>
<div id="{$::uuid$}" class="help-panel collapse width" ng-class="{'in': openHelp}">
<div class="well" ng-transclude>
</div>
</div>

View File

@ -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',

View File

@ -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 () {

View File

@ -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

View File

@ -26,6 +26,7 @@
'lrDragNDrop',
'ngCookies',
'ngSanitize',
'schemaForm',
'smart-table',
'ngFileUpload',
'ui.bootstrap'

View File

@ -8,7 +8,6 @@
/* Fix for input icon alignment */
input + .fa {
line-height: $input-height-base;
top: 0;
}
.password-icon {

View File

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

View File

@ -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,

View File

@ -66,6 +66,9 @@
<script src='{{ STATIC_URL }}horizon/js/horizon.firewalls.js'></script>
<script src='{{ STATIC_URL }}horizon/js/horizon.volumes.js'></script>
<script src='{{ STATIC_URL }}horizon/lib/jsencrypt/jsencrypt.js'></script>
<script src='{{ STATIC_URL }}horizon/lib/objectpath/ObjectPath.js'></script>
<script src='{{ STATIC_URL }}horizon/lib/tv4/tv4.js'></script>
<script src='{{ STATIC_URL }}horizon/lib/angular/schema-form.js'></script>
{% for file in HORIZON_CONFIG.js_files %}
<script src='{{ STATIC_URL }}{{ file }}'></script>

View File

@ -12,7 +12,7 @@
}
}
#help-panel {
.help-panel {
// Material is all about depth, lets add some
& > .well {
@extend .panel;

View File

@ -0,0 +1,7 @@
---
features:
- >
[`blueprint angular-schema-form <https://blueprints.launchpad.net/horizon/+spec/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 `<http://schemaform.io/>`_

View File

@ -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