UNPKG

angular-formly

Version:

AngularJS directive which takes JSON representing a form and renders to HTML

793 lines (695 loc) 26.7 kB
import angular from 'angular-fix' import apiCheckFactory from 'api-check' export default formlyField /** * @ngdoc directive * @name formlyField * @restrict AE */ // @ngInject function formlyField($http, $q, $compile, $templateCache, $interpolate, formlyConfig, formlyApiCheck, formlyUtil, formlyUsability, formlyWarn) { const {arrayify} = formlyUtil return { restrict: 'AE', transclude: true, require: '?^formlyForm', scope: { options: '=', model: '=', originalModel: '=?', formId: '@', // TODO remove formId in a breaking release index: '=?', fields: '=?', formState: '=?', formOptions: '=?', form: '=?', // TODO require form in a breaking release }, controller: FormlyFieldController, link: fieldLink, } // @ngInject function FormlyFieldController($scope, $timeout, $parse, $controller, formlyValidationMessages) { /* eslint max-statements:[2, 37] */ if ($scope.options.fieldGroup) { setupFieldGroup() return } const fieldType = getFieldType($scope.options) simplifyLife($scope.options) mergeFieldOptionsWithTypeDefaults($scope.options, fieldType) extendOptionsWithDefaults($scope.options, $scope.index) checkApi($scope.options) // set field id to link labels and fields // initalization setFieldIdAndName() setDefaultValue() setInitialValue() runExpressions() watchExpressions() addValidationMessages($scope.options) invokeControllers($scope, $scope.options, fieldType) // function definitions function runExpressions() { const deferred = $q.defer() // must run on next tick to make sure that the current value is correct. $timeout(function runExpressionsOnNextTick() { const promises = [] const field = $scope.options const currentValue = valueGetterSetter() angular.forEach(field.expressionProperties, function runExpression(expression, prop) { const setter = $parse(prop).assign const promise = $q.when(formlyUtil.formlyEval($scope, expression, currentValue, currentValue)) .then(function setFieldValue(value) { setter(field, value) }) promises.push(promise) }) $q.all(promises).then(function() { deferred.resolve() }) }, 0, false) return deferred.promise } function watchExpressions() { if ($scope.formOptions.watchAllExpressions) { const field = $scope.options const currentValue = valueGetterSetter() angular.forEach(field.expressionProperties, function watchExpression(expression, prop) { const setter = $parse(prop).assign $scope.$watch(function expressionPropertyWatcher() { return formlyUtil.formlyEval($scope, expression, currentValue, currentValue) }, function expressionPropertyListener(value) { setter(field, value) }, true) }) } } function valueGetterSetter(newVal) { if (!$scope.model || !$scope.options.key) { return undefined } if (angular.isDefined(newVal)) { parseSet($scope.options.key, $scope.model, newVal) } return parseGet($scope.options.key, $scope.model) } function shouldNotUseParseKey(key) { return angular.isNumber(key) || !formlyUtil.containsSelector(key) } function keyContainsArrays(key) { return /\[\d{1,}\]/.test(key) } function deepAssign(obj, prop, value) { if (angular.isString(prop)) { prop = prop.replace(/\[(\w+)\]/g, '.$1').split('.') } if (prop.length > 1) { const e = prop.shift() obj[e] = obj[e] || (isNaN(prop[0])) ? {} : [] deepAssign(obj[e], prop, value) } else { obj[prop[0]] = value } } function parseSet(key, model, newVal) { // If either of these are null/undefined then just return undefined if ((!key && key !== 0) || !model) { return } // If we are working with a number then $parse wont work, default back to the old way for now if (shouldNotUseParseKey(key)) { // TODO: Fix this so we can get several levels instead of just one with properties that are numeric model[key] = newVal } else if (formlyConfig.extras.parseKeyArrays && keyContainsArrays(key)) { deepAssign($scope.model, key, newVal) } else { const setter = $parse($scope.options.key).assign if (setter) { setter($scope.model, newVal) } } } function parseGet(key, model) { // If either of these are null/undefined then just return undefined if ((!key && key !== 0) || !model) { return undefined } // If we are working with a number then $parse wont work, default back to the old way for now if (shouldNotUseParseKey(key)) { // TODO: Fix this so we can get several levels instead of just one with properties that are numeric return model[key] } else { return $parse(key)(model) } } function simplifyLife(options) { // add a few empty objects (if they don't already exist) so you don't have to undefined check everywhere formlyUtil.reverseDeepMerge(options, { originalModel: options.model, extras: {}, data: {}, templateOptions: {}, validation: {}, }) // create $scope.to so template authors can reference to instead of $scope.options.templateOptions $scope.to = $scope.options.templateOptions $scope.formOptions = $scope.formOptions || {} } function setFieldIdAndName() { if (angular.isFunction(formlyConfig.extras.getFieldId)) { $scope.id = formlyConfig.extras.getFieldId($scope.options, $scope.model, $scope) } else { const formName = ($scope.form && $scope.form.$name) || $scope.formId $scope.id = formlyUtil.getFieldId(formName, $scope.options, $scope.index) } $scope.options.id = $scope.id $scope.name = $scope.options.name || $scope.options.id $scope.options.name = $scope.name } function setDefaultValue() { if (angular.isDefined($scope.options.defaultValue) && !angular.isDefined(parseGet($scope.options.key, $scope.model))) { parseSet($scope.options.key, $scope.model, $scope.options.defaultValue) } } function setInitialValue() { $scope.options.initialValue = $scope.model && parseGet($scope.options.key, $scope.model) } function mergeFieldOptionsWithTypeDefaults(options, type) { if (type) { mergeOptions(options, type.defaultOptions) } const properOrder = arrayify(options.optionsTypes).reverse() // so the right things are overridden angular.forEach(properOrder, typeName => { mergeOptions(options, formlyConfig.getType(typeName, true, options).defaultOptions) }) } function mergeOptions(options, extraOptions) { if (extraOptions) { if (angular.isFunction(extraOptions)) { extraOptions = extraOptions(options, $scope) } formlyUtil.reverseDeepMerge(options, extraOptions) } } function extendOptionsWithDefaults(options, index) { const key = options.key || index || 0 angular.extend(options, { // attach the key in case the formly-field directive is used directly key, value: options.value || valueGetterSetter, runExpressions, resetModel, updateInitialValue, }) } function resetModel() { parseSet($scope.options.key, $scope.model, $scope.options.initialValue) if ($scope.options.formControl) { if (angular.isArray($scope.options.formControl)) { angular.forEach($scope.options.formControl, function(formControl) { resetFormControl(formControl, true) }) } else { resetFormControl($scope.options.formControl) } } if ($scope.form) { $scope.form.$setUntouched && $scope.form.$setUntouched() $scope.form.$setPristine() } } function resetFormControl(formControl, isMultiNgModel) { if (!isMultiNgModel) { formControl.$setViewValue(parseGet($scope.options.key, $scope.model)) } formControl.$render() formControl.$setUntouched && formControl.$setUntouched() formControl.$setPristine() // To prevent breaking change requiring a digest to reset $viewModel if (!$scope.$root.$$phase) { $scope.$digest() } } function updateInitialValue() { $scope.options.initialValue = parseGet($scope.options.key, $scope.model) } function addValidationMessages(options) { options.validation.messages = options.validation.messages || {} angular.forEach(formlyValidationMessages.messages, function createFunctionForMessage(expression, name) { if (!options.validation.messages[name]) { options.validation.messages[name] = function evaluateMessage(viewValue, modelValue, scope) { return formlyUtil.formlyEval(scope, expression, modelValue, viewValue) } } }) } function invokeControllers(scope, options = {}, type = {}) { angular.forEach([type.controller, options.controller], controller => { if (controller) { $controller(controller, {$scope: scope}) } }) } function setupFieldGroup() { $scope.options.options = $scope.options.options || {} $scope.options.options.formState = $scope.formState $scope.to = $scope.options.templateOptions } } // link function function fieldLink(scope, el, attrs, formlyFormCtrl) { if (scope.options.fieldGroup) { setFieldGroupTemplate() return } // watch the field model (if exists) if there is no parent formly-form directive (that would watch it instead) if (!formlyFormCtrl && scope.options.model) { scope.$watch('options.model', () => scope.options.runExpressions(), true) } addAttributes() addClasses() const type = getFieldType(scope.options) const args = arguments const thusly = this let fieldCount = 0 const fieldManipulators = getManipulators(scope.options, scope.formOptions) getFieldTemplate(scope.options) .then(runManipulators(fieldManipulators.preWrapper)) .then(transcludeInWrappers(scope.options, scope.formOptions)) .then(runManipulators(fieldManipulators.postWrapper)) .then(setElementTemplate) .then(watchFormControl) .then(callLinkFunctions) .catch(error => { formlyWarn( 'there-was-a-problem-setting-the-template-for-this-field', 'There was a problem setting the template for this field ', scope.options, error ) }) function setFieldGroupTemplate() { checkFieldGroupApi(scope.options) el.addClass('formly-field-group') let extraAttributes = '' if (scope.options.elementAttributes) { extraAttributes = Object.keys(scope.options.elementAttributes).map(key => { return `${key}="${scope.options.elementAttributes[key]}"` }).join(' ') } let modelValue = 'model' scope.options.form = scope.form if (scope.options.key) { modelValue = `model['${scope.options.key}']` } getTemplate(` <formly-form model="${modelValue}" fields="options.fieldGroup" options="options.options" form="options.form" class="${scope.options.className}" ${extraAttributes} is-field-group> </formly-form> `) .then(transcludeInWrappers(scope.options, scope.formOptions)) .then(setElementTemplate) } function addAttributes() { if (scope.options.elementAttributes) { el.attr(scope.options.elementAttributes) } } function addClasses() { if (scope.options.className) { el.addClass(scope.options.className) } if (scope.options.type) { el.addClass(`formly-field-${scope.options.type}`) } } function setElementTemplate(templateString) { el.html(asHtml(templateString)) $compile(el.contents())(scope) return templateString } function watchFormControl(templateString) { let stopWatchingShowError = angular.noop if (scope.options.noFormControl) { return } const templateEl = angular.element(`<div>${templateString}</div>`) const ngModelNodes = templateEl[0].querySelectorAll('[ng-model],[data-ng-model]') if (ngModelNodes.length) { angular.forEach(ngModelNodes, function(ngModelNode) { fieldCount++ watchFieldNameOrExistence(ngModelNode.getAttribute('name')) }) } function watchFieldNameOrExistence(name) { const nameExpressionRegex = /\{\{(.*?)}}/ const nameExpression = nameExpressionRegex.exec(name) if (nameExpression) { name = $interpolate(name)(scope) } watchFieldExistence(name) } function watchFieldExistence(name) { scope.$watch(`form["${name}"]`, function formControlChange(formControl) { if (formControl) { if (fieldCount > 1) { if (!scope.options.formControl) { scope.options.formControl = [] } scope.options.formControl.push(formControl) } else { scope.options.formControl = formControl } scope.fc = scope.options.formControl // shortcut for template authors stopWatchingShowError() addShowMessagesWatcher() addParsers() addFormatters() } }) } function addShowMessagesWatcher() { stopWatchingShowError = scope.$watch(function watchShowValidationChange() { const customExpression = formlyConfig.extras.errorExistsAndShouldBeVisibleExpression const options = scope.options const formControls = arrayify(scope.fc) if (!formControls.some(fc => fc.$invalid)) { return false } else if (typeof options.validation.show === 'boolean') { return options.validation.show } else if (customExpression) { return formControls.some(fc => formlyUtil.formlyEval(scope, customExpression, fc.$modelValue, fc.$viewValue)) } else { return formControls.some(fc => { const noTouchedButDirty = (angular.isUndefined(fc.$touched) && fc.$dirty) return (fc.$touched || noTouchedButDirty) }) } }, function onShowValidationChange(show) { scope.options.validation.errorExistsAndShouldBeVisible = show scope.showError = show // shortcut for template authors }) } function addParsers() { setParsersOrFormatters('parsers') } function addFormatters() { setParsersOrFormatters('formatters') const ctrl = scope.fc const formWasPristine = scope.form.$pristine if (scope.options.formatters) { let value = ctrl.$modelValue ctrl.$formatters.forEach((formatter) => { value = formatter(value) }) ctrl.$setViewValue(value) ctrl.$render() ctrl.$setPristine() if (formWasPristine) { scope.form.$setPristine() } } } function setParsersOrFormatters(which) { let originalThingProp = 'originalParser' if (which === 'formatters') { originalThingProp = 'originalFormatter' } // init with type's parsers let things = getThingsFromType(type) // get optionsTypes things things = formlyUtil.extendArray(things, getThingsFromOptionsTypes(scope.options.optionsTypes)) // get field's things things = formlyUtil.extendArray(things, scope.options[which]) // convert things into formlyExpression things angular.forEach(things, (thing, index) => { things[index] = getFormlyExpressionThing(thing) }) let ngModelCtrls = scope.fc if (!angular.isArray(ngModelCtrls)) { ngModelCtrls = [ngModelCtrls] } angular.forEach(ngModelCtrls, ngModelCtrl => { ngModelCtrl['$' + which] = ngModelCtrl['$' + which].concat(...things) }) function getThingsFromType(theType) { if (!theType) { return [] } if (angular.isString(theType)) { theType = formlyConfig.getType(theType, true, scope.options) } let typeThings = [] // get things from parent if (theType.extends) { typeThings = formlyUtil.extendArray(typeThings, getThingsFromType(theType.extends)) } // get own type's things typeThings = formlyUtil.extendArray(typeThings, getDefaultOptionsProperty(theType, which, [])) // get things from optionsTypes typeThings = formlyUtil.extendArray( typeThings, getThingsFromOptionsTypes(getDefaultOptionsOptionsTypes(theType)) ) return typeThings } function getThingsFromOptionsTypes(optionsTypes = []) { let optionsTypesThings = [] angular.forEach(angular.copy(arrayify(optionsTypes)).reverse(), optionsTypeName => { optionsTypesThings = formlyUtil.extendArray(optionsTypesThings, getThingsFromType(optionsTypeName)) }) return optionsTypesThings } function getFormlyExpressionThing(thing) { formlyExpressionParserOrFormatterFunction[originalThingProp] = thing return formlyExpressionParserOrFormatterFunction function formlyExpressionParserOrFormatterFunction($viewValue) { const $modelValue = scope.options.value() return formlyUtil.formlyEval(scope, thing, $modelValue, $viewValue) } } } } function callLinkFunctions() { if (type && type.link) { type.link.apply(thusly, args) } if (scope.options.link) { scope.options.link.apply(thusly, args) } } function runManipulators(manipulators) { return function runManipulatorsOnTemplate(templateToManipulate) { let chain = $q.when(templateToManipulate) angular.forEach(manipulators, manipulator => { chain = chain.then(template => { return $q.when(manipulator(template, scope.options, scope)).then(newTemplate => { return angular.isString(newTemplate) ? newTemplate : asHtml(newTemplate) }) }) }) return chain } } } // sort-of stateless util functions function asHtml(el) { const wrapper = angular.element('<a></a>') return wrapper.append(el).html() } function getFieldType(options) { return options.type && formlyConfig.getType(options.type) } function getManipulators(options, formOptions) { let preWrapper = [] let postWrapper = [] addManipulators(options.templateManipulators) addManipulators(formOptions.templateManipulators) addManipulators(formlyConfig.templateManipulators) return {preWrapper, postWrapper} function addManipulators(manipulators) { /* eslint-disable */ // it doesn't understand this :-( const {preWrapper:pre = [], postWrapper:post = []} = (manipulators || {}); preWrapper = preWrapper.concat(pre); postWrapper = postWrapper.concat(post); /* eslint-enable */ } } function getFieldTemplate(options) { function fromOptionsOrType(key, fieldType) { if (angular.isDefined(options[key])) { return options[key] } else if (fieldType && angular.isDefined(fieldType[key])) { return fieldType[key] } } const type = formlyConfig.getType(options.type, true, options) const template = fromOptionsOrType('template', type) const templateUrl = fromOptionsOrType('templateUrl', type) if (angular.isUndefined(template) && !templateUrl) { throw formlyUsability.getFieldError( 'type-type-has-no-template', `Type '${options.type}' has no template. On element:`, options ) } return getTemplate(templateUrl || template, angular.isUndefined(template), options) } function getTemplate(template, isUrl, options) { let templatePromise if (angular.isFunction(template)) { templatePromise = $q.when(template(options)) } else { templatePromise = $q.when(template) } if (!isUrl) { return templatePromise } else { const httpOptions = {cache: $templateCache} return templatePromise .then((url) => $http.get(url, httpOptions)) .then((response) => response.data) .catch(function handleErrorGettingATemplate(error) { formlyWarn( 'problem-loading-template-for-templateurl', 'Problem loading template for ' + template, error ) }) } } function transcludeInWrappers(options, formOptions) { const wrapper = getWrapperOption(options, formOptions) return function transcludeTemplate(template) { if (!wrapper.length) { return $q.when(template) } wrapper.forEach((aWrapper) => { formlyUsability.checkWrapper(aWrapper, options) runApiCheck(aWrapper, options) }) const promises = wrapper.map(w => getTemplate(w.template || w.templateUrl, !w.template)) return $q.all(promises).then(wrappersTemplates => { wrappersTemplates.forEach((wrapperTemplate, index) => { formlyUsability.checkWrapperTemplate(wrapperTemplate, wrapper[index]) }) wrappersTemplates.reverse() // wrapper 0 is wrapped in wrapper 1 and so on... let totalWrapper = wrappersTemplates.shift() wrappersTemplates.forEach(wrapperTemplate => { totalWrapper = doTransclusion(totalWrapper, wrapperTemplate) }) return doTransclusion(totalWrapper, template) }) } } function doTransclusion(wrapper, template) { const superWrapper = angular.element('<a></a>') // this allows people not have to have a single root in wrappers superWrapper.append(wrapper) let transcludeEl = superWrapper.find('formly-transclude') if (!transcludeEl.length) { // try it using our custom find function transcludeEl = formlyUtil.findByNodeName(superWrapper, 'formly-transclude') } transcludeEl.replaceWith(template) return superWrapper.html() } function getWrapperOption(options, formOptions) { /* eslint complexity:[2, 6] */ let wrapper = options.wrapper // explicit null means no wrapper if (wrapper === null) { return [] } // nothing specified means use the default wrapper for the type if (!wrapper) { // get all wrappers that specify they apply to this type wrapper = arrayify(formlyConfig.getWrapperByType(options.type)) } else { wrapper = arrayify(wrapper).map(formlyConfig.getWrapper) } // get all wrappers for that the type specified that it uses. const type = formlyConfig.getType(options.type, true, options) if (type && type.wrapper) { const typeWrappers = arrayify(type.wrapper).map(formlyConfig.getWrapper) wrapper = wrapper.concat(typeWrappers) } // add form wrappers if (formOptions.wrapper) { const formWrappers = arrayify(formOptions.wrapper).map(formlyConfig.getWrapper) wrapper = wrapper.concat(formWrappers) } // add the default wrapper last const defaultWrapper = formlyConfig.getWrapper() if (defaultWrapper) { wrapper.push(defaultWrapper) } return wrapper } function checkApi(options) { formlyApiCheck.throw(formlyApiCheck.formlyFieldOptions, options, { prefix: 'formly-field directive', url: 'formly-field-directive-validation-failed', }) // validate with the type const type = options.type && formlyConfig.getType(options.type) if (type) { runApiCheck(type, options, true) } if (options.expressionProperties && options.expressionProperties.hide) { formlyWarn( 'dont-use-expressionproperties.hide-use-hideexpression-instead', 'You have specified `hide` in `expressionProperties`. Use `hideExpression` instead', options ) } } function checkFieldGroupApi(options) { formlyApiCheck.throw(formlyApiCheck.fieldGroup, options, { prefix: 'formly-field directive', url: 'formly-field-directive-validation-failed', }) } function runApiCheck({apiCheck, apiCheckInstance, apiCheckFunction, apiCheckOptions}, options, forType) { runApiCheckForType(apiCheck, apiCheckInstance, apiCheckFunction, apiCheckOptions, options) if (forType && options.type) { angular.forEach(formlyConfig.getTypeHeritage(options.type), function(type) { runApiCheckForType(type.apiCheck, type.apiCheckInstance, type.apiCheckFunction, type.apiCheckOptions, options) }) } } function runApiCheckForType(apiCheck, apiCheckInstance, apiCheckFunction, apiCheckOptions, options) { /* eslint complexity:[2, 9] */ if (!apiCheck) { return } const instance = apiCheckInstance || formlyConfig.extras.apiCheckInstance || formlyApiCheck if (instance.config.disabled || apiCheckFactory.globalConfig.disabled) { return } const fn = apiCheckFunction || 'warn' // this is the new API const checkerObjects = apiCheck(instance) angular.forEach(checkerObjects, (shape, name) => { const checker = instance.shape(shape) const checkOptions = angular.extend({ prefix: `formly-field type ${options.type} for property ${name}`, url: formlyApiCheck.config.output.docsBaseUrl + 'formly-field-type-apicheck-failed', }, apiCheckOptions) instance[fn](checker, options[name], checkOptions) }) } } // Stateless util functions function getDefaultOptionsOptionsTypes(type) { return getDefaultOptionsProperty(type, 'optionsTypes', []) } function getDefaultOptionsProperty(type, prop, defaultValue) { return type.defaultOptions && type.defaultOptions[prop] || defaultValue }