[Forms] Merge in forms bundle

Merge in forms bundle from its development branch
to begin work integrating Forms into the general
UI, for WTD-593.
This commit is contained in:
Victor Woeltjen 2014-12-03 12:22:12 -08:00
commit 2bee842509
19 changed files with 856 additions and 4 deletions

View File

@ -10,6 +10,7 @@
"platform/features/layout",
"platform/features/plot",
"platform/features/scrolling",
"platform/forms",
"example/generator",
"example/persistence"

18
example/forms/bundle.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "Declarative Forms example",
"sources": "src",
"extensions": {
"controllers": [
{
"key": "ExampleFormController",
"implementation": "ExampleFormController.js",
"depends": [ "$scope" ]
}
],
"routes": [
{
"templateUrl": "templates/exampleForm.html"
}
]
}
}

View File

@ -0,0 +1,19 @@
<div ng-controller="ExampleFormController">
<mct-form structure="form" ng-model="state" name="aForm">
</mct-form>
<ul>
<li>Dirty: {{aForm.$dirty}}</li>
<li>Valid: {{aForm.$valid}}</li>
</ul>
<pre>
<textarea>
{{state | json}}
</textarea>
</pre>
</div>

View File

@ -0,0 +1,79 @@
/*global define*/
define(
[],
function () {
"use strict";
function ExampleFormController($scope) {
$scope.state = {
};
$scope.form = {
name: "An example form.",
sections: [
{
name: "First section",
rows: [
{
name: "Check me",
control: "checkbox",
key: "checkMe"
},
{
name: "Enter your name",
required: true,
control: "textfield",
key: "yourName"
},
{
name: "Enter a number",
control: "textfield",
pattern: "^\\d+$",
key: "aNumber"
}
]
},
{
name: "Second section",
rows: [
{
name: "Pick a date",
required: true,
description: "Enter date in form YYYY-DDD",
control: "datetime",
key: "aDate"
},
{
name: "Choose something",
control: "select",
options: [
{ name: "Hats", value: "hats" },
{ name: "Bats", value: "bats" },
{ name: "Cats", value: "cats" },
{ name: "Mats", value: "mats" }
],
key: "aChoice"
},
{
name: "Choose something",
control: "select",
required: true,
options: [
{ name: "Hats", value: "hats" },
{ name: "Bats", value: "bats" },
{ name: "Cats", value: "cats" },
{ name: "Mats", value: "mats" }
],
key: "aRequiredChoice"
}
]
}
]
};
}
return ExampleFormController;
}
);

View File

@ -1,3 +1,87 @@
# Overview
This bundle contains a general implementation of forms in Open MCT Web.
This allows forms to be expressed using a reasonably concise declarative
syntax, and rendered as Angular templates in a consistent fashion.
# Usage
To include a form with a declarative definition, use the `mct-form`
directive, e.g.:
<mct-form ng-model="myModel" structure="myStructure" name="myForm">
</mct-form>
The attributes utilized by this form are as follows:
* `ng-model`: The object which should contain the full form input. Individual
fields in this model are bound to individual controls; the names used for
these fields are provided in the form structure (see below).
* `structure`: The structure of the form; e.g. sections, rows, their names,
and so forth. The value of this attribute should be an Angular expression.
* `name`: The name in the containing scope under which to publish form
"meta-state", e.g. `$valid`, `$dirty`, etc. This is as the behavior of
`ng-form`. Passed as plain text in the attribute.
## Form structure
A form's structure is described as a JavaScript object in the following form:
{
"name": ... title to display for the form, as a string ...,
"sections": [
{
"name": ... title to display for the section ...,
"rows": [
{
"name": ... title to display for this row ...,
"control": ... symbolic key for the control ...,
"key": ... field name in ng-model ...
"pattern": ... optional, reg exp to match against ...
"required": ... optional boolean ...
"options": [
"name": ... name to display (e.g. in a select) ...,
"value": ... value to store in the model ...
]
},
... and other rows ...
]
},
... and other sections ...
]
}
Note that `pattern` may be specified as a string, to simplify storing
for structures as JSON when necessary. The string should be given in
a form appropriate to pass to a `RegExp` constructor.
## Adding controls
Four standard control types are included in the forms bundle:
* `textfield`: An area to enter plain text.
* `select`: A drop-down list of options.
* `checkbox`: A box which may be checked/unchecked.
* `datetime`: An input for UTC date/time entry; gives result as a
UNIX timestamp, in milliseconds since start of 1970, UTC.
New controls may be added as extensions of the `controls` category.
Extensions of this category have two properites:
* `key`: The symbolic name for this control (matched against the
`control` field in rows of the form structure).
* `templateUrl`: The URL to the control's Angular template, relative
to the resources directory of the bundle which exposes the extension.
Within the template for a control, the following variables will be
included in scope:
* `ngModel`: The model where form input will be stored. Notably we
also need to look at `field` (see below) to determine which field
in the model should be modified.
* `ngRequired`: True if input is required.
* `ngPattern`: The pattern to match against (for text entry.)
* `options`: The options for this control, as passed from the
`options` property of an individual row.
* `field`: Name of the field in `ngModel` which will hold the value
for this control.

View File

@ -0,0 +1,72 @@
{
"name": "MCT Forms",
"description": "Form generator; includes directive and some controls.",
"extensions": {
"directives": [
{
"key": "mctForm",
"implementation": "MCTForm.js"
},
{
"key": "mctControl",
"implementation": "MCTControl.js",
"depends": [ "controls[]" ]
}
],
"controls": [
{
"key": "checkbox",
"templateUrl": "templates/controls/checkbox.html"
},
{
"key": "datetime",
"templateUrl": "templates/controls/datetime.html"
},
{
"key": "select",
"templateUrl": "templates/controls/select.html"
},
{
"key": "textfield",
"templateUrl": "templates/controls/textfield.html"
}
],
"controllers": [
{
"key": "DateTimeController",
"implementation": "controllers/DateTimeController.js",
"depends": [ "$scope" ]
}
],
"templates": [
{
"key": "_checkbox",
"templateUrl": "templates/_checkbox.html"
},
{
"key": "_checkboxes",
"templateUrl": "templates/_checkboxes.html"
},
{
"key": "_datetime",
"templateUrl": "templates/_datetime.html"
},
{
"key": "_select",
"templateUrl": "templates/_select.html"
},
{
"key": "_selects",
"templateUrl": "templates/_selects.html"
},
{
"key": "_textfield",
"templateUrl": "templates/_textfield.html"
},
{
"key": "_textfields",
"templateUrl": "templates/_textfields.html"
}
]
}
}

6
platform/forms/lib/moment.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,7 @@
<label class="checkbox custom no-text">
<input type="checkbox"
name="mctControl"
ng-model="ngModel[field]"
ng-disabled="ngDisabled">
<em>&nbsp;</em>
</label>

View File

@ -0,0 +1,62 @@
<div class='form-control complex datetime'>
<div class='field-hints'>
<span class='hint date'>Date</span>
<span class='hint time sm'>Hour</span>
<span class='hint time sm'>Min</span>
<span class='hint time sm'>Sec</span>
<span class='hint timezone'>Timezone</span>
</div>
<ng-form name="mctControl">
<div class='fields' ng-controller="DateTimeController">
<span class='field control date'>
<input type='text'
name='date'
placeholder="YYYY-DDD"
ng-pattern="/\d\d\d\d-\d\d\d/"
ng-model='datetime.date'
ng-required='true'/>
</span>
<span class='field control time sm'>
<input type='text'
name='hour'
maxlength='2'
min='0'
max='23'
integer
ng-pattern='/\d+/'
ng-model="datetime.hour"
ng-required='true'/>
</span>
<span class='field control time sm'>
<input type='text'
name='min'
maxlength='2'
min='0'
max='59'
integer
ng-pattern='/\d+/'
ng-model="datetime.min"
ng-required='true'/>
</span>
<span class='field control time sm'>
<input type='text'
name='sec'
maxlength='2'
min='0'
max='59'
integer
ng-pattern='/\d+/'
ng-model="datetime.sec"
ng-required='true'/>
</span>
<span class='field control timezone'>
UTC
</span>
</div>
</ng-form>
</div>

View File

@ -0,0 +1,8 @@
<select class='form-control input select'
ng-model="ngModel[field]"
ng-options="opt.value as opt.name for opt in options"
ng-required="ngRequired"
name="mctControl">
<option value="" ng-if="!ngModel[field]">- Select One -</option>
<span class='ui-symbol arw colorKey'>v</span>
</select>

View File

@ -0,0 +1,9 @@
<span class='form-control shell'>
<span class='field control'>
<input type="text"
ng-required="ngRequired"
ng-model="ngModel[field]"
ng-pattern="ngPattern"
name="mctControl">
</span>
</span>

View File

@ -0,0 +1,55 @@
<form name="mctForm" novalidate>
<div class="form">
<span ng-repeat="section in structure.sections">
<div class="section-header" ng-if="section.name">
{{section.name}}
</div>
<div class="form-section">
<ng-form name="mctFormInner" ng-repeat="row in section.rows">
<div class="form-row validates"
ng-class="{
req: row.required,
valid: mctFormInner.$dirty && mctFormInner.$valid,
invalid: mctFormInner.$dirty && !mctFormInner.$valid
}">
<div class='label' title="{{row.description}}">
{{row.name}}
<span ng-if="row.description"
class="ui-symbol">
i
</span>
</div>
<div class='controls'>
<div class="wrapper" ng-if="row.control">
<mct-control key="row.control"
ng-model="ngModel"
ng-required="row.required"
ng-pattern="getRegExp(row.pattern)"
options="row.options"
structure="row"
field="row.key">
</mct-control>
</div>
<div ng-repeat="item in row.items" class="validates">
<ng-form name="mctFormItem">
<mct-control key="item.control"
ng-model="ngModel"
ng-required="item.required"
ng-pattern="getRegExp(item.pattern)"
options="item.options"
structure="row"
field="item.key">
</mct-control>
{{item.name}}
</ng-form>
</div>
</div>
</div>
</ng-form>
</div>
</span>
</div>
</form>

View File

@ -0,0 +1,81 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* The mct-control will dynamically include the control
* for a form element based on a symbolic key. Individual
* controls are defined under the extension category
* `controls`; this allows plug-ins to introduce new form
* control types while still making use of the form
* generator to ensure an overall consistent form style.
*/
function MCTControl(controls) {
var controlMap = {};
// Prepopulate controlMap for easy look up by key
controls.forEach(function (control) {
var path = [
control.bundle.path,
control.bundle.resources,
control.templateUrl
].join("/");
controlMap[control.key] = path;
});
function controller($scope) {
$scope.$watch("key", function (key) {
// Pass the template URL to ng-include via scope.
$scope.inclusion = controlMap[key];
});
}
return {
// Only show at the element level
restrict: "E",
// Use the included controller to populate scope
controller: controller,
// Use ng-include as a template; "inclusion" will be the real
// template path
template: '<ng-include src="inclusion"></ng-include>',
// ngOptions is terminal, so we need to be higher priority
priority: 1000,
// Pass through Angular's normal input field attributes
scope: {
// Used to choose which form control to use
key: "=",
// The state of the form value itself
ngModel: "=",
// Enabled/disabled state
ngDisabled: "=",
// Whether or not input is required
ngRequired: "=",
// Pattern (for input fields)
ngPattern: "=",
// Set of choices (if any)
options: "=",
// Structure (subtree of Form Structure)
structure: "=",
// Name, as in "<input name="...
field: "="
}
};
}
return MCTControl;
}
);

View File

@ -0,0 +1,107 @@
/*global define,Promise*/
/**
* Module defining MCTForm. Created by vwoeltje on 11/10/14.
*/
define(
[],
function () {
"use strict";
// Default ng-pattern; any non whitespace
var NON_WHITESPACE = /\S/;
/**
* The mct-form directive allows generation of displayable
* forms based on a declarative description of the form's
* structure.
*
* This directive accepts three attributes:
*
* * `ng-model`: The model for the form; where user input
* where be stored.
* * `structure`: The declarative structure of the form.
* Describes what controls should be shown and where
* their values should be read/written in the model.
* * `name`: The name under which to expose the form's
* dirty/valid state. This is similar to ng-form's use
* of name, except this will be made available in the
* parent scope.
*
* @constructor
*/
function MCTForm() {
var templatePath = [
"platform/forms", //MCTForm.bundle.path,
"res", //MCTForm.bundle.resources,
"templates/form.html"
].join("/");
function controller($scope) {
var regexps = [];
// ng-pattern seems to want a RegExp, and not a
// string (despite what documentation says) but
// we want form structure to be JSON-expressible,
// so we make RegExp's from strings as-needed
function getRegExp(pattern) {
// If undefined, don't apply a pattern
if (!pattern) {
return NON_WHITESPACE;
}
// Just echo if it's already a regexp
if (pattern instanceof RegExp) {
return pattern;
}
// Otherwise, assume a string
// Cache for easy lookup later (so we don't
// creat a new RegExp every digest cycle)
if (!regexps[pattern]) {
regexps[pattern] = new RegExp(pattern);
}
return regexps[pattern];
}
// Publish the form state under the requested
// name in the parent scope
$scope.$watch("mctForm", function (mctForm) {
if ($scope.name) {
$scope.$parent[$scope.name] = mctForm;
}
});
$scope.getRegExp = getRegExp;
}
return {
// Only show at the element level
restrict: "E",
// Load the forms template
templateUrl: templatePath,
// Use the controller defined above to
// populate/respond to changes in scope
controller: controller,
// Initial an isolate scope
scope: {
// The model: Where form input will actually go
ngModel: "=",
// Form structure; what sections/rows to show
structure: "=",
// Name under which to publish the form
name: "@"
}
};
}
return MCTForm;
}
);

View File

@ -0,0 +1,48 @@
/*global define,moment*/
define(
["../../lib/moment.min"],
function () {
"use strict";
var DATE_FORMAT = "YYYY-DDD";
/**
* Controller for the `datetime` form control.
* This is a composite control; it includes multiple
* input fields but outputs a single timestamp (in
* milliseconds since start of 1970) to the ngModel.
*
* @constructor
*/
function DateTimeController($scope) {
// Update the
function update() {
var date = $scope.datetime.date,
hour = $scope.datetime.hour,
min = $scope.datetime.min,
sec = $scope.datetime.sec,
fullDateTime = moment.utc(date, DATE_FORMAT)
.hour(hour || 0)
.minute(min || 0)
.second(sec || 0);
if (fullDateTime.isValid()) {
$scope.ngModel[$scope.field] = fullDateTime.valueOf();
}
}
// Update value whenever any field changes.
$scope.$watch("datetime.date", update);
$scope.$watch("datetime.hour", update);
$scope.$watch("datetime.min", update);
$scope.$watch("datetime.sec", update);
$scope.datetime = {};
}
return DateTimeController;
}
);

View File

@ -0,0 +1,60 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/MCTControl"],
function (MCTControl) {
"use strict";
describe("The mct-control directive", function () {
var testControls,
mockScope,
mctControl;
beforeEach(function () {
testControls = [
{
key: "abc",
bundle: { path: "a", resources: "b" },
templateUrl: "c/template.html"
},
{
key: "xyz",
bundle: { path: "x", resources: "y" },
templateUrl: "z/template.html"
}
];
mockScope = jasmine.createSpyObj("$scope", [ "$watch" ]);
mctControl = new MCTControl(testControls);
});
it("is restricted to the element level", function () {
expect(mctControl.restrict).toEqual("E");
});
it("watches its passed key to choose a template", function () {
mctControl.controller(mockScope);
expect(mockScope.$watch).toHaveBeenCalledWith(
"key",
jasmine.any(Function)
);
});
it("changes its template dynamically", function () {
mctControl.controller(mockScope);
mockScope.key = "xyz";
mockScope.$watch.mostRecentCall.args[1]("xyz");
// Should have communicated the template path to
// ng-include via the "inclusion" field in scope
expect(mockScope.inclusion).toEqual(
"x/y/z/template.html"
);
});
});
}
);

View File

@ -0,0 +1,90 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/MCTForm"],
function (MCTForm) {
"use strict";
describe("The mct-form directive", function () {
var mockScope,
mctForm;
beforeEach(function () {
mockScope = jasmine.createSpyObj("$scope", [ "$watch" ]);
mockScope.$parent = {};
mctForm = new MCTForm();
});
it("is restricted to elements", function () {
expect(mctForm.restrict).toEqual("E");
});
it("watches for changes in form by name", function () {
// mct-form needs to watch for the form by name
// in order to convey changes in $valid, $dirty, etc
// up to the parent scope.
mctForm.controller(mockScope);
expect(mockScope.$watch).toHaveBeenCalledWith(
"mctForm",
jasmine.any(Function)
);
});
it("conveys form status to parent scope", function () {
var someState = { someKey: "some value" };
mockScope.name = "someName";
mctForm.controller(mockScope);
mockScope.$watch.mostRecentCall.args[1](someState);
expect(mockScope.$parent.someName).toBe(someState);
});
it("allows strings to be converted to RegExps", function () {
// This is needed to support ng-pattern in the template
mctForm.controller(mockScope);
// Should have added getRegExp to the scope,
// to convert strings to regular expressions
expect(mockScope.getRegExp("^\\d+$")).toEqual(/^\d+$/);
});
it("returns the same regexp instance for the same string", function () {
// Don't want new instances each digest cycle, for performance
var strRegExp = "^[a-z]\\d+$",
regExp;
// Add getRegExp to scope
mctForm.controller(mockScope);
regExp = mockScope.getRegExp(strRegExp);
// Same object instance each time...
expect(mockScope.getRegExp(strRegExp)).toBe(regExp);
expect(mockScope.getRegExp(strRegExp)).toBe(regExp);
});
it("passes RegExp objects through untouched", function () {
// Permit using forms to simply provide their own RegExp object
var regExp = /^\d+[a-d]$/;
// Add getRegExp to scope
mctForm.controller(mockScope);
// Should have added getRegExp to the scope,
// to convert strings to regular expressions
expect(mockScope.getRegExp(regExp)).toBe(regExp);
});
it("passes a non-whitespace regexp when no pattern is defined", function () {
// If no pattern is supplied, ng-pattern should match anything
mctForm.controller(mockScope);
expect(mockScope.getRegExp()).toEqual(/\S/);
expect(mockScope.getRegExp(undefined)).toEqual(/\S/);
});
});
}
);

View File

@ -0,0 +1,41 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../../src/controllers/DateTimeController"],
function (DateTimeController) {
"use strict";
describe("The date-time directive", function () {
var mockScope,
controller;
beforeEach(function () {
mockScope = jasmine.createSpyObj("$scope", [ "$watch" ]);
controller = new DateTimeController(mockScope);
});
it("watches for changes in fields", function () {
["date", "hour", "min", "sec"].forEach(function (fieldName) {
expect(mockScope.$watch).toHaveBeenCalledWith(
"datetime." + fieldName,
jasmine.any(Function)
);
});
});
it("converts date-time input into a timestamp", function () {
mockScope.ngModel = {};
mockScope.field = "test";
mockScope.datetime.date = "2014-332";
mockScope.datetime.hour = 22;
mockScope.datetime.min = 55;
mockScope.datetime.sec = 13;
mockScope.$watch.mostRecentCall.args[1]();
expect(mockScope.ngModel.test).toEqual(1417215313000);
});
});
}
);

View File

@ -0,0 +1,5 @@
[
"MCTControl",
"MCTForm",
"controllers/DateTimeController"
]