1

Is there a touchpoint I can use in ASP.NET MVC5.2.2 to extend the HTML that is generated for a range-constrained integer property of a viewmodel?

I have an integer property in a view model class, whose range is to be constrained between 1 and 999. I want to use the HTML5 min and max attributes so that the browser-supplied editor doesn't let the user enter a value out of that range.

e.g. in Chrome, <input type="number" min="1" max="999" /> gives me a field with a spinner that is limited to values 1 to 999.

Currently, jquery unobtrusive validation is enforcing the rule, highlighting values that are out of range, but it would be better if the spinner didn't create invalid values in the first place.

Viewmodel class:

public class MyViewModel
{
  [Required]
  [Range(1, 999)]
  public int? ValueInRange
  {
      get;
      set;
  }
}

cshtml for ValueInRange editor:

@Html.LabelFor(m => m.ValueInRange)
@Html.EditorFor(m => m.ValueInRange)
@Html.ValidationMessageFor(m => m.ValueInRange)

Generated HTML:

<input type="number" value="0" id="ValueInRange" name="ValueInRange"
    class="form-control text-box single-line" 
    data-val="true" 
    data-val-number="The field ValueInRange must be a number." 
    data-val-range="Please select a value between 1 and 999" 
    data-val-range-max="999" 
    data-val-range-min="1" 
    data-val-required="ValueInRange is required" />

Is there any existing way to add min and max attributes to the generated output which pick up the values declared in the property's Range attribute? I know I can manually add the attributes as part of the additionalViewData parameter of EditorFor, but that is duplication and offers the capacity for drift between the cshtml and the viewmodel code.

I'd rather not have to write an EditorFor template from scratch.....

Neil Moss
  • 6,598
  • 2
  • 26
  • 42
  • 1
    Not using the inbuilt templates (you would need to hard code them using `@Html.EditorFor(m => m.GroupSize, new { htmlAttributes = new { min = 1, max = 999 } })`. You could write your own extension method that reads the values from the `[Range]` and adds the `min` and `max` attributes, or you could use javascript/jQuery to read the `data-val-*` attributes and add the `min` and `max` attributes to the html –  Oct 21 '17 at 22:14
  • I like the jQuery approach - in my opinion, that comment is worthy of an answer. Thank you. – Neil Moss Oct 22 '17 at 14:04

4 Answers4

2

There is nothing out of the box to do this, but you could either create you own extension method, or use javascript/jQuery to add the min and max attributes.

An extension method might look like

public static MvcHtmlString NumericRangeInputFor<TModel, TProperty>(this HtmlHelper<TModel> helper, Expression<Func<TModel, TProperty>> expression, object htmlAttributes = null)
{
    // Add type="number"
    var attributes = HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes);
    attributes.Add("type", "number");
    // Check for a [Range] attribute
    ModelMetadata metadata = ModelMetadata.FromLambdaExpression(expression, helper.ViewData);
    var property = metadata.ContainerType.GetProperty(metadata.PropertyName);
    RangeAttribute range = property.GetCustomAttributes(typeof(RangeAttribute), false).First() as RangeAttribute;
    if (range != null)
    {
        attributes.Add("min", range.Minimum);
        attributes.Add("max", range.Maximum);
    }
    return helper.TextBoxFor(expression, attributes);
}

and you could enhance this by checking the the property type is a numeric type (for example these answers), adding a parameter for the step attribute etc.

Using jQuery, you can check for a data-val-range attribute, and add the values based on the data-val-range-min and data-val-range-max attributes

var numberInputs = $('[type="number"]');
$.each(numberInputs, function(index, item){
    if ($(this).data('val-range')) {
        var min = $(this).data('val-range-min');
        var max = $(this).data('val-range-max');
        $(this).attr('min', min);
        $(this).attr('max', max);
    }
})
2

I've extracted the range validator from .net corefx and created a custom one, because it's hard to add two lines of code for microsoft guys. Just save it somewhere, adjust the namespace accordingly and include it in your models as [Html5Range()] attirbute. Don't like the JS way.

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;

namespace Admin.Validators
{
    public class Html5RangeAttribute : ValidationAttribute, IClientModelValidator
    {
        private  string _max;
        private  string _min;

        private object Minimum { get;  set; }
        private object Maximum { get;  set; }

        private Type OperandType { get; }
        private bool ParseLimitsInInvariantCulture { get; set; }
        private bool ConvertValueInInvariantCulture { get; set; }

        public Html5RangeAttribute(int minimum, int maximum)
        {
            Minimum = minimum;
            Maximum = maximum;
            OperandType = typeof(int);
        }

        public Html5RangeAttribute(double minimum, double maximum)
        {
            Minimum = minimum;
            Maximum = maximum;
            OperandType = typeof(double);
        }

        public Html5RangeAttribute(Type type, string minimum, string maximum)
        {
            OperandType = type;
            Minimum = minimum;
            Maximum = maximum;
        }       

        private Func<object, object> Conversion { get; set; }

        private void Initialize(IComparable minimum, IComparable maximum, Func<object, object> conversion)
        {
            if (minimum.CompareTo(maximum) > 0)
            {
                throw new InvalidOperationException(string.Format("The maximum value '{0}' must be greater than or equal to the minimum value '{1}'.", maximum, minimum));
            }

            Minimum = minimum;
            Maximum = maximum;
            Conversion = conversion;
        }

        public override bool IsValid(object value)
        {            
            SetupConversion();

            if (value == null || (value as string)?.Length == 0)
            {
                return true;
            }

            object convertedValue;

            try
            {
                convertedValue = Conversion(value);
            }
            catch (FormatException)
            {
                return false;
            }
            catch (InvalidCastException)
            {
                return false;
            }
            catch (NotSupportedException)
            {
                return false;
            }

            var min = (IComparable)Minimum;
            var max = (IComparable)Maximum;
            return min.CompareTo(convertedValue) <= 0 && max.CompareTo(convertedValue) >= 0;
        }

        public override string FormatErrorMessage(string name)
        {
            SetupConversion();

            return string.Format(CultureInfo.CurrentCulture, ErrorMessageString, name, Minimum, Maximum);
        }

        private void SetupConversion()
        {
            if (Conversion == null)
            {
                object minimum = Minimum;
                object maximum = Maximum;

                if (minimum == null || maximum == null)
                {
                    throw new InvalidOperationException("The minimum and maximum values must be set.");
                }

                // Careful here -- OperandType could be int or double if they used the long form of the ctor.
                // But the min and max would still be strings.  Do use the type of the min/max operands to condition
                // the following code.
                Type operandType = minimum.GetType();

                if (operandType == typeof(int))
                {
                    Initialize((int) minimum, (int) maximum, v => Convert.ToInt32(v, CultureInfo.InvariantCulture));
                }
                else if (operandType == typeof(double))
                {
                    Initialize((double) minimum, (double) maximum,
                        v => Convert.ToDouble(v, CultureInfo.InvariantCulture));
                }
                else
                {
                    Type type = OperandType;
                    if (type == null)
                    {
                        throw new InvalidOperationException("The OperandType must be set when strings are used for minimum and maximum values.");
                    }

                    Type comparableType = typeof(IComparable);
                    if (!comparableType.IsAssignableFrom(type))
                    {
                        throw new InvalidOperationException(string.Format("The type {0} must implement {1}.",
                            type.FullName,
                            comparableType.FullName));
                    }

                    TypeConverter converter = TypeDescriptor.GetConverter(type);
                    IComparable min = (IComparable) (ParseLimitsInInvariantCulture
                        ? converter.ConvertFromInvariantString((string) minimum)
                        : converter.ConvertFromString((string) minimum));
                    IComparable max = (IComparable) (ParseLimitsInInvariantCulture
                        ? converter.ConvertFromInvariantString((string) maximum)
                        : converter.ConvertFromString((string) maximum));

                    Func<object, object> conversion;
                    if (ConvertValueInInvariantCulture)
                    {
                        conversion = value => value.GetType() == type
                            ? value
                            : converter.ConvertFrom(null, CultureInfo.InvariantCulture, value);
                    }
                    else
                    {
                        conversion = value => value.GetType() == type ? value : converter.ConvertFrom(value);
                    }

                    Initialize(min, max, conversion);
                }
            }
        }

        public  void AddValidation(ClientModelValidationContext context)
        {
            if (context == null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            _max = Convert.ToString(Maximum, CultureInfo.InvariantCulture);
            _min = Convert.ToString(Minimum, CultureInfo.InvariantCulture);

            MergeAttribute(context.Attributes, "data-val", "false");
            MergeAttribute(context.Attributes, "data-val-range", GetErrorMessage(context));
            MergeAttribute(context.Attributes, "data-val-range-max", _max);
            MergeAttribute(context.Attributes, "data-val-range-min", _min);
            MergeAttribute(context.Attributes, "min", _min);
            MergeAttribute(context.Attributes, "max", _max);
        }

        private static  bool MergeAttribute(IDictionary<string, string> attributes, string key, string value)
        {
            if (attributes.ContainsKey(key))
            {
                return false;
            }

            attributes.Add(key, value);
            return true;
        }

        private string GetErrorMessage(ModelValidationContextBase validationContext)
        {
            if (validationContext == null)
            {
                throw new ArgumentNullException(nameof(validationContext));
            }

            return string.Format("The field {0} must be between {1} and {2}.", validationContext.ModelMetadata.GetDisplayName(), Minimum, Maximum );
        }
    }
    }
Peter Húbek
  • 646
  • 6
  • 10
0

ES5 vanilla js version:

(function () {
    var inputNumberElements = document.querySelectorAll('input[type="number"]');
    
    Object.keys(inputNumberElements).forEach(function (key) {
        var inputNumberElement = inputNumberElements[key];
        var dataValRangeMin = inputNumberElement.getAttribute("data-val-range-min");
        var dataValRangeMax = inputNumberElement.getAttribute("data-val-range-max");
        
        if(dataValRangeMin && dataValRangeMax){
            inputNumberElement.setAttribute("min", dataValRangeMin);
            inputNumberElement.setAttribute("max", dataValRangeMax);
        }
    });    
})();
NPovlsen
  • 337
  • 1
  • 16
Peter Húbek
  • 646
  • 6
  • 10
0

I think this method is effective.

  @Html.EditorFor(model => model.ValueInRange, new {  
    htmlAttributes = new { 
       @class = "form-control", min="1" 
        } 
    })
Dharmeshsharma
  • 683
  • 1
  • 11
  • 28
farsam
  • 89
  • 1
  • 4