29

I have a View Component that contains some jQuery in the Razor (.cshtml) file. The script itself is quite specific to the view (deals with some some configuration of third-party libraries), so I would like to keep the script and HTML in the same file for organization's sake.

The problem is that the script is not rendered in the _Layout Scripts section. Apparently this is just how MVC handles scripts with regards to View Components.

I can get around it by just having the scripts in the Razor file, but not inside of the Scripts section.

But then I run into dependency issues - because jQuery is used before the reference to the library (the reference to the library is near the bottom of the _Layout file).

Is there any clever solution to this other than including the reference to jQuery as part of the Razor code (which would impede HTML rendering where ever the component is placed)?

I'm currently not in front of the code, but I can certainly provide it once I get the chance if anyone needs to look at it to better understand it.

Max Jacob
  • 701
  • 1
  • 9
  • 20

5 Answers5

34

What I decided to do is write a ScriptTagHelper that provides an attribute of "OnContentLoaded". If true, then I wrap the inner contents of the script tag with a Javascript function to execute once the document is ready. This avoids the problem with the jQuery library having not loaded yet when the ViewComponent's script fires.

[HtmlTargetElement("script", Attributes = "on-content-loaded")]
public class ScriptTagHelper : TagHelper
{
  /// <summary>
  /// Execute script only once document is loaded.
  /// </summary>
  public bool OnContentLoaded { get; set; } = false;

  public override void Process(TagHelperContext context, TagHelperOutput output)
  {
     if (!OnContentLoaded)
     {
        base.Process(context, output);
     } 
     else
     {
        var content = output.GetChildContentAsync().Result;
        var javascript = content.GetContent();

        var sb = new StringBuilder();
        sb.Append("document.addEventListener('DOMContentLoaded',");
        sb.Append("function() {");
        sb.Append(javascript);
        sb.Append("});");

        output.Content.SetHtmlContent(sb.ToString()); 
     }
  }
}

example usage within a ViewComponent:

<script type="text/javascript" on-content-loaded="true">
    $('.button-collapse').sideNav();
</script>

There might be a better way than this, but this has worked for me so far.

Jake Shakesworth
  • 3,335
  • 4
  • 29
  • 43
  • 2
    You should return after calling base, otherwise, everything else will override the base implementation: `if (!OnContentLoaded) { base.Process(context, output); return; }` – Johnny Oshika May 14 '18 at 18:23
  • 2
    Here is the link to this issue on GitHub: [GitHub](https://github.com/aspnet/Docs/issues/687) TL;DR: Microsoft is planning to address this issue in .NET Core 3.0 with Razor Components. – Viktors Telle Oct 19 '18 at 05:47
  • Note that you need to scope your tag helper to make @Jake's solution work: https://stackoverflow.com/questions/48271514/custom-tag-helper-not-working/48272622#48272622 – chakeda Nov 21 '18 at 16:57
  • Am I supposed to import the tag helper in the parent component? – Miguel J. Jan 15 '19 at 16:25
  • 1
    this solved my problem,however it costed me hours to figure out im still having my code tested while its written inside a section within the `ViewComponent`...Duh – Niklas Feb 23 '19 at 19:51
  • Great solution. – Mike S. Mar 18 '21 at 16:53
7

Sections only work in a View don't work in partial views or View Component (Default.cshtml executes independently of the main ViewResult and its Layout value is null by default. ) and that's by design.

You can use it to render sections for a Layout that the partial view or view component's view declares. However, sections defined in partials and view components don't flow back to the rendering view or it's Layout. If you want to use jQuery or other library reference in your partial view or View component then you can pull your library into head rather than body in your Layout page.

Example:

View Component:

public class ContactViewComponent : ViewComponent
{
    public IViewComponentResult Invoke()
    {
        return View();
    }
}

Location of ViewComponent:

/Views/[CurrentController]/Components/[NameOfComponent]/Default.cshtml
/Views/Shared/Components/[NameOfComponent]/Default.cshtml

Default.cshtml:

@model ViewComponentTest.ViewModels.Contact.ContactViewModel

<form method="post" asp-action="contact" asp-controller="home">
    <fieldset>
        <legend>Contact</legend>
        Email: <input asp-for="Email" /><br />
        Name: <input asp-for="Name" /><br />
        Message: <textarea asp-for="Message"></textarea><br />
        <input type="submit" value="Submit" class="btn btn-default" />
    </fieldset>
</form>

_Layout.cshtml :

    <!DOCTYPE html>
    <html>
    <head>
    ...
        @RenderSection("styles", required: false)
     <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.0/jquery.min.js"></script>
    </head>
    <body>
        @RenderBody()
    ...
    //script move to head
      @RenderSection("scripts", required: false)
    </body>
    </html>

View.cshtml:

@{
    Layout =  "~/Views/Shared/_Layout.cshtml";     
}
..................
@{Html.RenderPartial("YourPartialView");}
..................
@await Component.InvokeAsync("Contact")
..................
@section Scripts {
    <script>
    //Do somthing
    </script>
}

View Component by grahamehorner: (you can more information here)

The @section scripts don't render in a ViewComponent, view component should have the ability to include scripts within the @section unlike partital views as components may be reused across many views and the component is responsible for it's own functionality.

Ashiquzzaman
  • 5,129
  • 3
  • 27
  • 38
1

Here's the solution I'm using. It supports both external script loading and custom inline script. While some setup code needs to go in your layout file (e.g. _Layout.cshtml), everything is orchestrated from within the View Component.

Layout file:

Add this near the top of your page (inside the <head> tag is a good place):

<script>
    MYCOMPANY = window.MYCOMPANY || {};
    MYCOMPANY.delayed = null;
    MYCOMPANY.delay = function (f) { var e = MYCOMPANY.delayed; MYCOMPANY.delayed = e ? function () { e(); f(); } : f; };
</script>

Add this near the bottom of your page (just before the closing </body> tag is a good place):

<script>MYCOMPANY.delay = function (f) { setTimeout(f, 1) }; if (MYCOMPANY.delayed) { MYCOMPANY.delay(MYCOMPANY.delayed); MYCOMPANY.delayed = null; }</script>

View Component:

Now anywhere in your code, including your View Component, you can do this and have the script executed at the bottom of your page:

<script>
    MYCOMPANY.delay(function() {
        console.log('Hello from the bottom of the page');
    });
</script>



With External Script Dependency

But sometimes that's not enough. Sometimes you need to load an external JavaScript file and only execute your custom script once the external file is loaded. You also want to orchestrate this all in your View Component for full encapsulation. To do this, I add a little more setup code in my layout page (or external JavaScript file loaded by the layout page) to lazy load the external script file. That looks like this:

Layout file:

Lazy load script

<script>
    MYCOMPANY.loadScript = (function () {
        var ie = null;
        if (/MSIE ([^;]+)/.test(navigator.userAgent)) {
            var version = parseInt(RegExp['$1'], 10);
            if (version)
                ie = {
                    version: parseInt(version, 10)
                };
        }

        var assets = {};

        return function (url, callback, attributes) {

            attributes || (attributes = {});

            var onload = function (url) {
                assets[url].loaded = true;
                while (assets[url].callbacks.length > 0)
                    assets[url].callbacks.shift()();
            };

            if (assets[url]) {

                if (assets[url].loaded)
                    callback();

                assets[url].callbacks.push(callback);

            } else {

                assets[url] = {
                    loaded: false,
                    callbacks: [callback]
                };

                var script = document.createElement('script');
                script.type = 'text/javascript';
                script.async = true;
                script.src = url;

                for (var attribute in attributes)
                    if (attributes.hasOwnProperty(attribute))
                        script.setAttribute(attribute, attributes[attribute]);

                // can't use feature detection, as script.readyState still exists in IE9
                if (ie && ie.version < 9)
                    script.onreadystatechange = function () {
                        if (/loaded|complete/.test(script.readyState))
                            onload(url);
                    };
                else
                    script.onload = function () {
                        onload(url);
                    };

                document.getElementsByTagName('head')[0].appendChild(script);
            }
        };
    }());
</script>

View Component:

Now you can do this in your View Component (or anywhere else for that matter):

<script>
    MYCOMPANY.delay(function () {
        MYCOMPANY.loadScript('/my_external_script.js', function () {
            console.log('This executes after my_external_script.js is loaded.');
        });
    });
</script>

To clean things up a bit, here's a tag helper for the View Component (inspired by @JakeShakesworth's answer):

[HtmlTargetElement("script", Attributes = "delay")]
public class ScriptDelayTagHelper : TagHelper
{
    /// <summary>
    /// Delay script execution until the end of the page
    /// </summary>
    public bool Delay { get; set; }

    /// <summary>
    /// Execute only after this external script file is loaded
    /// </summary>
    [HtmlAttributeName("after")]
    public string After { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (!Delay) base.Process(context, output);

        var content = output.GetChildContentAsync().Result;
        var javascript = content.GetContent();

        var sb = new StringBuilder();
        sb.Append("if (!MYCOMPANY || !MYCOMPANY.delay) throw 'MYCOMPANY.delay missing.';");
        sb.Append("MYCOMPANY.delay(function() {");
        sb.Append(LoadFile(javascript));
        sb.Append("});");

        output.Content.SetHtmlContent(sb.ToString());
    }

    string LoadFile(string javascript)
    {
        if (After == NullOrWhiteSpace)
            return javascript;

        var sb = new StringBuilder();
        sb.Append("if (!MYCOMPANY || !MYCOMPANY.loadScript) throw 'MYCOMPANY.loadScript missing.';");
        sb.Append("MYCOMPANY.loadScript(");
        sb.Append($"'{After}',");
        sb.Append("function() {");
        sb.Append(javascript);
        sb.Append("});");

        return sb.ToString();
    }
}

Now in my View Component I can do this:

<script delay="true" after="/my_external_script.js"]">
  console.log('This executes after my_external_script.js is loaded.');
</script>

You can also leave out the after attribute if the View Component doesn't have an external file dependency.

Johnny Oshika
  • 54,741
  • 40
  • 181
  • 275
0

This answer is for .netcore 2.1. Short Answer, inject an instance of Microsoft.AspNetCore.Mvc.Razor.TagHelpers.ITagHelperComponentManager that takes the javascript needed for the components UI functionality as the last node of the body element.

Long answer how I got here and what works for me.

I have many widgets that my app uses that have the same HTML structure, style, and UI functionality & logic but get fed with different data sets.

My app uses identity claims (roles) to control navigation dynamically. Those roles also determine the data sets that feed the widgets that can be combined to provide different views of that data on a single page.

I use Areas to keep that code separated. In order to facilitate further separation of concerns my app needs to keep the widget code (data retrieval, markup, styles, and functionality) separate I chose to go with ViewComponents.

I had the same problem in that my functionality depends on javascript libraries that I want to make available to the entire app inside of the shared/_layout file.

My scripts render is the last element on the _layout file before the body tag. My body render is several nodes up the structure, so my need was to somehow get the javascript I need appended to the collection of child nodes in the body element. Otherwise, my components would fail to create the necessary javascript objects to provide the needed functionality.

At the root of this solutions is the TagHelperComponent and the TagHelperComponentManager. I created a class that derives from the abstract base class implementation of the ITagHelperComponent this file and placed it in ./Pages/Shared/

Here is my ScriptTagHelper

public class ScriptTagHelper : TagHelperComponent
{
    private readonly string _javascript;
    public ScriptTagHelper(string javascript = "") {
        _javascript = javascript;
    }
    public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        if (string.Equals(context.TagName, "body", StringComparison.Ordinal))
        {
            output.PostContent.AppendHtml($"<script type='text/javascript'>{_javascript}</script>");
        }
        return Task.CompletedTask;
    }
}

The code takes the javascript string as a constructor argument to the tag helper. The ProcessAsync task is override to find the body tag and append the javascript.

The javascript string is wrapped in the Html script tag, and that complete Html string is appended to the end of whatever content is already in the body. (that is any script sections inserted on the page by render script, and any script on the layout page).

The way this tag helper is used is via dependency injection inside of the ViewComponent. Here is an example of a widget wired up to create an instance of the ScriptTagHelper.

This is my Data Usage Distribution widget. It will eventually display the distribution of data usage in a pie chart, that when floating on a particular wedge of the chart will also show more data highlights of that particular data segment. For now, its just the template that all of my widgets will use to help me set up for assigning tasks to the team to start building out the resources.

This file is ./Pages/Shared/Components/DataDistribution/Default.cshtml

@model dynamic;
@using Microsoft.AspNetCore.Mvc.Razor.TagHelpers;
@inject ITagHelperComponentManager tagManager;

<div class="col-md-3 grid-margin stretch-card">
    <div class="card">
        <div class="card-body">
            <p class="card-title text-md-center text-xl-left">Data Usage Distribution</p>
            <div class="d-flex flex-wrap justify-content-between justify-content-md-center justify-content-xl-between align-items-center">
                <h3 class="mb-0 mb-md-2 mb-xl-0 order-md-1 order-xl-0">40016</h3>
                <i class="ti-agenda icon-md text-muted mb-0 mb-md-3 mb-xl-0"></i>
            </div>
            <p class="mb-0 mt-2 text-success">10.00%<span class="text-body ml-1"><small>(30 days)</small></span></p>
        </div>
    </div>
</div>


@{
    Layout = null;

    string scriptOptions = Newtonsoft.Json.JsonConvert.SerializeObject(Model);

    string script = @"";

    script += $"console.log({scriptOptions});";

    tagManager.Components.Add(new ScriptTagHelper(javascript: script));
}

By injecting the default implementation of the ITagHelperComponentManager, we get access to the collection of tag helper components and can add an implementation of our own tag helper. This tag helpers job is to put a script tag containing our javascript for this view component at the bottom of our body tag.

In this simple code stub, I can now safely and reliably expect all of my supporting UI libraries to be loaded before I start rendering the script that will use those libraries to modify my UI. Here I am just console logging my serialized model returned from my view component. So running this code won't actually prove it works, but, anyone can change the script variable to console log the jquery version if you want to make sure jquery is loaded in your layout file.

Hope this helps someone. I have heard that changes are coming to support sections in .netcore 3, but those problems people are having are already solved natively and rather elegantly in 2.1, as I hope to have shown here.

0

Let's say you have components A and B, you could keep a _Scripts partial view inside the components folder:

Views
  Shared
    Components
      A
        Default.cshtml
        _Scripts.cshtml
      B
        Default.cshtml
        _Scripts.cshtml

In your page (where you render the components), you could just do this for each component you use:

@await Component.InvokeAsync("A")
@await Component.InvokeAsync("B")

@section Scripts {
    @await Html.PartialAsync("~/Views/Shared/Components/A/_Scripts.cshtml", Model);
    @await Html.PartialAsync("~/Views/Shared/Components/B/_Scripts.cshtml", Model);
}

Inside the _Scripts partial you put whatever you'd like inside the Scripts section.

I would not recommend for complex pages in which you have too many components. Can be useful for few components, or where the name of the component being loaded is dynamic.

Alisson Reinaldo Silva
  • 10,009
  • 5
  • 65
  • 83