24

If I have an Action like this:

public ActionResult DoStuff(List<string> stuff)
{
   ...
   ViewData["stuff"] = stuff;
   ...
   return View();
}

I can hit it with the following URL:

http://mymvcapp.com/controller/DoStuff?stuff=hello&stuff=world&stuff=foo&stuff=bar

But in my ViewPage, I have this code:

<%= Html.ActionLink("click here", "DoMoreStuff", "MoreStuffController", new { stuff = ViewData["stuff"] }, null) %>

Unfortunately, MVC is not smart enough to recognize that the action takes an array, and unrolls the list to form the proper url route. instead it just does a .ToString() on the object which just lists the data type in the case of a List.

Is there a way to get Html.ActionLink to generate a proper URL when one of the destination Action's parameters is an array or list?

-- edit --

As Josh pointed out below, ViewData["stuff"] is just an object. I tried to simplify the problem but instead caused an unrelated bug! I'm actually using a dedicated ViewPage<T> so I have a tightly coupled type aware Model. The ActionLink actually looks like:

<%= Html.ActionLink("click here", "DoMoreStuff", "MoreStuffController", new { stuff = ViewData.Model.Stuff }, null) %>

Where ViewData.Model.Stuff is typed as a List

puffpio
  • 3,402
  • 6
  • 36
  • 41
  • 1
    ViewData["stuff"] is just an object. What happens when you pass in a real list, like {Stuff= (List)ViewData["stuff"]} or {Stuff= ViewData["stuff"] as List} or {Stuff= new List(...)}? – Josh Pearce Nov 18 '09 at 00:04
  • 1
    same problem... In my actual implementation currently, I use tightly coupled ViewPage so that line looks more like: <%= Html.ActionLink("click here", "DoMoreStuff", "MoreStuffController", new { stuff = ViewData.Model.Stuff }, null) %> where ViewData.Model.Stuff is typed as a List – puffpio Nov 18 '09 at 00:13

6 Answers6

21

I'm thinking that a custom HtmlHelper would be in order.

 public static string ActionLinkWithList( this HtmlHelper helper, string text, string action, string controller, object routeData, object htmlAttributes )
 {
     var urlHelper = new UrlHelper( helper.ViewContext.RequestContext );


     string href = urlHelper.Action( action, controller );

     if (routeData != null)
     {
         RouteValueDictionary rv = new RouteValueDictionary( routeData );
         List<string> urlParameters = new List<string>();
         foreach (var key in rv.Keys)
         {
             object value = rv[key];
             if (value is IEnumerable && !(value is string))
             {
                 int i = 0;
                 foreach (object val in (IEnumerable)value)
                 {
                     urlParameters.Add( string.Format( "{0}[{2}]={1}", key, val, i ));
                     ++i;
                 }
             }
             else if (value != null)
             {
                 urlParameters.Add( string.Format( "{0}={1}", key, value ) );
             }
         }
         string paramString = string.Join( "&", urlParameters.ToArray() ); // ToArray not needed in 4.0
         if (!string.IsNullOrEmpty( paramString ))
         {
            href += "?" + paramString;
         }
     }

     TagBuilder builder = new TagBuilder( "a" );
     builder.Attributes.Add("href",href);
     builder.MergeAttributes( new RouteValueDictionary( htmlAttributes ) );
     builder.SetInnerText( text );
     return builder.ToString( TagRenderMode.Normal );
}
tvanfosson
  • 524,688
  • 99
  • 697
  • 795
  • Yeah, this will definitely work. On a more general note, what level of compatibility should be expect between Url generators like UrlHelper.Action and the action parameter binding? I mean, is this a bug or what? – Josh Pearce Nov 18 '09 at 00:40
  • No. Not a bug. I would call it a design decision. The case where you have multiple values for a parameter is pretty rare, especially on a get. I think the expectation that the object is a map of simple value types serves most people well. – tvanfosson Nov 18 '09 at 03:06
  • Thanks for this, it got my thinking in the right direction. It won't exactly work because a string is an IEnumerable that will enumerate chars. It also makes all the route params querystring params, whereas the original ActionLink takes into account the Routes that are created in global.asax.cs to format route parameters REST style that support it. – puffpio Nov 19 '09 at 00:28
  • I took your idea, and I used LINQ to give me all the routedata items that as IEnumerable..and generated a partial querystring with that. I then took the remaining route data, and add another entry to it like "___replacemequerystringname___", "___replacmequerystringvalue___" I then used the regular ActionLink method with the remaining route data. Then I replace that above route entry which has been transformed into a querystring parameter with my aforementioned partial querystring – puffpio Nov 19 '09 at 00:30
  • Hmmm. Maybe check if it implements IList instead of IEnumerable? Or specifically exclude string. – tvanfosson Nov 19 '09 at 02:21
  • @tvanfosson in your inner most foreach loop do, you have `val` and `i` swapped? – Brian Sweeney Mar 02 '16 at 23:24
  • @BrianSweeney I don't think so, the parameters are ordered 0, 2, 1 so that the value of `i` goes inside the brackets and the value of `val` on the right of the equals sign. – tvanfosson Mar 03 '16 at 03:57
  • Works perfectly after I changed the return type to MvcHtmlString – Patrick Koorevaar Dec 09 '19 at 12:55
4

you can suffix your routevalues with an array index like so:

RouteValueDictionary rv = new RouteValueDictionary();
rv.Add("test[0]", val1);
rv.Add("test[1]", val2);

this will result in the querystring containing test=val1&test=val2

that might help ?

Nerdroid
  • 13,398
  • 5
  • 58
  • 69
4

Combining both methods works nicely.

public static RouteValueDictionary FixListRouteDataValues(RouteValueDictionary routes)
{
    var newRv = new RouteValueDictionary();
    foreach (var key in routes.Keys)
    {
        object value = routes[key];
        if (value is IEnumerable && !(value is string))
        {
            int index = 0;
            foreach (string val in (IEnumerable)value)
            {
                newRv.Add(string.Format("{0}[{1}]", key, index), val);
                index++;
            }
        }
        else
        {
            newRv.Add(key, value);
        }
    }

    return newRv;
}

Then use this method in any extension method that requires routeValues with IEnumerable(s) in it.

Sadly, this workaround seams to be needed in MVC3 too.

Nerdroid
  • 13,398
  • 5
  • 58
  • 69
Emanuel
  • 610
  • 6
  • 15
1

This will just act as an extension to the UrlHelper and just provide a nice url ready to put anywhere rather than an an entire a tag, also it will preserve most of the other route values for any other specific urls being used... giving you the most friendly specific url you have (minus the IEnumerable values) and then just append the query string values at the end.

public static string ActionWithList(this UrlHelper helper, string action, object routeData)
{

    RouteValueDictionary rv = new RouteValueDictionary(routeData);

    var newRv = new RouteValueDictionary();
    var arrayRv = new RouteValueDictionary();
    foreach (var kvp in rv)
    {
        var nrv = newRv;
        var val = kvp.Value;
        if (val is IEnumerable && !(val is string))
        {
            nrv = arrayRv;
        }

        nrv.Add(kvp.Key, val);

    }


    string href = helper.Action(action, newRv);

    foreach (var kvp in arrayRv)
    {
        IEnumerable lst = kvp.Value as IEnumerable;
        var key = kvp.Key;
        foreach (var val in lst)
        {
            href = href.AddQueryString(key, val);
        }

    }
    return href;
}

public static string AddQueryString(this string url, string name, object value)
{
    url = url ?? "";

    char join = '?';
    if (url.Contains('?'))
        join = '&';

    return string.Concat(url, join, name, "=", HttpUtility.UrlEncode(value.ToString()));
}   
Nerdroid
  • 13,398
  • 5
  • 58
  • 69
tocsoft
  • 1,709
  • 16
  • 22
0

There is a librarly called Unbinder, which you can use to insert complex objects into routes/urls.

It works like this:

using Unbound;

Unbinder u = new Unbinder();
string url = Url.RouteUrl("routeName", new RouteValueDictionary(u.Unbind(YourComplexObject)));
Krisztián Balla
  • 19,223
  • 13
  • 68
  • 84
-2

I'm not at my workstation, but how about something like:

<%= Html.ActionLink("click here", "DoMoreStuff", "MoreStuffController", new { stuff = (List<T>)ViewData["stuff"] }, null) %>

or the typed:

<%= Html.ActionLink("click here", "DoMoreStuff", "MoreStuffController", new { stuff = (List<T>)ViewData.Model.Stuff }, null) %>
Chaddeus
  • 13,134
  • 29
  • 104
  • 162