UNPKG

@darkobits/formation

Version:
790 lines (667 loc) 23.5 kB
'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;