mm-dynamic-forms
Version:
Customized ngMaterial formly generator
621 lines (571 loc) • 30.1 kB
JavaScript
/**
* mmDynamicForms - Build ngMaterial based Forms in AngularJS From JSON config file
* @version v0.0.6 - 2018-06-20
* @link https://github.com/balaji-b-v/mm-dynamic-forms
* @license MIT, http://opensource.org/licenses/MIT
* Added handling for form-name and conditional div wrapper for button/reset elements
*/
/**
* Dynamically build an Search HTML form using a JSON array/object as a template.
* The form uses ngMaterial based tags in the form.
*
* @todo Properly describe this directive.
* @param {mixed} [template] - The form template itself, as an array or object.
* @param {string} [templateUrl] - The URL to retrieve the form template from; template overrides.
* @param {Object} ngModel - An object in the current scope where the form data should be stored.
* @example <form mm-dynamic-form name="queryTransaction" ng-submit="vm.search()" autocomplete="off" template="vm.searchInfoFields">
*/
angular.module('mm-dynamic-forms', [])
.directive('mmDynamicForm', ['$q', '$parse', '$http', '$templateCache', '$compile', '$document', '$timeout', '$translate', function ($q, $parse, $http, $templateCache, $compile, $document, $timeout, $translate) {
var supported = {
// Text-based elements
'text': {element: 'input', type: 'text', editable: true, textBased: true},
'date': {element: 'md-datepicker', type: 'date', editable: true, textBased: true},
'datetime': {element: 'input', type: 'datetime', editable: true, textBased: true},
'datetime-local': {element: 'input', type: 'datetime-local', editable: true, textBased: true},
'email': {element: 'input', type: 'email', editable: true, textBased: true},
'month': {element: 'input', type: 'month', editable: true, textBased: true},
'number': {element: 'input', type: 'number', editable: true, textBased: true},
'password': {element: 'input', type: 'password', editable: true, textBased: true},
'search': {element: 'input', type: 'search', editable: true, textBased: true},
'tel': {element: 'input', type: 'tel', editable: true, textBased: true},
'textarea': {element: 'textarea', editable: true, textBased: true},
'time': {element: 'input', type: 'time', editable: true, textBased: true},
'url': {element: 'input', type: 'url', editable: true, textBased: true},
'week': {element: 'input', type: 'week', editable: true, textBased: true},
// Specialized editables
'checkbox': {element: 'input', type: 'checkbox', editable: true, textBased: false},
'color': {element: 'input', type: 'color', editable: true, textBased: false},
'file': {element: 'input', type: 'file', editable: true, textBased: false},
'range': {element: 'input', type: 'range', editable: true, textBased: false},
'select': {element: 'md-select', editable: true, textBased: false},
// Pseudo-non-editables (containers)
'checklist': {element: 'div', editable: false, textBased: false},
'fieldset': {element: 'fieldset', editable: false, textBased: false},
'radio': {element: 'div', editable: false, textBased: false},
// Non-editables (mostly buttons)
'button': {element: 'md-button', type: 'button', editable: false, textBased: false},
'hidden': {element: 'input', type: 'hidden', editable: false, textBased: false},
'image': {element: 'input', type: 'image', editable: false, textBased: false},
'legend': {element: 'legend', editable: false, textBased: false},
'reset': {element: 'md-button', type: 'reset', editable: false, textBased: false},
'submit': {element: 'md-button', type: 'submit', editable: false, textBased: false}
};
return {
restrict: 'A', // supports using attribute on the <form> element
scope: true,
link: function ($scope, element, attrs) {
// Basic initialization
var newElement = null,
newElementWrapper = '<md-input-container class="md-block" flex-gt-sm></md-input-container>',
newChild = null,
optGroups = {},
cbAtt = '',
foundOne = false,
iterElem = element,
model = null;
// Check that the required attributes are in place
// dependency on ngModel attr in form element is removed.
// the field models are specified in the template array itself
if ((angular.isDefined(attrs.template) || angular.isDefined(attrs.templateUrl)) && !element.hasClass('mm-dynamic-form')) {
// model = $parse(attrs.ngModel)($scope);
// Grab the template. either from the template attribute, or from the URL in templateUrl
(attrs.template ? $q.when($parse(attrs.template)($scope)) :
$http.get(attrs.templateUrl, {cache: $templateCache}).then(function (result) {
return result.data;
})
).then(function (template) {
var setProperty = function (obj, props, value, lastProp, buildParent) {
props = props.split('.');
lastProp = lastProp || props.pop();
for (var i = 0; i < props.length; i++) {
obj = obj[props[i]] = obj[props[i]] || {};
}
if (!buildParent) {
obj[lastProp] = value;
}
},
bracket = function (model, base) {
// The original implementation modified here to return just the incoming mmodel
// props = model.split('.');
// return (base || props.shift()) + (props.length ? "['" + props.join("']['") + "']" : '');
// console.log((base || props.shift()) + (props.length ? "." + props.join(".") : ""))
// if(base == null){
return model;
// }
// else{
// return (base || props.shift()) + (props.length ? "." + props.join(".") : "");
// }
},
buildFields = function (wrapElement, field, id) {
var rowElem = angular
// var thisObj = this;
if (String(id).charAt(0) == '$') {
// Don't process keys added by Angular... See GitHub Issue #29
return;
}
else if (!angular.isDefined(supported[field.type]) || supported[field.type] === false) {
// Unsupported. Create SPAN with field.label as contents
newElement = angular.element('<span></span>');
if (angular.isDefined(field.label)) {angular.element(newElement).attr('translate', field.label);}
angular.forEach(field, function (val, attr) {
if (["label", "type"].indexOf(attr) > -1) {
return;
}
newElement.attr(attr, val);
});
this.append(newElement);
newElement = null;
}
else {
// Supported. Create element (or container) according to type
var wrapperElem = angular.element(newElementWrapper);
// colect the attributes into field.attributes array
// This code kept for possible future enhancement
// angular.forEach(field, function (val, attr) {
// if (["label", "type", "model"].indexOf(attr) > -1) {
// return;
// }else{
// // populate the field.attributes array
// field.attributes[attr] = val;
// }
// });
if (!angular.isDefined(field.model)) {
field.model = id;
}
newElement = angular.element($document[0].createElement(supported[field.type].element));
if (angular.isDefined(supported[field.type].type)) {
newElement.attr('type', supported[field.type].type);
}
// Editable fields (those that can feed models)
if (angular.isDefined(supported[field.type].editable) && supported[field.type].editable) {
// newElement.attr('name', bracket(field.model));
// console.log(attrs.ngModel);
newElement.attr('ng-model', bracket(field.model, attrs.ngModel));
// Build parent in case of a nested model
setProperty(field.model, field.model, {}, null, true);
if (angular.isDefined(field.readonly)) {newElement.attr('ng-readonly', field.readonly);}
if (angular.isDefined(field.required)) {newElement.attr('ng-required', field.required);}
if (angular.isDefined(field.val)) {
setProperty(field.model, field.model, angular.copy(field.val));
newElement.attr('value', field.val);
}
}
// Fields based on input type=text
if (angular.isDefined(supported[field.type].textBased) && supported[field.type].textBased) {
if (angular.isDefined(field.minLength)) {newElement.attr('ng-minlength', field.minLength);}
if (angular.isDefined(field.maxLength)) {newElement.attr('ng-maxlength', field.maxLength);}
if (angular.isDefined(field.validate)) {newElement.attr('ng-pattern', field.validate);}
if (angular.isDefined(field.placeholder)) {newElement.attr('placeholder', field.placeholder);}
}
// Special cases
if (field.type === 'number' || field.type === 'range') {
if (angular.isDefined(field.minValue)) {newElement.attr('min', field.minValue);}
if (angular.isDefined(field.maxValue)) {newElement.attr('max', field.maxValue);}
if (angular.isDefined(field.step)) {newElement.attr('step', field.step);}
}
else if (['text', 'textarea'].indexOf(field.type) > -1) {
if (angular.isDefined(field.splitBy)) {newElement.attr('ng-list', field.splitBy);}
}
else if (field.type === 'checkbox') {
if (angular.isDefined(field.isOn)) {newElement.attr('ng-true-value', field.isOn);}
if (angular.isDefined(field.isOff)) {newElement.attr('ng-false-value', field.isOff);}
if (angular.isDefined(field.slaveTo)) {newElement.attr('ng-checked', field.slaveTo);}
}
else if (field.type === 'checklist') {
// TODO make this replacement to use ng-material based HTML tags
if (angular.isDefined(field.val)) {
setProperty(field.model, field.model, angular.copy(field.val));
}
if (angular.isDefined(field.options)) {
if (! (angular.isDefined(model[field.model]) && angular.isObject(model[field.model]))) {
setProperty(field.model, field.model, {});
}
angular.forEach(field.options, function (option, childId) {
newChild = angular.element('<input type="checkbox" />');
newChild.attr('name', bracket(field.model + '.' + childId));
newChild.attr('ng-model', bracket(field.model + "." + childId, attrs.ngModel));
if (angular.isDefined(option['class'])) {newChild.attr('ng-class', option['class']);}
if (angular.isDefined(field.disabled)) {newChild.attr('ng-disabled', field.disabled);}
if (angular.isDefined(field.readonly)) {newChild.attr('ng-readonly', field.readonly);}
if (angular.isDefined(field.required)) {newChild.attr('ng-required', field.required);}
if (angular.isDefined(field.callback)) {newChild.attr('ng-change', field.callback);}
if (angular.isDefined(option.isOn)) {newChild.attr('ng-true-value', option.isOn);}
if (angular.isDefined(option.isOff)) {newChild.attr('ng-false-value', option.isOff);}
if (angular.isDefined(option.slaveTo)) {newChild.attr('ng-checked', option.slaveTo);}
if (angular.isDefined(option.val)) {
setProperty(field.model, field.model, angular.copy(option.val), childId);
newChild.attr('value', option.val);
}
if (angular.isDefined(option.label)) {
newChild = newChild.wrap('<label></label>').parent();
newChild.append(document.createTextNode(' ' + option.label));
}
newElement.append(newChild);
});
}
}
else if (field.type === 'radio') {
// TODO make this replacement to use ng-material based HTML tags
if (angular.isDefined(field.val)) {
setProperty(field.model, field.model, angular.copy(field.val));
}
if (angular.isDefined(field.values)) {
angular.forEach(field.values, function (label, val) {
newChild = angular.element('<input type="radio" />');
newChild.attr('name', bracket(field.model));
newChild.attr('ng-model', bracket(field.model, attrs.ngModel));
if (angular.isDefined(field['class'])) {newChild.attr('ng-class', field['class']);}
if (angular.isDefined(field.disabled)) {newChild.attr('ng-disabled', field.disabled);}
if (angular.isDefined(field.callback)) {newChild.attr('ng-change', field.callback);}
if (angular.isDefined(field.readonly)) {newChild.attr('ng-readonly', field.readonly);}
if (angular.isDefined(field.required)) {newChild.attr('ng-required', field.required);}
newChild.attr('value', val);
if (angular.isDefined(field.val) && field.val === val) {newChild.attr('checked', 'checked');}
if (label) {
newChild = newChild.wrap('<label></label>').parent();
newChild.append(document.createTextNode(' ' + label));
}
newElement.append(newChild);
});
}
}
else if (field.type === 'select') {
// Handled to use ngMaterial based HTML tags
if (angular.isDefined(field.multiple) && field.multiple !== false) {newElement.attr('multiple', 'multiple');}
if (angular.isDefined(field.empty) && field.empty !== false) {newElement.append(angular.element($document[0].createElement('md-option')).attr('value', '').html($translate.instant(field.empty)));}
if (angular.isDefined(field.autoOptions)) {
newElement.attr('ng-options', field.autoOptions);
}
else if (angular.isDefined(field.options)) {
// This doesnt handle the optSubgroups
newChild = angular.element($document[0].createElement('md-option')).html(field.options.displayText);
newChild.attr('ng-repeat', field.options.ngRepeat);
newChild.attr('value', field.options.ngValue);
newElement.append(newChild);
// code for handling the optSubgroups
/*
angular.forEach(field.options, function (option, childId) {
newChild = angular.element($document[0].createElement('md-option')).html("{{type}}");
newChild.attr('ng-repeat', "type in " + field.options);
newChild.attr('ng-value', 'type');
if (angular.isDefined(option.disabled)) {newChild.attr('ng-disabled', option.disabled);}
if (angular.isDefined(option.slaveTo)) {newChild.attr('ng-selected', option.slaveTo);}
if (angular.isDefined(option.label)) {newChild.html($translate.instant(option.label));}
if (angular.isDefined(option.group)) {
if (!angular.isDefined(optGroups[option.group])) {
optGroups[option.group] = angular.element($document[0].createElement('optgroup'));
optGroups[option.group].attr('label', $translate.instant(option.group));
}
optGroups[option.group].append(newChild);
}
else {
newElement.append(newChild);
}
});
if (!angular.equals(optGroups, {})) {
angular.forEach(optGroups, function (optGroup) {
newElement.append(optGroup);
});
optGroups = {};
}*/
}
}
else if (field.type === 'image') {
if (angular.isDefined(field.label)) {newElement.attr('alt', field.label);}
if (angular.isDefined(field.source)) {newElement.attr('src', field.source);}
}
else if (field.type === 'hidden') {
newElement.attr('name', bracket(field.model));
newElement.attr('ng-model', bracket(field.model, attrs.ngModel));
if (angular.isDefined(field.val)) {
setProperty(field.model, field.model, angular.copy(field.val));
newElement.attr('value', field.val);
}
}
else if (field.type === 'file') {
if (angular.isDefined(field.multiple)) {
newElement.attr('multiple', field.multiple);
}
}
else if (field.type === 'fieldset') {
if (angular.isDefined(field.fields)) {
var workingElement = newElement;
angular.forEach(field.fields, buildFields, newElement);
newElement = workingElement;
}
}else{
angular.noop();
}
// Common attributes; radio already applied these...
if (field.type !== "radio") {
if (angular.isDefined(field['class'])) {newElement.attr('ng-class', field['class']);}
// ...and checklist has already applied these.
if (field.type !== "checklist") {
if (angular.isDefined(field.disabled)) {newElement.attr('ng-disabled', field.disabled);}
if (angular.isDefined(field.callback)) {
// Some input types need listeners on click...
if (["button", "fieldset", "image", "legend", "reset", "submit"].indexOf(field.type) > -1) {
cbAtt = 'ng-click';
}
// ...the rest on change.
else {
cbAtt = 'ng-change';
}
newElement.attr(cbAtt, field.callback);
}
}
}
// If there's a label, add it.
if (angular.isDefined(field.label)) {
// Some elements have already applied their labels.
if (["image", "hidden"].indexOf(field.type) > -1) {
angular.noop();
}
// Fieldset elements put their labels in legend child elements.
else if (["fieldset"].indexOf(field.type) > -1) {
newElement.prepend(angular.element($document[0].createElement('legend')).html($translate.instant(field.label)));
}
// Button elements get their labels from their contents.
else if (["button", "legend", "reset", "submit"].indexOf(field.type) > -1) {
newElement.html($translate.instant(field.label));
}else {
angular.noop();
}
}
// Arbitrary attributes are added here
angular.forEach(field, function (val, attr) {
if (["label", "type", "model", "enclosing-div", "empty", "options"].indexOf(attr) > -1) {
angular.noop();
}else if(["md-icon"].indexOf(attr) > -1){
newElement = newElement.prepend('<md-icon md-font-icon="'+ val +'"></md-icon>');
}
else if(["md-placeholder", "aria-label"].indexOf(attr) > -1){
newElement.attr(attr, $translate.instant(val));
}
else{
// the attributes need to be updated.
newElement.attr(attr, val);
}
});
// Control the div around the md-button
if(field.type === "submit" || field.type === "reset"){
if(field["enclosing-div"] == true){
newElement = angular.element("<div></div>").append(newElement);
}
}else {
// Everything else should have label element followed by the actual element.
wrapperElem = wrapperElem.append("<label translate='"+ field.label +"'></label>");
newElement = wrapperElem.append( newElement);
}
wrapElement.append( newElement );
newElement = null;
}
};
//
/*
* Function to handle the building of the rows of field elements as per config Object
* params:templateRows - Object array containing the rows of field elements
* id: dummy param - for future usage
*/
buildRows = function (templateRows, id){
var templateRowsLength = Object.keys(templateRows).length;
for(var j=0; j < templateRowsLength; j++){
var rowWrapper = angular.element('<div layout-gt-sm="row"></div>');
var rowName = Object.keys(templateRows)[j];
var row = templateRows[rowName];
var rowFieldLength = row.length;
for(var k=0; k < rowFieldLength; k++){
buildFields(rowWrapper, row[k], k);
}
element.append(rowWrapper);
}
};
// call the buildRows function
buildRows(template);
// Determine what tag name to use (ng-form if nested; form if outermost)
while (!angular.equals(iterElem.parent(), $document) && !angular.equals(iterElem[0], $document[0].documentElement)) {
if (['form','ngForm','mmDynamicForm'].indexOf(attrs.$normalize(angular.lowercase(iterElem.parent()[0].nodeName))) > -1) {
foundOne = true;
break;
}
iterElem = iterElem.parent();
}
if (foundOne) {
newElement = angular.element($document[0].createElement('ng-form'));
}
else {
newElement = angular.element("<form></form>");
}
// Psuedo-transclusion
angular.forEach(attrs.$attr, function(attName, attIndex) {
newElement.attr(attName, attrs[attIndex]);
});
newElement.attr('model', attrs.ngModel);
newElement.attr('name', attrs.formName);
newElement.removeAttr('form-name');
newElement.removeAttr('ng-model');
angular.forEach(element[0].classList, function(clsName) {
newElement[0].classList.add(clsName);
});
newElement.addClass('mm-dynamic-form');
newElement.append(element.contents());
// onReset logic
newElement.data('$_cleanModel', angular.copy(model));
newElement.bind('reset', function () {
$timeout(function () {
$scope.$broadcast('reset', arguments);
}, 0);
});
$scope.$on('reset', function () {
$scope.$apply(function () {
$scope[attrs.ngModel] = {};
});
$scope.$apply(function () {
$scope[attrs.ngModel] = angular.copy(newElement.data('$_cleanModel'));
});
});
// Compile and update DOM
$compile(newElement)($scope);
element.replaceWith(newElement);
});
}
}
};
}])
// Not a fan of how Angular's ngList is implemented, so here's a better one (IMO). It will ONLY
// apply to <mm-dynamic-form> child elements, and replaces the ngList that ships with Angular.
.directive('ngList', [function () {
return {
require: '?ngModel',
link: function (scope, element, attr, ctrl) {
var match = /\/(.*)\//.exec(element.attr(attr.$attr.ngList)),
separator = match && new RegExp(match[1]) || element.attr(attr.$attr.ngList) || ',';
if (element[0].form !== null && !angular.element(element[0].form).hasClass('mm-dynamic-form')) {
return;
}
ctrl.$parsers.splice(0, 1);
ctrl.$formatters.splice(0, 1);
ctrl.$parsers.push(function(viewValue) {
var list = [];
if (angular.isString(viewValue)) {
// Don't have Angular's trim() exposed, so let's simulate it:
if (String.prototype.trim) {
angular.forEach(viewValue.split(separator), function(value) {
if (value) list.push(value.trim());
});
}
else {
angular.forEach(viewValue.split(separator), function(value) {
if (value) list.push(value.replace(/^\s*/, '').replace(/\s*$/, ''));
});
}
}
return list;
});
ctrl.$formatters.push(function(val) {
var joinBy = angular.isString(separator) && separator || ', ';
if (angular.isArray(val)) {
return val.join(joinBy);
}
return undefined;
});
}
};
}])
// Following code was adapted from http://odetocode.com/blogs/scott/archive/2013/07/05/a-file-input-directive-for-angularjs.aspx
.directive('input', ['$parse', function ($parse) {
return {
restrict: 'E',
require: '?ngModel',
link: function (scope, element, attrs, ctrl) {
if (!ctrl) {
// Doesn't have an ng-model attribute; nothing to do here.
return;
}
if (attrs.type === 'file') {
var modelGet = $parse(attrs.ngModel),
modelSet = modelGet.assign,
onChange = $parse(attrs.onChange),
updateModel = function () {
scope.$apply(function () {
modelSet(scope, element[0].files);
onChange(scope);
});
};
ctrl.$render = function () {
element[0].files = this.$viewValue;
};
element.bind('change', updateModel);
}
else if (attrs.type === 'range') {
ctrl.$parsers.push(function (val) {
if (val) {
return parseFloat(val);
}
});
}
}
};
}])
// Following code was adapted from http://odetocode.com/blogs/scott/archive/2013/07/03/building-a-filereader-service-for-angularjs-the-service.aspx
.factory('fileReader', ['$q', function ($q) {
var onLoad = function(reader, deferred, scope) {
return function () {
scope.$apply(function () {
deferred.resolve(reader.result);
});
};
},
onError = function (reader, deferred, scope) {
return function () {
scope.$apply(function () {
deferred.reject(reader.error);
});
};
},
onProgress = function(reader, scope) {
return function (event) {
scope.$broadcast('fileProgress',
{
total: event.total,
loaded: event.loaded,
status: reader.readyState
});
};
},
getReader = function(deferred, scope) {
var reader = new FileReader();
reader.onload = onLoad(reader, deferred, scope);
reader.onerror = onError(reader, deferred, scope);
reader.onprogress = onProgress(reader, scope);
return reader;
};
return {
readAsArrayBuffer: function (file, scope) {
var deferred = $q.defer(),
reader = getReader(deferred, scope);
reader.readAsArrayBuffer(file);
return deferred.promise;
},
readAsBinaryString: function (file, scope) {
var deferred = $q.defer(),
reader = getReader(deferred, scope);
reader.readAsBinaryString(file);
return deferred.promise;
},
readAsDataURL: function (file, scope) {
var deferred = $q.defer(),
reader = getReader(deferred, scope);
reader.readAsDataURL(file);
return deferred.promise;
},
readAsText: function (file, scope) {
var deferred = $q.defer(),
reader = getReader(deferred, scope);
reader.readAsText(file);
return deferred.promise;
}
};
}]);
/* End of mm-dynamic-forms.js */