@darkobits/formation
Version:
790 lines (667 loc) • 23.5 kB
JavaScript
'use strict';
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.END_SUBMIT_EVENT = exports.BEGIN_SUBMIT_EVENT = exports.NG_FORM_CONTROLLER = undefined;
exports.FormController = FormController;
var _angular = require('angular');
var _angular2 = _interopRequireDefault(_angular);
var _ramda = require('ramda');
var _config = require('../../etc/config');
var _FormGroup = require('../FormGroup/FormGroup');
var _FormationControl = require('../../classes/FormationControl');
var _MockControl = require('../../classes/MockControl');
var _utils = require('../../etc/utils');
var _constants = require('../../etc/constants');
var _interfaces = require('../../etc/interfaces');
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function _asyncToGenerator(fn) { return function () { var gen = fn.apply(this, arguments); return new Promise(function (resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; } // -----------------------------------------------------------------------------
// ----- Form Component --------------------------------------------------------
// -----------------------------------------------------------------------------
/**
* Key at which the Formation form controller will store a reference to the
* Angular form controller
*
* @private
*
* @type {string}
*/
var NG_FORM_CONTROLLER = exports.NG_FORM_CONTROLLER = '$ngFormController';
/**
* Event name used to signal to child forms that a submit has begun.
*
* @type {string}
*/
var BEGIN_SUBMIT_EVENT = exports.BEGIN_SUBMIT_EVENT = '$fmInitiateSubmit';
/**
* Event name used to signal to child forms that a submit has ended.
*
* @type {string}
*/
var END_SUBMIT_EVENT = exports.END_SUBMIT_EVENT = '$fmTerminateSubmit';
/**
* Curried assertType.
*
* Remaining arguments:
*
* @param {string} label
* @param {any} value
*
* @return {boolean}
*/
var assertIsObjectOrNil = (0, _utils.assertType)('Form', [Object, undefined]);
/**
* The controller for Formation `<fm></fm>` components.
*
* This component has the following bindings:
*
* - `name`: Name of the form, and scope value to assign controller reference
* to. (Ex: `vm.myForm`)
* - `controls`: Object containing control names and configuration. (Ex:
* `vm.controlConfig`)
* - `on-submit`: Function to invoke when the form is submitted. (Ex:
* `vm.onSubmit`)
* - `ng-disabled`: Expression to evaluate that, if truthy, will disable all
* Formation controls in the form.
*/
function FormController($attrs, $compile, $element, $log, $parse, $scope, $transclude) {
var _this = this;
var Form = this;
/**
* Counter for getNextId(). This is used to assign unique IDs to controls
* within the form.
*
* @private
*
* @type {number}
*/
var counter = -1;
/**
* Configured error behavior for the form.
*
* @private
*
* @type {array}
*/
var errorBehavior = [];
/**
* Control configuration data for this form and possible child forms.
*
* @private
*
* @type {object}
*/
var controlConfiguration = {};
/**
* Tracks registered controls and child forms.
*
* @private
*
* @type {array}
*/
var registry = [];
/**
* Tracks model values for each control.
*
* @private
*
* @type {Object}
*/
var modelValues = new Map();
// ----- Private Methods -----------------------------------------------------
/**
* Curried applyToCollection using our local registry and generating entries
* using each member's 'name' property.
*
* Remaining arguments:
*
* @param {string} methodName - Method name to invoke on each member.
* @param {object|array} [data] - Optional data to disperse to members.
*/
var applyToRegistry = (0, _ramda.partial)(_utils.applyToCollection, [registry, (0, _ramda.prop)('name')]);
/**
* Curried assignToScope that will assign the form controller instance to the
* provided expression in the controller's parent scope.
*
* Remaining arguments:
*
* @param {string} expression - Expression to assign to.
*/
var assignName = (0, _utils.assignToScope)($parse)($scope.$parent)(Form);
/**
* Returns the next available ID.
*
* @private
*
* @return {number}
*/
function getNextId() {
return ++counter;
}
/**
* Returns a promise that resolves when the Angular form controller's
* "$pending" flag becomes false.
*
* @private
*
* @return {promise}
*/
function waitForAsyncValidators() {
return new Promise(function (resolve) {
var watchExpression = 'Form.' + NG_FORM_CONTROLLER + '.$pending';
var cancelWatcher = $scope.$watch(watchExpression, function (isPending) {
if (!isPending) {
cancelWatcher();
resolve();
}
});
});
}
/**
* Sets related form attributes to the correct state for submitting.
*
* @private
*/
function initiateSubmit() {
Form[_interfaces.ClearCustomErrorMessage]();
Form[NG_FORM_CONTROLLER].$setSubmitted(true);
Form.$submitting = true;
Form.disable();
// Note: This could be replaced with an interface.
$scope.$parent.$broadcast(BEGIN_SUBMIT_EVENT);
}
/**
* Returns the form to an editable state when a submit process is complete.
*
* @private
*/
function terminateSubmit() {
// Form.$debug('Broadcasting END_SUBMIT on $scope:', $scope.$parent);
Form.$submitting = false;
Form.enable();
$scope.$parent.$broadcast(END_SUBMIT_EVENT);
}
// ----- Interfaces ----------------------------------------------------------
/**
* Implement a callback that decorated form/ngForm directives will use to
* register with this controller.
*
* @private
*
* @param {object} ngFormController - Form/ngForm controller instance.
*/
_interfaces.RegisterNgForm.implementedBy(Form).as(function (ngFormController) {
if (Form[NG_FORM_CONTROLLER]) {
(0, _utils.throwError)('ngForm already registered with Formation.');
}
Form[NG_FORM_CONTROLLER] = ngFormController;
// Expose common Angular form controller properties.
(0, _ramda.forEach)(function (prop) {
Reflect.defineProperty(Form, prop, {
get: function get() {
return Form[NG_FORM_CONTROLLER][prop];
}
});
}, ['$dirty', '$invalid', '$pending', '$pristine', '$submitted', '$valid']);
});
/**
* Adds the provided child form to the registry and applies model values and
* configuration.
*
* @private
*
* @param {object} childForm
*/
_interfaces.RegisterForm.implementedBy(Form).as(function (childForm) {
var childFormName = childForm.name;
// Ensure there is not another registered child form with the same name as
// the form being registered.
if (Form.getForm(childFormName)) {
(0, _utils.throwError)('Cannot register child form "' + childFormName + '"; another child form with this name already exists.');
}
// Ensure there is not a registered control with the same name as the form
// being registered.
if (Form.getControl(childFormName)) {
(0, _utils.throwError)('Cannot register child form "' + childFormName + '"; a control with this name already exists.');
}
Form.$debug('Registering child form "' + childFormName + '".');
registry.push(childForm);
// Configure the child form/form group.
(0, _utils.invoke)(_interfaces.Configure, childForm, controlConfiguration[childFormName]);
});
/**
* Adds the provided control to the registry and configures it.
*
* @private
*
* @param {object} control
*/
_interfaces.RegisterControl.implementedBy(Form).as(function (control) {
var controlName = control.name || 'control';
// Ensure there is not a registered child form with the same name as the
// control being registered.
if (Form.getForm(controlName)) {
(0, _utils.throwError)('Cannot register control "' + controlName + '"; a child form with this name already exists.');
}
Form.$debug('Registering control "' + controlName + '".');
// Controls need unique IDs, as radio buttons will share the same name.
control.$uid = controlName + '-' + getNextId();
registry.push(control);
// Configure the control.
(0, _utils.invoke)(_interfaces.Configure, control, controlConfiguration[controlName]);
});
/**
* Implement a callback that decorated ngModel directives will use to register
* with this controller. This is used primarily to support instances of
* ngModel used in a Formation form without a Formation control.
*
* @private
*
* @param {object} ngModelCtrl
*/
_interfaces.RegisterNgModel.implementedBy(Form).as(function (ngModelCtrl) {
Form[_interfaces.RegisterControl](new _MockControl.MockControl(ngModelCtrl, Form, $scope));
});
/**
* Updates the form's configuration data and (re)configures each registered
* control, child form, or child form group.
*/
_interfaces.Configure.implementedBy(Form).as(function (config) {
assertIsObjectOrNil('configuration', config);
// Update our local configuration object so that controls can pull from it
// as they come online.
controlConfiguration = (0, _utils.mergeDeep)(controlConfiguration, config);
// Delegate to each existing member's Configure method.
applyToRegistry(_interfaces.Configure, controlConfiguration);
});
/**
* Returns the form's aggregate model values by delegating to the
* GetModelValue method of each control, child form, or child form group.
*
* @return {object}
*/
_interfaces.GetModelValue.implementedBy(Form).as(function () {
return (0, _ramda.fromPairs)(applyToRegistry(_interfaces.GetModelValue));
});
/**
* Sets the the model value(s) for each registered control, child form, or
* child form group.
*
* @param {object} newValues - Values to set.
*/
_interfaces.SetModelValue.implementedBy(Form).as(function (newValues) {
assertIsObjectOrNil('model values', newValues);
// Delegate to each member's SetModelValue method.
applyToRegistry(_interfaces.SetModelValue, newValues);
});
/**
* Applies "$custom" errors returned from the consumer's submit handler.
* Expects a mapping of field names to error messages or child forms.
*
* @private
*
* @param {object} errorMessages
*/
_interfaces.SetCustomErrorMessage.implementedBy(Form).as(function (errorMessages) {
assertIsObjectOrNil('error messages', errorMessages);
// Delegate to each member's SetCustomErrorMessage method.
applyToRegistry(_interfaces.SetCustomErrorMessage, errorMessages);
});
/**
* Clear custom error messages on all registered controls, child forms, and
* child form groups that also implement ClearCustomErrorMessage.
*
* @private
*/
_interfaces.ClearCustomErrorMessage.implementedBy(Form).as(function () {
applyToRegistry(_interfaces.ClearCustomErrorMessage);
});
/**
* Resets each control and the form to a pristine state. Optionally resets the
* model value of each control to the provided value, and validates all
* controls.
*
* @param {object} [modelValues]
*/
_interfaces.Reset.implementedBy(Form).as(function (modelValues) {
assertIsObjectOrNil('model values', modelValues);
Form[NG_FORM_CONTROLLER].$setPristine();
// Delegate to each member's Reset method, passing related model value data.
applyToRegistry(_interfaces.Reset, modelValues);
});
// ----- Angular Lifecycle Hooks ---------------------------------------------
/**
* Determines whether to use a form or ngForm element based on whether this
* instance has a parent form or not.
*
* @private
*/
Form.$postLink = function () {
function transclude(template) {
var elementName = _angular2.default.element(template)[0].tagName;
// Compile our template using our isolate scope and append it to our element.
$compile(template)($scope, function (compiledElement) {
$element.append(compiledElement);
});
// Handle transcluded content from the user by appending it to the above
// form/ngForm template and using a new scope that inherits from our outer
// scope, mimicing the default Angular behavior.
$transclude($scope.$parent.$new(), function (compiledElement, scope) {
// Assign a reference to the form controller in the transclusion scope.
// This allows users to reference the Form API from templates:
// <div ng-if="$fm.getControl('foo').$valid"></div>
scope.$fm = Form;
$element.find(elementName).append(compiledElement);
});
}
if (Form.$parentForm) {
transclude('\n <ng-form></ng-form>\n ');
} else {
transclude('\n <form novalidate\n ng-submit="Form.$submit()"\n ng-model-options="{getterSetter: true}">\n </form>\n ');
}
};
/**
* Set up form name and assign controller instance to its name attribute.
*
* @private
*/
Form.$onInit = function () {
var parent = (0, _utils.greaterScopeId)(Form.$parentForm, Form.$parentFormGroup);
// Auto-generate name if one was not supplied.
Form.name = Form.name || 'Form-' + (0, _config.$getNextId)();
// Merge configuration data from the "config" attribute into our local copy.
controlConfiguration = (0, _utils.mergeDeep)(controlConfiguration, Form.$controlConfiguration);
// Set debug mode if the "debug" attribute is present.
if (Reflect.has($attrs, 'debug')) {
Form.$debugging = true;
}
if (parent) {
// If we are a child form, register with our parent form and set up submit
// listeners.
parent[_interfaces.RegisterForm](Form);
$scope.$on(BEGIN_SUBMIT_EVENT, function () {
if (!Form.$submitting) {
initiateSubmit();
}
});
$scope.$on(END_SUBMIT_EVENT, function () {
if (Form.$submitting) {
terminateSubmit();
}
});
} else {
// If we are the top-level form, assign to parent scope expression.
assignName(Form.name);
}
// Parse error behavior.
errorBehavior = (0, _utils.parseFlags)(Form.$showErrorsOn || (0, _config.$getShowErrorsOnStr)());
};
/**
* Handle changes to bindings.
*
* Note: This will only report reassignment to bindings, it will not
* deep-watch bound objects.
*
* @private
*
* @param {object} changes
*/
Form.$onChanges = function (changes) {
// Handle changes to name.
if (changes.name && !changes.name.isFirstChange()) {
var _changes$name = changes.name,
currentValue = _changes$name.currentValue,
previousValue = _changes$name.previousValue;
Form.$debug('Name changed from "' + previousValue + '" to "' + currentValue + '".');
assignName(currentValue);
}
if (changes.$showErrorsOn && !changes.$showErrorsOn.isFirstChange()) {
var _currentValue = changes.$showErrorsOn.currentValue;
errorBehavior = (0, _utils.parseFlags)(_currentValue || (0, _config.$getShowErrorsOnStr)());
}
};
/**
* Handles form tear-down and cleanup.
*
* @private
*/
Form.$onDestroy = function () {
if (Form.$parentForm) {
Form.$parentForm.$unregisterForm(Form);
}
};
// ----- Semi-Public Methods -------------------------------------------------
/**
* Passes provided arguments to $log.log if the "debug" attribute is
* present on the form element.
*
* @private
*
* @param {...arglist} args
*/
Form.$debug = function () {
for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
if (Form.$debugging) {
$log.log.apply($log, ['[' + Form.name + ']'].concat(args));
}
};
/**
* Returns the form's $scope. Used to compare scope IDs for child form
* registration, and passed to configurable validators.
*
* @return {object}
*/
Form.$getScope = function () {
return $scope;
};
/**
* Returns a copy of the model value for the named control.
*
* @private
*
* @param {string} controlName
* @return {*}
*/
Form.$getModelValue = function (controlName) {
return (0, _ramda.clone)(modelValues.get(controlName));
};
/**
* Sets the model value for the named control to a copy of the provided value.
*
* @private
*
* @param {string} controlName
* @param {*} newValue
*/
Form.$setModelValue = function (controlName, newValue) {
// Any time we programatically update the model values map, we need to
// trigger a digest cycle so that controls' ngModel getter/setters will pull
// the new values.
$scope.$applyAsync(function () {
modelValues.set(controlName, (0, _ramda.clone)(newValue));
});
};
/**
* Removes the provided control from the registry.
*
* @private
*
* @param {object} control
*/
Form.$unregisterControl = function (control) {
Form.$debug('Unregistering control "' + control.name + '".');
registry = (0, _ramda.reject)((0, _ramda.propEq)('$uid', control.$uid), registry);
};
/**
* Removes the provided control from the registry.
*
* @private
*
* @param {object} control
*/
Form.$unregisterForm = function (childForm) {
Form.$debug('Unregistering child form "' + childForm.name + '".');
registry = (0, _ramda.reject)((0, _ramda.propEq)('name', childForm.name), registry);
};
/**
* Handles form submission.
*
* Once all validators have finished, clears all custom errors and then
* checks the form's validity. If valid, calls the consumer's submit handler
* passing an object representing each control's current model value.
*
* If the consumer returns an object (typically from a `.catch()`) it will be
* assumed to be a map of control names and error messages, which will be
* applied to each control in the map.
*
* @private
*/
Form.$submit = _asyncToGenerator(regeneratorRuntime.mark(function _callee() {
var customErrors;
return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
_context.prev = 0;
if (!Form.$submitting) {
_context.next = 4;
break;
}
Form.$debug('Submit already in progress.');
throw new Error('SUBMIT_IN_PROGRESS');
case 4:
_context.next = 6;
return waitForAsyncValidators();
case 6:
// [3] Prepare form and child forms for submit.
initiateSubmit();
// [4] If the form is (still) invalid, bail.
if (!Form[NG_FORM_CONTROLLER].$invalid) {
_context.next = 9;
break;
}
throw new Error('NG_FORM_INVALID');
case 9:
if (!(typeof Form.$onSubmit === 'function')) {
_context.next = 14;
break;
}
_context.next = 12;
return Promise.resolve(Form.$onSubmit(Form.getModelValues()));
case 12:
customErrors = _context.sent;
Form[_interfaces.SetCustomErrorMessage](customErrors);
case 14:
_context.next = 21;
break;
case 16:
_context.prev = 16;
_context.t0 = _context['catch'](0);
if (!(typeof process !== 'undefined' && (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development'))) {
_context.next = 21;
break;
}
Form.$debug('[Logged During Development Only]', _context.t0.message);
throw _context.t0;
case 21:
_context.prev = 21;
// [6] Restore forms to editable state. $apply is needed here because we're
// in an async function.
$scope.$apply(function () {
terminateSubmit();
});
return _context.finish(21);
case 24:
case 'end':
return _context.stop();
}
}
}, _callee, _this, [[0, 16, 21, 24]]);
}));
/**
* Returns the configured error behavior for the form.
*
* @private
*
* @return {array}
*/
Form.$getErrorBehavior = function () {
return (0, _ramda.clone)(errorBehavior);
};
// ----- Public Methods ------------------------------------------------------
/**
* Returns the first control whose name matches the provided value.
*
* @param {string} controlName
* @return {object} - Control instance.
*/
Form.getControl = function (controlName) {
var control = (0, _ramda.find)((0, _ramda.propEq)('name', controlName), registry);
if ((0, _ramda.is)(_FormationControl.FormationControl, control) || (0, _ramda.is)(_MockControl.MockControl, control)) {
return control;
}
};
/**
* Returns the first child form or form group whose name matches the provided
* name.
*
* @param {string} formName
* @return {object} - Child form instance, if found.
*/
Form.getForm = function (formName) {
var form = (0, _ramda.find)((0, _ramda.propEq)('name', formName), registry);
if ((0, _ramda.is)(FormController, form) || (0, _ramda.is)(_FormGroup.FormGroupController, form)) {
return form;
}
};
/**
* Returns true if the form is disabled.
*
* @return {boolean}
*/
Form.isDisabled = function () {
return Form.$disabled || Form.$ngDisabled || Form.$parentForm && Form.$parentForm.isDisabled();
};
/**
* Disables the form and any controls that implment `isDisabled`.
*/
Form.disable = function () {
Form.$disabled = true;
};
/**
* Enables the form and any controls that implement `isDisabled`.
*
* Note: The form may still remain disabled via `ngDisabled`.
*/
Form.enable = function () {
Form.$disabled = false;
};
// Expose select interfaces to the public API.
Form.configure = Form[_interfaces.Configure];
Form.getModelValues = Form[_interfaces.GetModelValue];
Form.reset = Form[_interfaces.Reset];
Form.setModelValues = Form[_interfaces.SetModelValue];
}
// NOTE: This might be obsolete now that it seems to be possible to use
// circular dependencies.
FormController[_constants.FORM_CONTROLLER] = true;
FormController.$inject = ['$attrs', '$compile', '$element', '$log', '$parse', '$scope', '$transclude'];
(0, _config.$registerComponent)(_constants.FORM_COMPONENT_NAME, {
require: {
$parentForm: '?^^' + _constants.FORM_COMPONENT_NAME,
$parentFormGroup: '?^^' + _constants.FORM_GROUP_COMPONENT_NAME
},
bindings: {
name: '@',
$controlConfiguration: '<controls',
$onSubmit: '<onSubmit',
$showErrorsOn: '@showErrorsOn',
$ngDisabled: '<ngDisabled'
},
transclude: true,
controller: FormController,
controllerAs: 'Form'
});
exports.default = FormController;