Merge branch 'Alain-es-Fix_for_U4-5875' into dev-v7 - And updates quite a lot: changes directive name to valPropertyValidator and changes how it works to be ultra flexible for custom validation of property data. Updates umbProperty directive to expose an API for other directives to utilize (like the new set setPropertyError method). Updates valServer to use the better way to access the current property object from the umbProperty directive API. Updates both valServer and valPropertyMsg to only perform a watch when necessary when there has been server side errors, this will speed up client side performance quite a bit. Now the tags, color picker and upload property editors performs correct client side and server side validation for the required validation flag.

This commit is contained in:
Shannon
2015-01-06 10:55:38 +11:00
parent d4c0ea9c48
commit a4f7c49f50
10 changed files with 286 additions and 118 deletions

View File

@@ -13,8 +13,18 @@ angular.module("umbraco.directives")
restrict: 'E',
replace: true,
templateUrl: 'views/directives/umb-property.html',
link: function (scope, element, attrs, ctrl) {
//Define a controller for this directive to expose APIs to other directives
controller: function ($scope, $timeout) {
var self = this;
//set the API properties/methods
self.property = $scope.property;
self.setPropertyError = function(errorMsg) {
$scope.property.propertyErrorMessage = errorMsg;
};
}
};
});

View File

@@ -9,22 +9,86 @@
* and when an error is detected for this property we'll show the error message.
* In order for this directive to work, the valStatusChanged directive must be placed on the containing form.
**/
function valPropertyMsg(serverValidationManager) {
function valPropertyMsg(serverValidationManager) {
return {
scope: {
property: "=property"
property: "="
},
require: "^form", //require that this directive is contained within an ngForm
replace: true, //replace the element with the template
restrict: "E", //restrict to element
template: "<div ng-show=\"errorMsg != ''\" class='alert alert-error property-error' >{{errorMsg}}</div>",
template: "<div ng-show=\"errorMsg != ''\" class='alert alert-error property-error' >{{errorMsg}}</div>",
/**
Our directive requries a reference to a form controller
which gets passed in to this parameter
*/
link: function (scope, element, attrs, formCtrl) {
var watcher = null;
// Gets the error message to display
function getErrorMsg() {
//this can be null if no property was assigned
if (scope.property) {
//first try to get the error msg from the server collection
var err = serverValidationManager.getPropertyError(scope.property.alias, "");
//if there's an error message use it
if (err && err.errorMsg) {
return err.errorMsg;
}
else {
return scope.property.propertyErrorMessage ? scope.property.propertyErrorMessage : "Property has errors";
}
}
return "Property has errors";
}
// We need to subscribe to any changes to our model (based on user input)
// This is required because when we have a server error we actually invalidate
// the form which means it cannot be resubmitted.
// So once a field is changed that has a server error assigned to it
// we need to re-validate it for the server side validator so the user can resubmit
// the form. Of course normal client-side validators will continue to execute.
function startWatch() {
//if there's not already a watch
if (!watcher) {
watcher = scope.$watch("property.value", function (newValue, oldValue) {
if (!newValue || angular.equals(newValue, oldValue)) {
return;
}
var errCount = 0;
for (var e in formCtrl.$error) {
if (angular.isArray(formCtrl.$error[e])) {
errCount++;
}
}
//we are explicitly checking for valServer errors here, since we shouldn't auto clear
// based on other errors. We'll also check if there's no other validation errors apart from valPropertyMsg, if valPropertyMsg
// is the only one, then we'll clear.
if ((errCount === 1 && angular.isArray(formCtrl.$error.valPropertyMsg)) || (formCtrl.$invalid && angular.isArray(formCtrl.$error.valServer))) {
scope.errorMsg = "";
formCtrl.$setValidity('valPropertyMsg', true);
stopWatch();
}
}, true);
}
}
//clear the watch when the property validator is valid again
function stopWatch() {
if (watcher) {
watcher();
watcher = null;
}
}
//if there's any remaining errors in the server validation service then we should show them.
var showValidation = serverValidationManager.items.length > 0;
var hasError = false;
@@ -33,7 +97,7 @@ function valPropertyMsg(serverValidationManager) {
scope.errorMsg = "";
//listen for form error changes
scope.$on("valStatusChanged", function(evt, args) {
scope.$on("valStatusChanged", function (evt, args) {
if (args.form.$invalid) {
//first we need to check if the valPropertyMsg validity is invalid
@@ -47,12 +111,7 @@ function valPropertyMsg(serverValidationManager) {
hasError = true;
//update the validation message if we don't already have one assigned.
if (showValidation && scope.errorMsg === "") {
var err;
//this can be null if no property was assigned
if (scope.property) {
err = serverValidationManager.getPropertyError(scope.property.alias, "");
}
scope.errorMsg = err ? err.errorMsg : "Property has errors";
scope.errorMsg = getErrorMsg();
}
}
else {
@@ -70,15 +129,11 @@ function valPropertyMsg(serverValidationManager) {
scope.$on("formSubmitting", function (ev, args) {
showValidation = true;
if (hasError && scope.errorMsg === "") {
var err;
//this can be null if no property was assigned
if (scope.property) {
err = serverValidationManager.getPropertyError(scope.property.alias, "");
}
scope.errorMsg = err ? err.errorMsg : "Property has errors";
scope.errorMsg = getErrorMsg();
}
else if (!hasError) {
scope.errorMsg = "";
stopWatch();
}
});
@@ -86,37 +141,10 @@ function valPropertyMsg(serverValidationManager) {
scope.$on("formSubmitted", function (ev, args) {
showValidation = false;
scope.errorMsg = "";
formCtrl.$setValidity('valPropertyMsg', true);
formCtrl.$setValidity('valPropertyMsg', true);
stopWatch();
});
//We need to subscribe to any changes to our model (based on user input)
// This is required because when we have a server error we actually invalidate
// the form which means it cannot be resubmitted.
// So once a field is changed that has a server error assigned to it
// we need to re-validate it for the server side validator so the user can resubmit
// the form. Of course normal client-side validators will continue to execute.
scope.$watch("property.value", function(newValue) {
//we are explicitly checking for valServer errors here, since we shouldn't auto clear
// based on other errors. We'll also check if there's no other validation errors apart from valPropertyMsg, if valPropertyMsg
// is the only one, then we'll clear.
if (!newValue) {
return;
}
var errCount = 0;
for (var e in formCtrl.$error) {
if (angular.isArray(formCtrl.$error[e])) {
errCount++;
}
}
if ((errCount === 1 && angular.isArray(formCtrl.$error.valPropertyMsg)) || (formCtrl.$invalid && angular.isArray(formCtrl.$error.valServer))) {
scope.errorMsg = "";
formCtrl.$setValidity('valPropertyMsg', true);
}
}, true);
//listen for server validation changes
// NOTE: we pass in "" in order to listen for all validation changes to the content property, not for
// validation changes to fields in the property this is because some server side validators may not
@@ -124,27 +152,30 @@ function valPropertyMsg(serverValidationManager) {
// It's important to note that we need to subscribe to server validation changes here because we always must
// indicate that a content property is invalid at the property level since developers may not actually implement
// the correct field validation in their property editors.
if (scope.property) { //this can be null if no property was assigned
serverValidationManager.subscribe(scope.property.alias, "", function(isValid, propertyErrors, allErrors) {
serverValidationManager.subscribe(scope.property.alias, "", function (isValid, propertyErrors, allErrors) {
hasError = !isValid;
if (hasError) {
//set the error message to the server message
scope.errorMsg = propertyErrors[0].errorMsg;
//flag that the current validator is invalid
formCtrl.$setValidity('valPropertyMsg', false);
startWatch();
}
else {
scope.errorMsg = "";
//flag that the current validator is valid
formCtrl.$setValidity('valPropertyMsg', true);
stopWatch();
}
});
//when the element is disposed we need to unsubscribe!
// NOTE: this is very important otherwise when this controller re-binds the previous subscriptsion will remain
// but they are a different callback instance than the above.
element.bind('$destroy', function() {
element.bind('$destroy', function () {
stopWatch();
serverValidationManager.unsubscribe(scope.property.alias, "");
});
}

View File

@@ -0,0 +1,67 @@
/**
* @ngdoc directive
* @name umbraco.directives.directive:valPropertyValidator
* @restrict A
* @description Performs any custom property value validation checks on the client side. This allows property editors to be highly flexible when it comes to validation
on the client side. Typically if a property editor stores a primitive value (i.e. string) then the client side validation can easily be taken care of
with standard angular directives such as ng-required. However since some property editors store complex data such as JSON, a given property editor
might require custom validation. This directive can be used to validate an Umbraco property in any way that a developer would like by specifying a
callback method to perform the validation. The result of this method must return an object in the format of
{isValid: true, errorKey: 'required', errorMsg: 'Something went wrong' }
The error message returned will also be displayed for the property level validation message.
This directive should only be used when dealing with complex models, if custom validation needs to be performed with primitive values, use the simpler
angular validation directives instead since this will watch the entire model.
**/
function valPropertyValidator(serverValidationManager) {
return {
scope: {
valPropertyValidator: "="
},
// The element must have ng-model attribute and be inside an umbProperty directive
require: ['ngModel', '^umbProperty'],
restrict: "A",
link: function (scope, element, attrs, ctrls) {
var modelCtrl = ctrls[0];
var propCtrl = ctrls[1];
// Check whether the scope has a valPropertyValidator method
if (!scope.valPropertyValidator || !angular.isFunction(scope.valPropertyValidator)) {
throw new Error('val-property-validator directive must specify a function to call');
}
var initResult = scope.valPropertyValidator();
// Validation method
var validate = function (viewValue) {
// Calls the validition method
var result = scope.valPropertyValidator();
if (!result.errorKey || result.isValid === undefined || !result.errorMsg) {
throw "The result object from valPropertyValidator does not contain required properties: isValid, errorKey, errorMsg";
}
if (result.isValid === true) {
// Tell the controller that the value is valid
modelCtrl.$setValidity(result.errorKey, true);
propCtrl.setPropertyError(null);
}
else {
// Tell the controller that the value is invalid
modelCtrl.$setValidity(result.errorKey, false);
propCtrl.setPropertyError(result.errorMsg);
}
};
// Formatters are invoked when the model is modified in the code.
modelCtrl.$formatters.push(validate);
// Parsers are called as soon as the value in the form input is modified
modelCtrl.$parsers.push(validate);
}
};
}
angular.module('umbraco.directives.validation').directive("valPropertyValidator", valPropertyValidator);

View File

@@ -7,14 +7,49 @@
**/
function valServer(serverValidationManager) {
return {
require: 'ngModel',
require: ['ngModel', '^umbProperty'],
restrict: "A",
link: function (scope, element, attr, ctrl) {
if (!scope.model || !scope.model.alias){
throw "valServer can only be used in the scope of a content property object";
link: function (scope, element, attr, ctrls) {
var modelCtrl = ctrls[0];
var umbPropCtrl = ctrls[1];
var watcher = null;
//Need to watch the value model for it to change, previously we had subscribed to
//modelCtrl.$viewChangeListeners but this is not good enough if you have an editor that
// doesn't specifically have a 2 way ng binding. This is required because when we
// have a server error we actually invalidate the form which means it cannot be
// resubmitted. So once a field is changed that has a server error assigned to it
// we need to re-validate it for the server side validator so the user can resubmit
// the form. Of course normal client-side validators will continue to execute.
function startWatch() {
//if there's not already a watch
if (!watcher) {
watcher = scope.$watch(function () {
return modelCtrl.$modelValue;
}, function (newValue, oldValue) {
if (!newValue || angular.equals(newValue, oldValue)) {
return;
}
if (modelCtrl.$invalid) {
modelCtrl.$setValidity('valServer', true);
stopWatch();
}
}, true);
}
}
var currentProperty = scope.model;
function stopWatch() {
if (watcher) {
watcher();
watcher = null;
}
}
var currentProperty = umbPropCtrl.property;
//default to 'value' if nothing is set
var fieldName = "value";
@@ -25,33 +60,20 @@ function valServer(serverValidationManager) {
fieldName = attr.valServer;
}
}
//Need to watch the value model for it to change, previously we had subscribed to
//ctrl.$viewChangeListeners but this is not good enough if you have an editor that
// doesn't specifically have a 2 way ng binding. This is required because when we
// have a server error we actually invalidate the form which means it cannot be
// resubmitted. So once a field is changed that has a server error assigned to it
// we need to re-validate it for the server side validator so the user can resubmit
// the form. Of course normal client-side validators will continue to execute.
scope.$watch(function() {
return ctrl.$modelValue;
}, function (newValue) {
if (ctrl.$invalid) {
ctrl.$setValidity('valServer', true);
}
});
//subscribe to the server validation changes
serverValidationManager.subscribe(currentProperty.alias, fieldName, function (isValid, propertyErrors, allErrors) {
if (!isValid) {
ctrl.$setValidity('valServer', false);
modelCtrl.$setValidity('valServer', false);
//assign an error msg property to the current validator
ctrl.errorMsg = propertyErrors[0].errorMsg;
modelCtrl.errorMsg = propertyErrors[0].errorMsg;
startWatch();
}
else {
ctrl.$setValidity('valServer', true);
modelCtrl.$setValidity('valServer', true);
//reset the error message
ctrl.errorMsg = "";
modelCtrl.errorMsg = "";
stopWatch();
}
});
@@ -59,6 +81,7 @@ function valServer(serverValidationManager) {
// NOTE: this is very important otherwise when this controller re-binds the previous subscriptsion will remain
// but they are a different callback instance than the above.
element.bind('$destroy', function () {
stopWatch();
serverValidationManager.unsubscribe(currentProperty.alias, fieldName);
});
}

View File

@@ -2,11 +2,23 @@ function ColorPickerController($scope) {
$scope.toggleItem = function (color) {
if ($scope.model.value == color) {
$scope.model.value = "";
//this is required to re-validate
$scope.propertyForm.modelValue.$setViewValue($scope.model.value);
}
else {
$scope.model.value = color;
//this is required to re-validate
$scope.propertyForm.modelValue.$setViewValue($scope.model.value);
}
};
// Method required by the valPropertyValidator directive (returns true if the property editor has at least one color selected)
$scope.validateMandatory = function () {
return {
isValid: !$scope.model.validation.mandatory || ($scope.model.value != null && $scope.model.value != ""),
errorMsg: "Value cannot be empty",
errorKey: "required"
};
}
$scope.isConfigured = $scope.model.config && $scope.model.config.items && _.keys($scope.model.config.items).length > 0;
}

View File

@@ -12,4 +12,6 @@
</li>
</ul>
<input type="hidden" name="modelValue" ng-model="model.value" val-property-validator="validateMandatory"/>
</div>

View File

@@ -21,6 +21,11 @@ function fileUploadController($scope, $element, $compile, imageHelper, fileManag
fileManager.setFiles($scope.model.alias, []);
//clear the current files
$scope.files = [];
if ($scope.propertyForm.fileCount) {
//this is required to re-validate
$scope.propertyForm.fileCount.$setViewValue($scope.files.length);
}
}
/** this method is used to initialize the data and to re-initialize it if the server value is changed */
@@ -71,6 +76,15 @@ function fileUploadController($scope, $element, $compile, imageHelper, fileManag
initialize();
// Method required by the valPropertyValidator directive (returns true if the property editor has at least one file selected)
$scope.validateMandatory = function () {
return {
isValid: !$scope.model.validation.mandatory || ((($scope.persistedFiles != null && $scope.persistedFiles.length > 0) || ($scope.files != null && $scope.files.length > 0)) && !$scope.clearFiles),
errorMsg: "Value cannot be empty",
errorKey: "required"
};
}
//listen for clear files changes to set our model to be sent up to the server
$scope.$watch("clearFiles", function (isCleared) {
if (isCleared == true) {
@@ -80,6 +94,8 @@ function fileUploadController($scope, $element, $compile, imageHelper, fileManag
else {
//reset to original value
$scope.model.value = $scope.originalValue;
//this is required to re-validate
$scope.propertyForm.fileCount.$setViewValue($scope.files.length);
}
});
@@ -96,6 +112,10 @@ function fileUploadController($scope, $element, $compile, imageHelper, fileManag
$scope.files.push({ alias: $scope.model.alias, file: args.files[i] });
newVal += args.files[i].name + ",";
}
//this is required to re-validate
$scope.propertyForm.fileCount.$setViewValue($scope.files.length);
//set clear files to false, this will reset the model too
$scope.clearFiles = false;
//set the model value to be the concatenation of files selected. Please see the notes

View File

@@ -25,4 +25,7 @@
<li ng-repeat="file in files">{{file.file.name}}</li>
</ul>
</div>
</div>
<input type="hidden" name="fileCount" ng-model="files.length" val-property-validator="validateMandatory"/>
</div>

View File

@@ -12,31 +12,38 @@ angular.module("umbraco")
$scope.isLoading = false;
//load current value
$scope.currentTags = [];
if ($scope.model.value) {
if ($scope.model.config.storageType && $scope.model.config.storageType === "Json") {
//it's a json array already
$scope.currentTags = $scope.model.value;
}
else {
if (!$scope.model.config.storageType || $scope.model.config.storageType !== "Json") {
//it is csv
if (!$scope.model.value) {
$scope.currentTags = [];
$scope.model.value = [];
}
else {
$scope.currentTags = $scope.model.value.split(",");
$scope.model.value = $scope.model.value.split(",");
}
}
}
else {
$scope.model.value = [];
}
// Method required by the valPropertyValidator directive (returns true if the property editor has at least one tag selected)
$scope.validateMandatory = function () {
return {
isValid: !$scope.model.validation.mandatory || ($scope.model.value != null && $scope.model.value.length > 0),
errorMsg: "Value cannot be empty",
errorKey: "required"
};
}
//Helper method to add a tag on enter or on typeahead select
function addTag(tagToAdd) {
if (tagToAdd.length > 0) {
if ($scope.currentTags.indexOf(tagToAdd) < 0) {
$scope.currentTags.push(tagToAdd);
//update the model value, this is required if there's a server validation error, it can
// only then be cleared if the model changes
$scope.model.value = $scope.currentTags;
if (tagToAdd != null && tagToAdd.length > 0) {
if ($scope.model.value.indexOf(tagToAdd) < 0) {
$scope.model.value.push(tagToAdd);
//this is required to re-validate
$scope.propertyForm.tagCount.$setViewValue($scope.model.value.length);
}
}
}
@@ -47,7 +54,6 @@ angular.module("umbraco")
if ($element.find('.tags-' + $scope.model.alias).parent().find(".tt-dropdown-menu .tt-cursor").length === 0) {
//this is required, otherwise the html form will attempt to submit.
e.preventDefault();
$scope.addTag();
}
}
@@ -66,36 +72,26 @@ angular.module("umbraco")
$scope.removeTag = function (tag) {
var i = $scope.currentTags.indexOf(tag);
var i = $scope.model.value.indexOf(tag);
if (i >= 0) {
$scope.currentTags.splice(i, 1);
//update the model value, this is required if there's a server validation error, it can
// only then be cleared if the model changes
$scope.model.value = $scope.currentTags;
$scope.model.value.splice(i, 1);
//this is required to re-validate
$scope.propertyForm.tagCount.$setViewValue($scope.model.value.length);
}
};
//sync model on submit, always push up a json array
$scope.$on("formSubmitting", function (ev, args) {
$scope.model.value = $scope.currentTags;
});
//vice versa
$scope.model.onValueChanged = function (newVal, oldVal) {
//update the display val again if it has changed from the server
$scope.model.value = newVal;
if ($scope.model.config.storageType && $scope.model.config.storageType === "Json") {
//it's a json array already
$scope.currentTags = $scope.model.value;
}
else {
if (!$scope.model.config.storageType || $scope.model.config.storageType !== "Json") {
//it is csv
if (!$scope.model.value) {
$scope.currentTags = [];
$scope.model.value = [];
}
else {
$scope.currentTags = $scope.model.value.split(",");
$scope.model.value = $scope.model.value.split(",");
}
}
};
@@ -110,14 +106,14 @@ angular.module("umbraco")
});
// remove current tags from the list
return $.grep(tagList, function (tag) {
return ($.inArray(tag.value, $scope.currentTags) === -1);
return ($.inArray(tag.value, $scope.model.value) === -1);
});
}
// helper method to remove current tags
function removeCurrentTagsFromSuggestions(suggestions) {
return $.grep(suggestions, function (suggestion) {
return ($.inArray(suggestion.value, $scope.currentTags) === -1);
return ($.inArray(suggestion.value, $scope.model.value) === -1);
});
}
@@ -158,7 +154,6 @@ angular.module("umbraco")
// name = the data set name, we'll make this the tag group name
name: $scope.model.config.group,
displayKey: "value",
//source: tagsHound.ttAdapter(),
source: function (query, cb) {
tagsHound.get(query, function (suggestions) {
cb(removeCurrentTagsFromSuggestions(suggestions));

View File

@@ -1,21 +1,26 @@
<div ng-controller="Umbraco.PropertyEditors.TagsController" class="umb-editor umb-tags">
<div ng-if="isLoading">
<localize key="loading">Loading</localize>...
<div ng-if="isLoading">
<localize key="loading">Loading</localize>...
</div>
<div ng-if="!isLoading">
<span ng-repeat="tag in currentTags" ng-click="removeTag(tag)" class="label label-primary tag">
<input type="hidden" name="tagCount" ng-model="model.value.length" val-property-validator="validateMandatory" />
<span ng-repeat="tag in model.value" ng-click="$parent.removeTag(tag)" class="label label-primary tag">
<span ng-bind-html="tag"></span>
<i class="icon icon-delete"></i>
</span>
<input type="text"
class="typeahead tags-{{model.alias}}"
ng-model="$parent.tagToAdd"
ng-model="$parent.tagToAdd"
ng-keydown="$parent.addTagOnEnter($event)"
on-blur="$parent.addTag()"
on-blur="$parent.addTag()"
localize="placeholder"
placeholder="@placeholders_enterTags" />
placeholder="@placeholders_enterTags" />
</div>
</div>