Introduce form-field directive

The form-field directive abstracts the data, presentation, and operation of
a form field into a reusable component. It is intended to improve
maintainability and consistency. Form-field is currently being used to
create and edit ports, however, the goal is to use it throughout the
Ironic-UI. Componentizing form elements will also make it easier to
move to other libraries such as SchemaForm should we decide to do that.

Change-Id: I4cd84cd0840878b3dc34afe9def8e7f94be46fd0
This commit is contained in:
Peter Piela 2017-07-04 09:43:29 -04:00
parent 4fa67ebfcd
commit 681d4744b1
8 changed files with 442 additions and 181 deletions

View File

@ -27,95 +27,44 @@
'$uibModalInstance',
'horizon.dashboard.admin.ironic.validMacAddressPattern',
'horizon.dashboard.admin.ironic.validDatapathIdPattern',
'horizon.dashboard.admin.ironic.form-field.service',
'ctrl'
];
/**
* @description Utility class for managing form fields
*
* @param {object} args - Valid properties are:
* value - Initial value of the field
* required - Does the field require a value
* desc - Field description
* pattern - Regular expression pattern used to match
* valid input values
* disabled - Is the field disabled
* info - Additional information about the current state of
* the field. It will be displayed in a tooltip associated
* with the field.
*
* @return {void}
*/
function Field(args) {
this.value = angular.isDefined(args.value) ? args.value : undefined;
this.required = angular.isDefined(args.required) ? args.required : false;
this.desc = angular.isDefined(args.desc) ? args.desc : undefined;
this.pattern = angular.isDefined(args.pattern)
? new RegExp(args.pattern) : undefined;
this.disabled = angular.isDefined(args.disabled) ? args.disabled : false;
this.info = angular.isDefined(args.info) ? args.info : undefined;
/**
* Test whether the field has a non-empty value. Note that an
* empty value can be either '' or undefined in the case of a
* required field
*
* @return {boolean} Return true if the field has a value
*/
this.hasValue = function() {
return angular.isDefined(this.value) && this.value !== '';
};
/**
* Test whether the field has help-text
*
* @return {boolean} Return true if the field has help text
*/
this.hasHelpText = function() {
return this.desc || this.info;
};
/**
* Get the help-text associated with this field
*
* @return {string} Return true if the field has help text
*/
this.getHelpText = function() {
var text = angular.isDefined(this.desc) ? this.desc : '';
if (angular.isDefined(this.info)) {
if (text !== '') {
text += '<br><br>';
}
text += this.info;
}
return text;
};
}
/**
* @description Utility class used to manage local-link-connection
* form fields.
*
* @param {string} formFieldService - Provider service for creating
* form fields.
* @param {string} validMacAddressPattern - Regular expression
* pattern used to test for valid mac addresses.
* @param {string} validDatapathIdPattern - Regular expression
* pattern used to test for valid datapath ids.
* @return {void}
*/
function LocalLinkConnectionMgr(validMacAddressPattern,
function LocalLinkConnectionMgr(formFieldService,
validMacAddressPattern,
validDatapathIdPattern) {
this.port_id = new Field({});
var mgr = this;
this.switch_id = new Field({
desc: gettext("MAC address or OpenFlow datapath ID"),
pattern: validMacAddressPattern + '|' + validDatapathIdPattern});
mgr.port_id = new formFieldService.FormField(
{id: 'port_id', title: 'port_id'});
this.switch_info = new Field({});
mgr.switch_id = new formFieldService.FormField(
{id: 'switch_id',
title: 'switch_id',
desc: gettext("MAC address or OpenFlow datapath ID"),
pattern: new RegExp(validMacAddressPattern + '|' +
validDatapathIdPattern)});
this.fields = {
port_id: this.port_id,
switch_id: this.switch_id,
switch_info: this.switch_info
mgr.switch_info = new formFieldService.FormField(
{id: 'switch_info', title: 'switch_info'});
mgr.fields = {
port_id: mgr.port_id,
switch_id: mgr.switch_id,
switch_info: mgr.switch_info
};
/**
@ -123,13 +72,17 @@
*
* @return {void}
*/
this.update = function() {
var required = this.port_id.hasValue() || this.switch_id.hasValue();
this.port_id.required = required;
this.switch_id.required = required;
mgr.update = function() {
var required = mgr.port_id.hasValue() || mgr.switch_id.hasValue();
mgr.port_id.required = required;
mgr.switch_id.required = required;
};
// Add form field value change handlers
angular.forEach(mgr.fields, function(field) {
field.change = mgr.update;
});
/**
* Generate an attribute object that conforms to the format
* required for port creation using the Ironic client
@ -138,33 +91,45 @@
* A value of null is returned if the local-link-connection
* information is incomplete.
*/
this.toPortAttr = function() {
mgr.toPortAttr = function() {
var attr = null;
if (this.port_id.hasValue() &&
this.switch_id.hasValue()) {
if (mgr.port_id.hasValue() &&
mgr.switch_id.hasValue()) {
attr = {};
attr.port_id = this.port_id.value;
attr.switch_id = this.switch_id.value;
attr.port_id = mgr.port_id.value;
attr.switch_id = mgr.switch_id.value;
if (this.switch_info.hasValue()) {
attr.switch_info = this.switch_info.value;
if (mgr.switch_info.hasValue()) {
attr.switch_info = mgr.switch_info.value;
}
}
return attr;
};
/**
* dis/enable the local-link-connection form fields
* @description Set values of form fields;
*
* @param {boolean} disabled - True if the local-link-connection form
* fields should be disabled
* @param {string} reason - Optional reason for the state change
* @param {object} values - Dictionary of values indexed by
* property-name
* @return {void}
*/
this.setDisabled = function(disabled, reason) {
angular.forEach(this.fields, function(field) {
field.disabled = disabled;
field.info = reason;
mgr.setValues = function(values) {
angular.forEach(mgr.fields, function(field, propertyName) {
if (angular.isDefined(values[propertyName])) {
field.value = values[propertyName];
}
});
};
/**
* @description Disable the local-link-connection form fields.
*
* @param {string} reason - Optional reason for disabling fields.
* @return {void}
*/
mgr.disable = function(reason) {
angular.forEach(mgr.fields, function(item) {
item.disable(reason);
});
};
}
@ -172,17 +137,35 @@
function BasePortController($uibModalInstance,
validMacAddressPattern,
validDatapathIdPattern,
formFieldService,
ctrl) {
ctrl.port = {
address: null,
extra: {}
};
ctrl.pxeEnabled = new Field({value: 'True'});
ctrl.address = new formFieldService.FormField({
id: "macAddress",
title: gettext("MAC address"),
desc: gettext("MAC address for this port. Required."),
pattern: new RegExp(validMacAddressPattern),
value: null,
required: true,
autoFocus: true
});
ctrl.pxeEnabled = new formFieldService.FormField({
type: "radio",
id: "pxeEnabled",
title: gettext("PXE enabled"),
desc: gettext(
"Indicates whether this port should be used when PXE booting this node"),
options: ['True', 'False'],
value: 'True'});
// Object used to manage local-link-connection form fields
ctrl.localLinkConnection =
new LocalLinkConnectionMgr(validMacAddressPattern,
new LocalLinkConnectionMgr(formFieldService,
validMacAddressPattern,
validDatapathIdPattern);
/**

View File

@ -10,50 +10,8 @@
</div>
<div class="modal-body">
<form id="CreatePortForm" name="CreatePortForm">
<div class="form-group"
ng-class="{'has-error': CreatePortForm.macAddress.$invalid &&
CreatePortForm.macAddress.$dirty}">
<label for="macAddress"
class="control-label"
translate>MAC address</label>
<span class="hz-icon-required fa fa-asterisk"></span>
<div>
<input type="text"
class="form-control"
ng-model="ctrl.port.address"
id="macAddress"
name="macAddress"
ng-required="true"
ng-pattern="'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})'"
auto-focus
placeholder="{$ ::'MAC address for this port. Required.' | translate $}"/>
</div>
</div>
<div class="form-group">
<div>
<label for="pxeEnabled"
class="control-label"
translate>
PXE enabled
</label>
<span ng-if="ctrl.pxeEnabled.hasHelpText()"
class="help-icon"
data-container="body"
data-html="true"
title=""
data-toggle="tooltip"
data-original-title="{$ ctrl.pxeEnabled.getHelpText() $}">
<span class="fa fa-question-circle"></span>
</span>
</div>
<div class="btn-group" id="pxeEnabled">
<label class="btn btn-default"
ng-model="ctrl.pxeEnabled.value"
ng-repeat="opt in ['True', 'False']"
ng-disabled="ctrl.pxeEnabled.disabled"
uib-btn-radio="opt">{$ opt $}</label>
</div>
</div>
<form-field field="ctrl.address" form="CreatePortForm"></form-field>
<form-field field="ctrl.pxeEnabled" form="CreatePortForm"></form-field>
</form>
<form id="LocalLinkConnectionForm"
@ -61,34 +19,9 @@
class="well well-sm">
<h4 translate>Local link connection</h4>
<div class="form-group"
ng-repeat="(propertyName, propertyObj) in
ctrl.localLinkConnection.fields"
ng-class="{'has-error': LocalLinkConnectionForm.{$ propertyName $}.$invalid &&
LocalLinkConnectionForm.{$ propertyName $}.$dirty}">
<label class="control-label"
for="{$ propertyName $}">{$ propertyName $}
<span ng-if="propertyObj.required"
class="hz-icon-required fa fa-asterisk"></span>
<span ng-if="propertyObj.hasHelpText()"
class="help-icon"
data-container="body"
title=""
data-toggle="tooltip"
data-html="true"
data-original-title="{$ propertyObj.getHelpText() $}">
<span class="fa fa-question-circle"></span>
</span>
</label>
<input class="form-control"
type="text"
id="{$ propertyName $}"
name="{$ propertyName $}"
ng-model="propertyObj.value"
ng-required="propertyObj.required"
placeholder="{$ propertyObj.desc $}"
ng-pattern="propertyObj.pattern"
ng-disabled="propertyObj.disabled"
ng-change="ctrl.localLinkConnection.update()"/>
ng-repeat="(propertyName, propertyField) in
ctrl.localLinkConnection.fields">
<form-field field="propertyField" form="LocalLinkConnectionForm"></form-field>
</div>
</form>

View File

@ -56,6 +56,8 @@
var port = angular.copy(ctrl.port);
port.node_uuid = node.id;
port.address = ctrl.address.value;
var attr = ctrl.localLinkConnection.toPortAttr();
if (attr) {
port.local_link_connection = attr;

View File

@ -62,26 +62,22 @@
node.provision_state === "manageable"));
// Initialize form fields
ctrl.port.address = port.address;
ctrl.address.value = port.address;
if ((node.provision_state === "active" || node.instance_uuid) &&
!node.maintenance) {
ctrl.address.disable();
}
ctrl.pxeEnabled.value = port.pxe_enabled ? 'True' : 'False';
if (cannotEditConnectivityAttr) {
ctrl.pxeEnabled.disabled = true;
ctrl.pxeEnabled.info = UNABLE_TO_UPDATE_CONNECTIVITY_ATTR_MSG;
ctrl.pxeEnabled.disable(UNABLE_TO_UPDATE_CONNECTIVITY_ATTR_MSG);
}
angular.forEach(
['port_id', 'switch_id', 'switch_info'],
function(prop) {
if (angular.isDefined(port.local_link_connection[prop])) {
ctrl.localLinkConnection[prop].value =
port.local_link_connection[prop];
}
});
ctrl.localLinkConnection.setValues(
port.local_link_connection);
if (cannotEditConnectivityAttr) {
ctrl.localLinkConnection.setDisabled(
true,
ctrl.localLinkConnection.disable(
UNABLE_TO_UPDATE_CONNECTIVITY_ATTR_MSG);
}
@ -97,7 +93,7 @@
$log.info("Updating port " + JSON.stringify(port));
patcher.buildPatch(port.address, ctrl.port.address, "/address");
patcher.buildPatch(port.address, ctrl.address.value, "/address");
patcher.buildPatch(port.pxe_enabled ? 'True' : 'False',
ctrl.pxeEnabled.value,
"/pxe_enabled");

View File

@ -0,0 +1,52 @@
/*
* Copyright 2017 Cray 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';
angular
.module('horizon.dashboard.admin.ironic')
.directive('formField', FormField);
FormField.$inject = [
'$timeout',
'$compile',
'horizon.dashboard.admin.ironic.basePath'
];
function FormField($timeout, $compile, basePath) {
return {
restrict: 'E',
scope: {
field: '=',
form: '='
},
templateUrl: basePath + '/form-field.html',
link: function(scope, element) {
// Process the auto-focus attribute
if (scope.field.autoFocus) {
// Need to defer processing until the DOM is fully instantiated
$timeout(function() {
var inputs = element.find('input');
if (inputs[0]) {
inputs.attr('auto-focus', '');
$compile(element.contents())(scope);
}
});
}
}
};
}
})();

View File

@ -0,0 +1,47 @@
<div class="form-group"
ng-class="{'has-error': form[field.id].$invalid &&
form[field.id].$dirty}">
<label for="{$ field.id $}"
class="control-label">{$ field.title $}</label>
<span ng-if="field.getHelpText()"
class="help-icon"
data-container="body"
data-html="true"
title=""
data-toggle="tooltip"
data-original-title="{$ field.getHelpText() $}">
<span class="fa fa-question-circle"></span>
</span>
<span ng-if="field.required"
class="hz-icon-required fa fa-asterisk"></span>
<div ng-switch="field.type">
<input ng-switch-when="input"
type="text"
class="form-control"
ng-model="field.value"
id="{$ field.id $}"
name="{$ field.id $}"
ng-required="field.required"
ng-disabled="field.disabled"
ng-pattern="field.pattern"
ng-change="field.change()"
placeholder="{$ field.getHelpText() $}"/>
<div ng-switch-when="radio"
class="btn-group"
id="{$ field.id $}">
<label class="btn btn-default"
ng-model="field.value"
ng-repeat="opt in field.options"
ng-disabled="field.disabled"
uib-btn-radio="opt">{$ opt $}</label>
</div>
<div ng-switch-when="select">
<select id="field.id"
class="form-control"
ng-disabled="field.disabled"
ng-options="{$ field.options $}"
ng-model="field.value">
</select>
</div>
</div>
</div>

View File

@ -0,0 +1,128 @@
/*
* Copyright 2017 Cray 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';
angular
.module('horizon.dashboard.admin.ironic')
.factory('horizon.dashboard.admin.ironic.form-field.service',
formFieldService);
function formFieldService() {
var service = {
FormField: FormField
};
/**
* @description Utility class for managing form fields.
* Used is association with the form-field directive.
*
* @param {object} args - Base properties are:
* type [string] - Field type. One of: 'input', 'radio', 'select'
* id [string] - id/name of the DOM value element
* title [string] - Label used to identify the field to the user
* options - type == radio [array]:
* List of options for a radio field
* type == select [string]:
* String expression that is passed to ng-options
* value - Initial value of the field
* required [boolean] - Does the field require a value
* desc [string] - Field description
* pattern [RegExp] - Regular expression pattern used to match
* valid input values
* disabled [boolean] - Is the field disabled
* info [string] - Additional information about the current state of
* the field. It will be displayed in a tooltip
* associated with the field.
* autoFocus [boolean] - True if the focus should be set to this field. Only
* applies to fields of type input.
* change [string] - Expression to be evaluated when the value of this
* field changes. Only applies to fields of type input.
*
* @return {void}
*/
function FormField(args) {
var field = this;
field.type = 'input';
field.id = undefined;
field.title = undefined;
field.options = undefined;
field.value = undefined;
field.required = false;
field.desc = undefined;
field.pattern = undefined;
field.disabled = false;
field.info = undefined;
field.autoFocus = false;
field.change = undefined;
angular.forEach(args, function(value, arg) {
field[arg] = value;
});
/**
* @description Test whether the field has a non-empty value.
* Note that an empty value can be either '' or undefined in the
* case of a required field
*
* @return {boolean} Return true if the field has a value
*/
this.hasValue = function() {
return angular.isDefined(this.value) && this.value !== '';
};
/**
* @description Test whether the field has help-text.
*
* @return {boolean} Return true if the field has help text.
*/
this.hasHelpText = function() {
return angular.isDefined(this.desc) || angular.isDefined(this.info);
};
/**
* @description Get the help-text associated with this field
*
* @return {string} Return true if the field has help text
*/
this.getHelpText = function() {
var text = angular.isDefined(this.desc) ? this.desc : '';
if (angular.isDefined(this.info)) {
if (text !== '') {
text += '<br><br>';
}
text += this.info;
}
return text;
};
/**
* @description Disable this field.
*
* @param {string} reason - Optional reason for disabling this field.
* @return {void}
*/
this.disable = function(reason) {
this.disabled = true;
if (angular.isDefined(reason)) {
this.info = reason;
}
};
}
return service;
}
})();

View File

@ -0,0 +1,120 @@
/**
* Copyright 2017 Cray 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";
/**
* @description Unit tests for the form-field service
*/
describe(
'horizon.dashboard.admin.ironic.form-field.service',
function() {
var formFieldService;
beforeEach(module('horizon.dashboard.admin.ironic'));
beforeEach(inject(function($injector) {
formFieldService =
$injector.get('horizon.dashboard.admin.ironic.form-field.service');
}));
it('defines the form-field service', function() {
expect(formFieldService).toBeDefined();
});
it('FormField - default construction', function() {
var field = new formFieldService.FormField({});
expect(field.type).toEqual('input');
expect(field.id).toBeUndefined();
expect(field.title).toBeUndefined();
expect(field.options).toBeUndefined();
expect(field.value).toBeUndefined();
expect(field.required).toBe(false);
expect(field.desc).toBeUndefined();
expect(field.pattern).toBeUndefined();
expect(field.disabled).toBe(false);
expect(field.info).toBeUndefined();
expect(field.autoFocus).toBe(false);
expect(field.change).toBeUndefined();
expect(formFieldService).toBeDefined();
});
it('FormField - local parameters', function() {
var title = "title";
var field = new formFieldService.FormField({
title: title
});
expect(field.title).toBe(title);
});
it('hasValue', function() {
var field = new formFieldService.FormField({});
expect(field.hasValue()).toBe(false);
field.value = '';
expect(field.hasValue()).toBe(false);
field.value = null;
expect(field.hasValue()).toBe(true);
field.value = 'True';
expect(field.hasValue()).toBe(true);
});
it('hasHelpText', function() {
var field = new formFieldService.FormField({});
expect(field.hasHelpText()).toBe(false);
expect(field.getHelpText()).toBe('');
});
it('hasHelpText/getHelpText - desc', function() {
var field = new formFieldService.FormField({
desc: 'desc'
});
expect(field.hasHelpText()).toBe(true);
expect(field.getHelpText()).toBe('desc');
});
it('hasHelpText/getHelpText - info', function() {
var field = new formFieldService.FormField({
info: 'info'
});
expect(field.hasHelpText()).toBe(true);
expect(field.getHelpText()).toBe('info');
});
it('getHelpText - desc/info', function() {
var field = new formFieldService.FormField({
desc: 'desc',
info: 'info'
});
expect(field.hasHelpText()).toBe(true);
expect(field.getHelpText()).toBe('desc<br><br>info');
});
it('disable', function() {
var field = new formFieldService.FormField({});
expect(field.disabled).toBe(false);
field.disable();
expect(field.disabled).toBe(true);
});
});
})();