Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Issue with Decimals, Commas and Client-Side Validation

I'm trying to achieve client-side validation for a nullable<decimal> whose decimal separator can be a comma (e.g.: 123,45).

In my View:

...

<div class="editor-label">
    @Html.LabelFor(model => model.Turnover)
</div>
<div class="editor-field">
    @Html.EditorFor(model => model.Turnover)
    @Html.ValidationMessageFor(model => model.Turnover)
</div>

...

@section Scripts {

   @Styles.Render("~/Content/themes/base/css")
   @Scripts.Render("~/bundles/jquery")
   @Scripts.Render("~/bundles/jqueryui")
   @Scripts.Render("~/bundles/jqueryval")
   @Scripts.Render("~/bundles/jQueryFixes")
   ...scripts for this view...
}

My jQueryFixes overrides jquery.validate.js file for the range() and number():

$.validator.methods.range = function (value, element, param) {
    var globalizedValue = value.replace(",", ".");
    return this.optional(element) || (globalizedValue >= param[0] && globalizedValue <= param[1]);
}

$.validator.methods.number = function (value, element) {
    return this.optional(element) || /^-?(?:\d+|\d{1,3}(?:[\s\.,]\d{3})+)(?:[\.,]\d+)?$/.test(value);
}

...as suggested in many posts/questions regarding this issue (e.g.: here or here).

Strangely enough:

When I try to submit a value such as 123,45, and even though I've debugged the script with Firebug and seen that my overrode functions are being called and returning true, I'm not being able to submit the form. Instead, my EditorFor for the decimal value is being focused for whatever reason and I can't seem to find out why.

(I believe that my server-side validation - using a custom binder, etc. - is working fine and that is not the issue here: I would like some help on how to get the form to submit or why is the input field getting focused even though it looks valid.)

EDIT 1:

Additional info. In my BundlesConfig.cs:

bundles.Add(new ScriptBundle("~/bundles/jquery").Include(
                    "~/Scripts/jquery-{version}.js"));
...
bundles.Add(new ScriptBundle("~/bundles/jqueryui").Include(
                    "~/Scripts/jquery-ui-{version}.js"));
bundles.Add(new ScriptBundle("~/bundles/jqueryval").Include(
                    "~/Scripts/jquery.unobtrusive*",
                    "~/Scripts/jquery.validate*"));
bundles.Add(new ScriptBundle("~/bundles/jQueryFixes").Include(
                    "~/Scripts/jQueryFixes.js")); 
...

EDIT 2:

After @LeftyX suggestion, I tried using the Globalize script (after removing my jQueryFixes.js):

<script type="text/javascript">
    ...

    $( document ).ready(function() {
        Globalize.culture("en-US", "pt-PT");
    });

    $.validator.methods.number = function (value, element) {
        return this.optional(element) || jQuery.isNumeric(Globalize.parseFloat(value));
    }

    //Fix the range to use globalized methods
    jQuery.extend(jQuery.validator.methods, {
        range: function (value, element, param) {
            var val = Globalize.parseFloat(value);
            return this.optional(element) || (val >= param[0] && val <= param[1]);
        }
    });

   ...
</script>

...but I still face the same issue: the validator.methods.number is returning true but the form isn't submitted and the input field gets focused instead.

If I inspect the input element while I submit, I can see that it quickly goes from class="valid" to class='input-validation-error' and then back to valid. Very strange.

CONCLUSION:

@LeftyX gave a very good and complete solution for whoever finds the same issues.

I already had a custom model binder for the nullable decimals, but the globalization script and including the culture in the Model/ViewModel sure comes in handy.

Another reason for my problem could be the fact that I was (accidentally) including some scripts twice.

UPDATE (Jul/2015):

globalize.js is a bit different now. Ref. to this answer and the documentation for the updated steps.

like image 856
user1987392 Avatar asked Nov 28 '13 10:11

user1987392


2 Answers

I've struggled quite a bit with this.

The best approach for me is to define a custom binder for decimals:

public class DecimalModelBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext,
        ModelBindingContext bindingContext)
    {
        ValueProviderResult valueResult = bindingContext.ValueProvider
            .GetValue(bindingContext.ModelName);
        ModelState modelState = new ModelState { Value = valueResult };
        object actualValue = null;
        try
        {
            //Check if this is a nullable decimal and a null or empty string has been passed
            var isNullableAndNull = (bindingContext.ModelMetadata.IsNullableValueType &&
                                     string.IsNullOrEmpty(valueResult.AttemptedValue));

            //If not nullable and null then we should try and parse the decimal
            if (!isNullableAndNull)
            {
                actualValue = Convert.ToDecimal(valueResult.AttemptedValue, CultureInfo.CurrentCulture);
            }
        }
        catch (FormatException e)
        {
            modelState.Errors.Add(e);
        }

        bindingContext.ModelState.Add(bindingContext.ModelName, modelState);
        return actualValue;
    }
}

and bind it in the Application_Start in Global.asax:

ModelBinders.Binders.Add(typeof(decimal), new DecimalModelBinder());
ModelBinders.Binders.Add(typeof(decimal?), new DecimalModelBinder());

I also use the globalize script (with cultures) which can be found here or downloaded from nuget here.

Your bundle should look something like this:

bundles.Add(ScriptBundle("~/bundles/jquery").Include(
    "~/Scripts/jquery-{version}.js",
    "~/Scripts/globalize.js",
    "~/Scripts/cultures/globalize.culture.en-GB.js",
    "~/Scripts/cultures/globalize.culture.it-IT.js"
    ));

Of course you can add more culures if you want to support different localizations.

Now, when your DOM is ready (javascript) you can define your culture:

Globalize.culture('en-GB');

$.validator.methods.number = function (value, element) {
    return this.optional(element) || jQuery.isNumeric(Globalize.parseFloat(value));
}

//Fix the range to use globalized methods
jQuery.extend(jQuery.validator.methods, {
    range: function (value, element, param) {
    var val = Globalize.parseFloat(value);
    return this.optional(element) || (val >= param[0] && val <= param[1]);
    }
});

$.validator.methods.date = function (value, element) {
    return (this.optional(element) || Globalize.parseDate(value));
}

and customize your validations (I've added the date as well). You've done that in your jQueryFixes.

You can find a working example here (MvcAppForDecimals) where you can change languages using a toolbar and cookies so that the culture can change on the server as well.

In the example I read the cookie in Application_BeginRequest or use the default culture define in the web.config:

<globalization enableClientBasedCulture="true" uiCulture="en-GB" culture="en-GB" />

I've also defined a ActionFilter (LanguageFilterAttribute) which injects the current culture in a base viewmodel so the client uses the current set on the server side.

An extended explanation can be found here.

Some more info about the globalize script and culture settings here.

like image 60
LeftyX Avatar answered Oct 30 '22 08:10

LeftyX


Just a small update regarding globalize.js. Things are a bit different (and confusing) now:

Include the scripts as follows:

<!--
First, we load Globalize's dependencies (`cldrjs` and its supplemental
module).
-->
<script type="text/javascript" src="~/Scripts/cldr.js"></script>
<script type="text/javascript" src="~/Scripts/cldr/event.js"></script>
<script type="text/javascript" src="~/Scripts/cldr/supplemental.js"></script>

<!--
Next, we load Globalize and its modules.
-->
<script type="text/javascript" src="~/Scripts/globalize.js"></script>
<script type="text/javascript" src="~/Scripts/globalize/number.js"></script>

Now we need to load the I18n content onto Globalize:

<script type='text/javascript'>

    // At this point, we have Globalize loaded. But, before we can use it, we
    // need to feed it on the appropriate I18n content (Unicode CLDR). In order
    // to do so, we use `Globalize.load()` and pass the content.

    $.when($.getJSON('/Scripts/cldr/supplemental/likelySubtags.json'),
               $.getJSON('/Scripts/cldr/main/en-GB/numbers.json'),
               ...other locales...
               $.getJSON('/Scripts/cldr/supplemental/numberingSystems.json'))
        .done(function (result1, result2, result3, result4) {
            Globalize.load(result1[0]); //contains data of first executed request
            ...load the other ones...
            Globalize.load(result3[0]); //contains data of third executed request
            Globalize.load(result4[0]); //contains data of fourth executed request

            var glob = Globalize([YOUR-LOCALE]); // e.g. en-UK, pt-PT, pt-BR, es-ES, etc.

            $.validator.methods.number = function (value, element) {
                var number = glob.parseNumber(value);
                return this.optional(element) || jQuery.isNumeric(number);
            }

            //Fix the range to use globalized methods
            jQuery.extend(jQuery.validator.methods, {
                range: function (value, element, param) {
                    var val = glob.formatNumber(value);
                    return this.optional(element) || (val >= param[0] && val <= param[1]);
                }

        });
    });

</script>

The I18n content is available as JSON here.

If you get 404 on your getJSON(), remember to add:

<system.webServer>

    (...)

    <staticContent>
      <mimeMap fileExtension=".json" mimeType="application/json" />
    </staticContent>

</system.webServer>

in your Web.config (ASP .NET MVC application).

like image 1
user1987392 Avatar answered Oct 30 '22 06:10

user1987392