diff --git a/example/forms/res/templates/exampleForm.html b/example/forms/res/templates/exampleForm.html index 70c65f3ab4..2360b0df97 100644 --- a/example/forms/res/templates/exampleForm.html +++ b/example/forms/res/templates/exampleForm.html @@ -1,4 +1,7 @@
+ + + diff --git a/example/forms/src/ExampleFormController.js b/example/forms/src/ExampleFormController.js index fd71122085..5534d26459 100644 --- a/example/forms/src/ExampleFormController.js +++ b/example/forms/src/ExampleFormController.js @@ -1,4 +1,4 @@ -/*global define*/ +/*global define,window*/ define( [], @@ -10,6 +10,89 @@ define( }; + $scope.toolbar = { + name: "An example toolbar.", + sections: [ + { + description: "First section", + items: [ + { + name: "X", + description: "X coordinate", + control: "textfield", + pattern: "^\\d+$", + disabled: true, + size: 2, + key: "x" + }, + { + name: "Y", + description: "Y coordinate", + control: "textfield", + pattern: "^\\d+$", + size: 2, + key: "y" + }, + { + name: "W", + description: "Cell width", + control: "textfield", + pattern: "^\\d+$", + size: 2, + key: "w" + }, + { + name: "H", + description: "Cell height", + control: "textfield", + pattern: "^\\d+$", + size: 2, + key: "h" + } + + ] + }, + { + description: "Second section", + items: [ + { + control: "button", + glyph: "1", + description: "Button A", + click: function () { + window.alert("A"); + } + }, + { + control: "button", + glyph: "2", + description: "Button B", + click: function () { + window.alert("B"); + } + }, + { + control: "button", + glyph: "3", + description: "Button C", + disabled: true, + click: function () { + window.alert("C"); + } + } + ] + }, + { + items: [ + { + control: "color", + key: "color" + } + ] + } + ] + }; + $scope.form = { name: "An example form.", sections: [ diff --git a/platform/commonUI/general/bundle.json b/platform/commonUI/general/bundle.json index c88684991c..00e73a1568 100644 --- a/platform/commonUI/general/bundle.json +++ b/platform/commonUI/general/bundle.json @@ -73,7 +73,7 @@ { "key": "ViewSwitcherController", "implementation": "controllers/ViewSwitcherController.js", - "depends": [ "$scope" ] + "depends": [ "$scope", "$timeout" ] }, { "key": "BottomBarController", diff --git a/platform/commonUI/general/src/controllers/ViewSwitcherController.js b/platform/commonUI/general/src/controllers/ViewSwitcherController.js index a7942fe627..05fec1bd1a 100644 --- a/platform/commonUI/general/src/controllers/ViewSwitcherController.js +++ b/platform/commonUI/general/src/controllers/ViewSwitcherController.js @@ -13,7 +13,7 @@ define( * of applicable views for a represented domain object. * @constructor */ - function ViewSwitcherController($scope) { + function ViewSwitcherController($scope, $timeout) { // If the view capability gets refreshed, try to // keep the same option chosen. function findMatchingOption(options, selected) { @@ -32,10 +32,12 @@ define( // Get list of views, read from capability function updateOptions(views) { - $scope.ngModel.selected = findMatchingOption( - views || [], - ($scope.ngModel || {}).selected - ); + $timeout(function () { + $scope.ngModel.selected = findMatchingOption( + views || [], + ($scope.ngModel || {}).selected + ); + }, 0); } // Update view options when the in-scope results of using the diff --git a/platform/commonUI/general/test/controllers/ViewSwitcherControllerSpec.js b/platform/commonUI/general/test/controllers/ViewSwitcherControllerSpec.js index af0b99aa88..1895fb5302 100644 --- a/platform/commonUI/general/test/controllers/ViewSwitcherControllerSpec.js +++ b/platform/commonUI/general/test/controllers/ViewSwitcherControllerSpec.js @@ -10,12 +10,15 @@ define( describe("The view switcher controller", function () { var mockScope, + mockTimeout, controller; beforeEach(function () { mockScope = jasmine.createSpyObj("$scope", [ "$watch" ]); + mockTimeout = jasmine.createSpy("$timeout"); + mockTimeout.andCallFake(function (cb) { cb(); }); mockScope.ngModel = {}; - controller = new ViewSwitcherController(mockScope); + controller = new ViewSwitcherController(mockScope, mockTimeout); }); it("watches for changes in applicable views", function () { @@ -71,6 +74,19 @@ define( expect(mockScope.ngModel.selected).not.toEqual(views[1]); }); + // Use of a timeout avoids infinite digest problems when deeply + // nesting switcher-driven views (e.g. in a layout.) See WTD-689 + it("updates initial selection on a timeout", function () { + // Verify precondition + expect(mockTimeout).not.toHaveBeenCalled(); + + // Invoke the watch for set of views + mockScope.$watch.mostRecentCall.args[1]([]); + + // Should have run on a timeout + expect(mockTimeout).toHaveBeenCalled(); + }); + }); } ); \ No newline at end of file diff --git a/platform/forms/README.md b/platform/forms/README.md index d435b023fd..034b687965 100644 --- a/platform/forms/README.md +++ b/platform/forms/README.md @@ -12,6 +12,11 @@ directive, e.g.: +Using toolbars is similar: + + + + The attributes utilized by this form are as follows: * `ng-model`: The object which should contain the full form input. Individual @@ -55,6 +60,44 @@ 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. +## Toolbar structure + +A toolbar's structure is described similarly to forms, except that there +is no notion of rows; instead, there are `items`. + + { + "name": ... title to display for the form, as a string ..., + "sections": [ + { + "name": ... title to display for the section ..., + "items": [ + { + "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 ... + ], + "disabled": ... true if control should be disabled ... + "size": ... size of the control (for textfields) ... + "click": ... function to invoke (for buttons) ... + "glyph": ... glyph to display (for buttons) ... + "text": ... text withiin control (for buttons) ... + }, + ... 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: @@ -62,6 +105,8 @@ 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. +* `color`: A color picker. +* `button`: A button. * `datetime`: An input for UTC date/time entry; gives result as a UNIX timestamp, in milliseconds since start of 1970, UTC. diff --git a/platform/forms/bundle.json b/platform/forms/bundle.json index 5654d80f2b..d8e3730eca 100644 --- a/platform/forms/bundle.json +++ b/platform/forms/bundle.json @@ -7,6 +7,10 @@ "key": "mctForm", "implementation": "MCTForm.js" }, + { + "key": "mctToolbar", + "implementation": "MCTToolbar.js" + }, { "key": "mctControl", "implementation": "MCTControl.js", @@ -30,6 +34,14 @@ "key": "textfield", "templateUrl": "templates/controls/textfield.html" }, + { + "key": "button", + "templateUrl": "templates/controls/button.html" + }, + { + "key": "color", + "templateUrl": "templates/controls/color.html" + }, { "key": "composite", "templateUrl": "templates/controls/composite.html" @@ -45,36 +57,6 @@ "key": "CompositeController", "implementation": "controllers/CompositeController.js" } - ], - "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" - } ] } } \ No newline at end of file diff --git a/platform/forms/res/templates/controls/button.html b/platform/forms/res/templates/controls/button.html new file mode 100644 index 0000000000..730a67dd8b --- /dev/null +++ b/platform/forms/res/templates/controls/button.html @@ -0,0 +1,11 @@ + + + {{structure.glyph}} + + + {{structure.text}} + + diff --git a/platform/forms/res/templates/controls/color.html b/platform/forms/res/templates/controls/color.html new file mode 100644 index 0000000000..4e44c8e84f --- /dev/null +++ b/platform/forms/res/templates/controls/color.html @@ -0,0 +1,3 @@ + diff --git a/platform/forms/res/templates/controls/textfield.html b/platform/forms/res/templates/controls/textfield.html index 57e61d3613..148f9e7005 100644 --- a/platform/forms/res/templates/controls/textfield.html +++ b/platform/forms/res/templates/controls/textfield.html @@ -4,6 +4,7 @@ ng-required="ngRequired" ng-model="ngModel[field]" ng-pattern="ngPattern" + size="{{structure.size}}" name="mctControl"> diff --git a/platform/forms/res/templates/toolbar.html b/platform/forms/res/templates/toolbar.html new file mode 100644 index 0000000000..17602268d8 --- /dev/null +++ b/platform/forms/res/templates/toolbar.html @@ -0,0 +1,32 @@ +
+ +
+ + + + + + + + + + + +
+ +
\ No newline at end of file diff --git a/platform/forms/src/MCTForm.js b/platform/forms/src/MCTForm.js index 8414938171..0de41797f7 100644 --- a/platform/forms/src/MCTForm.js +++ b/platform/forms/src/MCTForm.js @@ -4,13 +4,10 @@ * Module defining MCTForm. Created by vwoeltje on 11/10/14. */ define( - [], - function () { + ["./controllers/FormController"], + function (FormController) { "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 @@ -37,45 +34,6 @@ define( "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", @@ -83,9 +41,8 @@ define( // Load the forms template templateUrl: templatePath, - // Use the controller defined above to - // populate/respond to changes in scope - controller: controller, + // Use FormController to populate/respond to changes in scope + controller: FormController, // Initial an isolate scope scope: { diff --git a/platform/forms/src/MCTToolbar.js b/platform/forms/src/MCTToolbar.js new file mode 100644 index 0000000000..c4642e4544 --- /dev/null +++ b/platform/forms/src/MCTToolbar.js @@ -0,0 +1,64 @@ +/*global define,Promise*/ + +/** + * Module defining MCTForm. Created by vwoeltje on 11/10/14. + */ +define( + ["./controllers/FormController"], + function (FormController) { + "use strict"; + + /** + * The mct-toolbar 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 toolbar. + * 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/toolbar.html" + ].join("/"); + + return { + // Only show at the element level + restrict: "E", + + // Load the forms template + templateUrl: templatePath, + + // Use FormController to populate/respond to changes in scope + controller: FormController, + + // 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; + } +); \ No newline at end of file diff --git a/platform/forms/src/controllers/FormController.js b/platform/forms/src/controllers/FormController.js new file mode 100644 index 0000000000..ff7a1b0769 --- /dev/null +++ b/platform/forms/src/controllers/FormController.js @@ -0,0 +1,57 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + // Default ng-pattern; any non whitespace + var NON_WHITESPACE = /\S/; + + /** + * Controller for mct-form and mct-toolbar directives. + * @constructor + */ + function FormController($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; + } + }); + + // Expose the regexp lookup + $scope.getRegExp = getRegExp; + } + + return FormController; + } +); \ No newline at end of file diff --git a/platform/forms/test/MCTToolbarSpec.js b/platform/forms/test/MCTToolbarSpec.js new file mode 100644 index 0000000000..d18ff40ed6 --- /dev/null +++ b/platform/forms/test/MCTToolbarSpec.js @@ -0,0 +1,90 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/MCTToolbar"], + function (MCTToolbar) { + "use strict"; + + describe("The mct-toolbar directive", function () { + var mockScope, + mctToolbar; + + beforeEach(function () { + mockScope = jasmine.createSpyObj("$scope", [ "$watch" ]); + mockScope.$parent = {}; + mctToolbar = new MCTToolbar(); + }); + + it("is restricted to elements", function () { + expect(mctToolbar.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. + mctToolbar.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"; + + mctToolbar.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 + mctToolbar.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 + mctToolbar.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 + mctToolbar.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 + mctToolbar.controller(mockScope); + expect(mockScope.getRegExp()).toEqual(/\S/); + expect(mockScope.getRegExp(undefined)).toEqual(/\S/); + }); + + + }); + } +); \ No newline at end of file diff --git a/platform/forms/test/controllers/FormControllerSpec.js b/platform/forms/test/controllers/FormControllerSpec.js new file mode 100644 index 0000000000..538e4e5156 --- /dev/null +++ b/platform/forms/test/controllers/FormControllerSpec.js @@ -0,0 +1,69 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../../src/controllers/FormController"], + function (FormController) { + "use strict"; + + describe("The form controller", function () { + var mockScope, + controller; + + beforeEach(function () { + mockScope = jasmine.createSpyObj("$scope", [ "$watch" ]); + mockScope.$parent = {}; + controller = new FormController(mockScope); + }); + + it("watches for changes in form by name", function () { + expect(mockScope.$watch).toHaveBeenCalledWith( + "mctForm", + jasmine.any(Function) + ); + }); + + it("conveys form status to parent scope", function () { + var someState = { someKey: "some value" }; + mockScope.name = "someName"; + mockScope.$watch.mostRecentCall.args[1](someState); + expect(mockScope.$parent.someName).toBe(someState); + }); + + it("allows strings to be converted to RegExps", function () { + // 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 + 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]$/; + + // 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 + expect(mockScope.getRegExp()).toEqual(/\S/); + expect(mockScope.getRegExp(undefined)).toEqual(/\S/); + }); + + + }); + } +); \ No newline at end of file diff --git a/platform/forms/test/suite.json b/platform/forms/test/suite.json index f69c3a325e..61eff300e6 100644 --- a/platform/forms/test/suite.json +++ b/platform/forms/test/suite.json @@ -2,5 +2,6 @@ "MCTControl", "MCTForm", "controllers/CompositeController", - "controllers/DateTimeController" + "controllers/DateTimeController", + "controllers/FormController" ] \ No newline at end of file