Skip to content

Instantly share code, notes, and snippets.

@hallister
Created August 8, 2013 18:13
Show Gist options
  • Save hallister/6187144 to your computer and use it in GitHub Desktop.
Save hallister/6187144 to your computer and use it in GitHub Desktop.
An Angular directive that has no awareness of the DOM
angular.module('ui.bootstrap.editable', [])
.constant('editableConfig', {
validators: {
required: 'This field is required.',
min: 'A minimum value of {{ value }} is required.',
max: 'A maximum value of {{ value }} is allowed.',
ngMinlength: 'A minimum length of {{ value }} is required.',
ngMaxlength: 'A maximum length of {{ value }} is allowed.',
email: 'A valid email address is required.',
url: 'A valid URL is required.',
number: 'A valid number is required.',
defaultError: 'This is not a valid value.'
},
templateUrl: 'template/editable/editable.html',
inputTemplateUrl: 'template/editable/text.html',
inputClass: 'input-medium',
type: 'text',
bindOn: 'click',
optionLabel: 'label',
optionKey: 'key',
groupBy: ''
})
.directive('editable', ['$compile', '$timeout', '$http', '$interpolate', '$templateCache', '$parse', 'editableConfig', function ($compile, $timeout, $http, $interpolate, $templateCache, $parse, editableConfig) {
return {
require: 'ngModel',
scope: {
'source': '&',
'ngModel': '&model',
'type': '@'
},
link: function postLink(scope, element, attrs, ctrl) {
var display = element.css('display'),
form,
error,
submit,
template,
errors,
originalValue;
scope.opts = angular.extend({}, scope.$eval(attrs.uiOptions || attrs.editableOptions || attrs.options), editableConfig);
// attribute options override non-attribute options.. probalby a better way to do this
angular.forEach(attrs, function(value, id) {
if (angular.isDefined(editableConfig[id]) && id != 'validators') {
scope.opts[id] = value;
}
});
// we need the attributes for editable-input as well
scope.attrs = attrs;
// setup the errors
scope.errors = '';
// if the binding isn't on click, we need to prevent the default click behavior
if (scope.opts.bindOn != 'click') {
element.bind('click', function(e) {
e.preventDefault();
});
}
// changes from inside isolate scope -> outside isolate scope
scope.$watch('model', function(val) {
// when the element is intially created, it's undefined
if (angular.isDefined(val)) {
ctrl.$setViewValue(val);
} else {
// get the original value
originalValue = ctrl.$modelValue;
}
});
// changes from outside isolate scope -> isolate scope
scope.$watch(attrs.ngModel, function(val) {
scope.model = ctrl.$viewValue;
});
// called by editableInput directive when the input has successfully replaced <editable-input>
scope.inputReady = function() {
compileForm();
};
// displays the errors need to refactor some of this
scope.showError = function() {
// reset the errors
scope.errors = '';
errors = [];
// make sure the form field is defined
if (angular.isDefined(scope.editable_form.editable_field)) {
// loop through all editable_field errors
angular.forEach(scope.editable_form.editable_field.$error, function(key, value) {
// if error is true
if (key) {
// most validation fields don't have ng prefixes. But since ngMinlength and ngMaxlength do
// we have to check.
var camelValue = camelCase('ng-' + value);
var validator = scope.opts.validators[value] || scope.opts.validators[camelValue] || false;
// if validator is not falsy
if(validator) {
// interpolate the value... this only works for min/max/minlength/maxlength or custom
// it will return a null for any validator that doesn't have a {{ value }}
var errorFunction = $interpolate(validator, true);
var errorString;
// if a function is returned
if (angular.isFunction(errorFunction)) {
// get the value for {{ value }} and replace it
var replacementVal = attrs[value] || attrs[camelValue];
errorString = errorFunction({ value: replacementVal });
} else {
// no interpolation required, just set string
errorString = validator;
}
// push the error
errors.push(errorString);
} else {
// we don't have a validator for this (how?) push the default error
errors.push(scope.opts.validators.defaultError);
}
}
});
}
// push each error into the error array
angular.forEach(errors, function(value, key) {
if (angular.isDefined(value)) {
scope.errors = scope.errors + value + '<br>';
}
});
};
// submits the form if it's valid, shows error otherwise
scope.submitForm = function() {
if ((angular.isDefined(scope.editable_form) && scope.editable_form.$invalid)) {
// if we have an error, show it
scope.showError();
} else {
form.remove();
showElement();
// the new original value
originalValue = ctrl.$modelValue;
}
};
// cancels the form and sets the viewValue back to the originalValue
scope.cancelForm = function() {
scope.model = originalValue;
form.remove();
showElement();
scope.$apply();
};
var getTemplate = function() {
// bind the element
$http.get(scope.opts.templateUrl, {cache: $templateCache})
.success(function(result) {
template = result;
element.bind(scope.opts.bindOn, function(e) {
e.preventDefault();
hideElement();
buildForm();
});
});
};
// hides the element
var hideElement = function() {
element.css('display', 'none');
};
// shows the element
var showElement = function() {
element.css('display', 'inline');
};
// form must have an editable-input somewhere
var buildForm = function() {
form = angular.element(template);
element.after(form);
// we have to compile the editable input first,
// or the form invalidation won't work
$compile(form.find('editable-input'))(scope);
scope.$apply();
};
// compiles complete form against current scope
var compileForm = function() {
$compile(form)(scope);
};
// taken directly from Angular.js
// converts snake-case to camelCase
var camelCase = function(name) {
return name.
replace(/([\:\-\_]+(.))/g, function(_, separator, letter, offset) {
return offset ? letter.toUpperCase() : letter;
}).
replace(/^moz([A-Z])/, 'Moz$1');
};
// start it off
getTemplate();
}
};
}])
.directive('editableInput', ['$compile', '$timeout', '$http', '$templateCache', '$interpolate', 'editableConfig', function ($compile, $timeout, $http, $templateCache, $interpolate, editableConfig) {
return {
restrict: 'E',
link: function postLink(scope, element, attrs) {
// scope.source
// scope.ngModel
var input,
optionsAttr,
template,
requiresValidation = [],
groupBy = '',
templates = {
text: '<input name="editable_field" type="{{ opts.type }}" class="{{ opts.inputClass }}">',
select: '<select name="editable_field" class="{{ opts.inputClass }}"></select>'
};
scope.validationAttributes = '';
scope.onElementChange = function() {
if (angular.isDefined(scope.editable_form) && scope.editable_form.$invalid) {
scope.showError();
}
};
var getTemplate = function() {
// get the template, check template cache first
$http.get(scope.opts.inputTemplateUrl, {cache: $templateCache})
.success(function(result) {
// set the template and build the form
template = angular.element(result);
buildInput();
})
.error(function(result) {
// we could use Response interceptors here, but it seems unncessary at this point since global
// interceptors aren't needed
// we couldn't find the template, so let's fall back to editableConfig's default
if (scope.opts.inputTemplateUrl !== editableConfig.inputTemplateUrl) {
scope.opts.inputTemplateUrl = editableConfig.inputTemplateUrl;
getTemplate();
// final fall back, just grab the default text-type template
} else if (scope.opts.inputTemplateUrl !== 'template/editable/text.html') {
scope.opts.inputTemplateUrl = 'template/editable/text.html';
getTemplate();
}
});
};
var buildInput = function() {
findValidationElement(template);
element.replaceWith(template);
// we call this so the entire editable can be compiled
// otherwise the form validation (the field isn't attached to scope.form_name).
scope.inputReady();
};
// find all children in the template that require the validators
var findValidationElement = function(valElement) {
// if it has wg-editable-validators, push it
if (valElement.hasClass('wg-editable-validators')) {
addInputAttributes(valElement);
}
// if element has children, each child should be tested
if (valElement.children().length > 0) {
angular.forEach(valElement.children(), function(value, key) {
// recurse
findValidationElement(angular.element(value));
});
}
};
// adds the input attributes per scope.opts.validators
var addInputAttributes = function(valElement) {
angular.forEach(scope.attrs, function(value, key) {
if (angular.isDefined(scope.opts.validators[key])) {
valElement.attr(snakeCase(key), scope.attrs[key]);
// range and number input types accept "step" as a attribute
if ((scope.opts.type == 'number' || scope.opts.type == 'range') && angular.isDefined(scope.attrs.step)) {
valElement.attr('step', attrs.step);
}
}
});
};
// taken directly from Angular.
// converts camelCase to snake-case
var snakeCase = function(name, separator) {
separator = separator || '-';
return name.replace(/[A-Z]/g, function(letter, pos) {
return (pos ? separator : '') + letter.toLowerCase();
});
};
// start it!
getTemplate();
}
};
}]);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment