Wednesday, March 8, 2017

Posting Sitecore MVC forms with Html.FormHandler while maintaining rendering context.

Recently I have run into a situation where I had to post back a form that was part of controller rendering. I was using Html.FormHandler with controller and action names specified. If the form doesn't have any validation errors, it works just fine, but if the server side validation fails on the post back, you will see several issues:
  • If you return View in the controller rendering, you will see just the view returned without layout. Martina Welander described this scenario in her blog post at https://mhwelander.net/2014/05/30/posting-forms-in-sitecore-mvc-part-2-controller-renderings/
  • If you have any placeholders in the view for your controller rendering, the placeholder will throw an exception.
There are a couple of solutions that have been described in various resources, but none of them solved the issue that I was facing to my liking, so I came up with my own one. It might be more radical than others, but worked perfectly for the issues that I was trying to solve.

By default if you use Html.FormHandler with controller and action names specified, Sitecore executes ExecuteFormHandler pipeline processor where ControllerRunner is being executed. The result is being saved into the output stream and "scOutputGenerated" route value is being set to 1. Setting these values excludes rendering from being executed executed and rendering context for it from being set. As a result placeholder that the view controller returns, might throw an exception.

Here is the overview of solution that I had arrived at.
  1. By default sitecore FormHandler helper method renders scController and scAction hidden fields. I added another hidden field to the form called scRenderingUniqueId where I render rendering ID.

    @Html.CustomSitecore().FormHandlerRendering(Model.Rendering)

    Custom Helper:
        public class CustomSitecoreHelper : Sitecore.Mvc.Helpers.SitecoreHelper
        {
            protected HtmlHelper HtmlHelper { get; set; }
            public CustomSitecoreHelper(HtmlHelper htmlHelper)
                : base(htmlHelper)
            {
                Assert.ArgumentNotNull((object)htmlHelper, "htmlHelper"); 
                this.HtmlHelper = htmlHelper;
            }
            public virtual HtmlString FormHandlerRendering(Rendering rendering)
            {
                if (rendering == null)
                    return new HtmlString(string.Empty);
                var str = this.HtmlHelper.Hidden("scRenderingUniqueId", rendering.UniqueId).ToString();
                return new HtmlString(str);
            }
        }
    


  2. I replaced ExecuteFormHandler pipeline processor with a custom version. That allowed my form post rendering to be processed in the same manner as all other renderings if scRenderingUniqueId is specified. As a result renderings with rendering ID specified in a hidden field rendering context was created.

    <mvc.requestBegin>
        <processor type="Neb.SitecoreExtensions.Pipelines.Request.RequestBegin.CustomExecuteFormHandler, Neb.SitecoreExtensions"  patch:instead="processor[@type='Sitecore.Mvc.Pipelines.Request.RequestBegin.ExecuteFormHandler, Sitecore.Mvc']"/>
    </mvc.requestBegin>

    public class CustomExecuteFormHandler : RequestBeginProcessor
        {
            public override void Process(RequestBeginArgs args)
            {
                HttpContextBase httpContext = args.PageContext.RequestContext.HttpContext;
                if (WebHelper.GetRequestType(httpContext) != HttpVerbs.Post)
                    return;
                this.ExecuteHandler(httpContext.Request.Form, args);
            }
    
            protected virtual void ExecuteHandler(NameValueCollection formValues, RequestBeginArgs args)
            {
                if (!string.IsNullOrEmpty(formValues["scRenderingUniqueId"]))
                    return;
                Tuple<string, string> controllerAndAction = MvcSettings.ControllerLocator.GetControllerAndAction(formValues["scController"].OrIfEmpty(MvcSettings.DefaultFormControllerName), formValues["scAction"]);
                if (controllerAndAction == null)
                    return;
                ExecuteHandler(controllerAndAction.Item1, controllerAndAction.Item2, args);
            }
    
            protected virtual void ExecuteHandler(string controllerName, string actionName, RequestBeginArgs args)
            {
                string str = new ControllerRunner(controllerName, actionName).Execute();
                if (str.IsEmptyOrNull())
                    return;
                RequestContext requestContext = PageContext.Current.RequestContext;
                requestContext.HttpContext.Response.Output.Write(str);
                requestContext.RouteData.Values["scOutputGenerated"] = (object)"1";
            }
        }
    


  3. I added a new GetPostControllerRenderer pipeline processor to mvc.getRenderer pipeline after GetControllerRenderer. 

    <mvc.getRenderer>
            <processor patch:after="*[@type='Sitecore.Mvc.Pipelines.Response.GetRenderer.GetControllerRenderer, Sitecore.Mvc']" type="JCore.SitecoreExtensions.Pipelines.Response.GetRenderer.GetPostControllerRenderer, JCore.SitecoreExtensions" />
    </mvc.getRenderer>


    GetPostControllerRenderer pipeline processor:

    public class GetPostControllerRenderer : GetRendererProcessor
    {
            public override void Process(GetRendererArgs args)
            {
                HttpContextBase httpContext = args.PageContext.RequestContext.HttpContext;
                if (WebHelper.GetRequestType(httpContext) != HttpVerbs.Post)
                    return;
                var result = this.GetRenderer(args.Rendering, args, httpContext.Request.Form);
                if (result != null)
                    args.Result = result;
            }
    
            protected virtual Tuple<string, string> GetControllerAndAction(Rendering rendering, GetRendererArgs args, NameValueCollection formValues)
            {
                Tuple<string, string> controllerAndAction = MvcSettings.ControllerLocator.GetControllerAndAction(formValues["scController"].OrIfEmpty(MvcSettings.DefaultFormControllerName), formValues["scAction"]);
                if (controllerAndAction == null)
                    return (Tuple<string, string>)null;
                return MvcSettings.ControllerLocator.GetControllerAndAction(controllerAndAction.Item1, controllerAndAction.Item2);
            }
    
            protected virtual Renderer GetRenderer(Rendering rendering, GetRendererArgs args, NameValueCollection formValues)
            {
                Tuple<string, string> controllerAndAction = this.GetControllerAndAction(rendering, args, formValues);
                if (controllerAndAction == null)
                    return (Renderer)null;
                string str1 = controllerAndAction.Item1;
                string str2 = controllerAndAction.Item2;
                if (args.Result is ControllerRenderer && rendering != null)
                {
                    if (rendering.UniqueId.ToString() != formValues["scRenderingUniqueId"])
                        return (Renderer)null;
    
                    return (Renderer)new ControllerRenderer()
                    {
                        ControllerName = str1,
                        ActionName = str2
                    };
                }
                return (Renderer)null;
            }
    }
    

  4. To eliminate errors due to rendering context not being set, I also replaced RenderAddedContent with a custom version where I check if args.OwnerRendering and args.OwnerRendering.Properties are null to prevent exception that out-of-the-box version throws when placeholder is being rendered without rendering context.
After above changes were implemented the code stopped throwing exceptions due to rendering context being null and requests are being submitted to the proper controller action.



No comments:

Post a Comment