Turns out that it was a field ID naming issue with the way that collection IDs render in a variable length list. The validator was attempting to name the element ID of the dependent property with the expected statement of:
string depProp = viewContext.ViewData.TemplateInfo.GetFullHtmlFieldId(Attribute.DependentProperty);
I analyzed the HTML viewsource (posted in my comment, above), and actually, [ and ] characters are not output in the HTML of the collection-index elements... they're replaced with _... so, when I changed my CustomValidator.cs to have the dependent property set to:
string depProp = viewContext.ViewData.TemplateInfo.GetFullHtmlFieldId(Attribute.DependentProperty).Replace("[", "").Replace("]", "");
... then the client-side validator works since the name matches. I'll have to dig deeper to see WHY the ID is getting renamed in Sanderson's collection index method, below...
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
namespace Sendz.WebUI.Helpers
{
public static class HtmlPrefixScopeExtensions
{
private const string IdsToReuseKey = "__htmlPrefixScopeExtensions_IdsToReuse_";
public static IDisposable BeginCollectionItem(this HtmlHelper html, string collectionName)
{
var idsToReuse = GetIdsToReuse(html.ViewContext.HttpContext, collectionName);
var itemIndex = idsToReuse.Count > 0 ? idsToReuse.Dequeue() : Guid.NewGuid().ToString();
// autocomplete="off" is needed to work around a very annoying Chrome behaviour whereby it reuses old values after the user clicks "Back", which causes the xyz.index and xyz[...] values to get out of sync.
html.ViewContext.Writer.WriteLine(
string.Format("<input type=\"hidden\" name=\"{0}.index\" autocomplete=\"off\" value=\"{1}\" />",
collectionName, html.Encode(itemIndex)));
return BeginHtmlFieldPrefixScope(html, string.Format("{0}[{1}]", collectionName, itemIndex));
}
public static IDisposable BeginHtmlFieldPrefixScope(this HtmlHelper html, string htmlFieldPrefix)
{
return new HtmlFieldPrefixScope(html.ViewData.TemplateInfo, htmlFieldPrefix);
}
private static Queue<string> GetIdsToReuse(HttpContextBase httpContext, string collectionName)
{
// We need to use the same sequence of IDs following a server-side validation failure,
// otherwise the framework won't render the validation error messages next to each item.
var key = IdsToReuseKey + collectionName;
var queue = (Queue<string>)httpContext.Items[key];
if (queue == null)
{
httpContext.Items[key] = queue = new Queue<string>();
var previouslyUsedIds = httpContext.Request[collectionName + ".index"];
if (!string.IsNullOrEmpty(previouslyUsedIds))
foreach (var previouslyUsedId in previouslyUsedIds.Split(','))
queue.Enqueue(previouslyUsedId);
}
return queue;
}
#region Nested type: HtmlFieldPrefixScope
private class HtmlFieldPrefixScope : IDisposable
{
private readonly string _previousHtmlFieldPrefix;
private readonly TemplateInfo _templateInfo;
public HtmlFieldPrefixScope(TemplateInfo templateInfo, string htmlFieldPrefix)
{
_templateInfo = templateInfo;
_previousHtmlFieldPrefix = templateInfo.HtmlFieldPrefix;
templateInfo.HtmlFieldPrefix = htmlFieldPrefix;
}
#region IDisposable Members
public void Dispose()
{
_templateInfo.HtmlFieldPrefix = _previousHtmlFieldPrefix;
}
#endregion
}
#endregion
}
}
A complete validator / attribute reference...
public class RequiredIfAttribute : ValidationAttribute
{
private RequiredAttribute innerAttribute = new RequiredAttribute();
public string DependentProperty { get; set; }
public object TargetValue { get; set; }
public RequiredIfAttribute(string dependentProperty, object targetValue)
{
this.DependentProperty = dependentProperty;
this.TargetValue = targetValue;
}
public override bool IsValid(object value)
{
return innerAttribute.IsValid(value);
}
}
public RequiredIfValidator(ModelMetadata metadata, ControllerContext context, RequiredIfAttribute attribute)
: base(metadata, context, attribute) { }
public override IEnumerable<ModelValidationResult> Validate(object container)
{
// Get a reference to the property this validation depends upon
var field = Metadata.ContainerType.GetProperty(Attribute.DependentProperty);
if (field != null)
{
// Get the value of the dependent property
var value = field.GetValue(container, null);
// Compare the value against the target value
if ((value == null && Attribute.TargetValue == null) ||
(value != null && value.ToString().ToLowerInvariant().Equals(Attribute.TargetValue.ToString().ToLowerInvariant())))
{
// A match => means we should try validating this field
if (!Attribute.IsValid(Metadata.Model))
// Validation failed - return an error
yield return new ModelValidationResult { Message = ErrorMessage };
}
}
}
public override IEnumerable<ModelClientValidationRule> GetClientValidationRules()
{
var rule = new ModelClientValidationRule()
{
ErrorMessage = ErrorMessage,
ValidationType = "requiredif"
};
var viewContext = (ControllerContext as ViewContext);
var depProp = viewContext.ViewData.TemplateInfo.GetFullHtmlFieldId(Attribute.DependentProperty).Replace("[", "_").Replace("]", "_");
rule.ValidationParameters.Add("dependentProperty", depProp);
rule.ValidationParameters.Add("targetValue", Attribute.TargetValue.ToString());
yield return rule;
}