Skip to content
Ray Fan edited this page Jun 3, 2019 · 4 revisions

Overview

A widget displays some visual elements inside a widget area and widget areas are content areas that normally surround the main content of a website, such as a sidebar, footer etc. Widgets are frequently seen on CMS, Blog and eCommerce applications. Develop a widget when you want to show a visual element that user can drag and drop between widget areas.

A widget is consited of four things: the UI, Settings, Widget class and Manifest file. Let's look at widget in details through an example of the BlogTags widget.

But since widget lives inside a widget area, let's first talk about Widget Areas before we delve into how to create a widget.

Widget Areas

Widget areas are containers of widgets that normally surround your main contents on the website. So, widget areas work together with themes, and where they actually show up depends on your theme's theme.json. For example, with the default Clarity theme, in the Admin Panel > Widgets page you would see the following.

There are two widget areas on the right, Blog Sidebar and Blog After Post Content. This is because the Clarity theme has the following in its manifest file,

  "widgetAreas": [
    {
      "id": "blog-sidebar1",
      "name": "Blog Sidebar"
    },
    {
      "id": "blog-after-post",
      "name": "Blog After Post Content"
    }
  ]

There are two kinds of widget areas, System Defined and Theme Defined.

System-defined widget areas

The system pre-defines a set of areas for commonly used scenarios, such as sidebars, footers etc. When themes use these predefined areas, they have the advantage of maintaining the areas' existing widgets when user switches themes.

The system-defined areas include the following.

  • blog-sidebar1 intends to be the primary sidebar when theme has only 1 sidebar
  • blog-sidebar2 intends to be the secondary sidebar when theme supports 2 sidebars
  • blog-before-post intends to show up before a single blog post
  • blog-after-post intends to show up after a single blog post
  • blog-before-post-list intends to show up before a list of blog post, like on the index page
  • blog-after-post-list intends to show up after a list of blog post, like on the index page
  • footer1 site footer
  • footer2 when site footer has two columns
  • footer3 when site footer has three columns

Theme-defined widget areas

While System Defined areas try to cover common scenarios, designers have the freedom to create whatever widget areas they want and place these anywhere they want.

To do this just add your area to the widgetAreas array by giving an id and name like this,

    {
      "id": "my-area",
      "name": "My area."
    }

Then you can place it anywhere you want on the theme views, like this <widget-area id="my-area" />. Once the site is launched the system will register your new area.

But be aware 1. once your widget area is registered with the system with the id you give, any update to the id will yield a new area; 2. if the user switches to a different theme, the Theme Defined widgets will not be carried over to the new theme.

Note, the area names are purely suggestive and each widget area renders a div html, the deisnger can really choose to use or place them anywhere on her theme.

Widget Project

To create a widget, first create a project inside src\Widgets folder.

  • Inside Visual Studio, go to File menu, select New > Project.
  • Select ASP.NET Core Web Application.
  • Input project name, for example "BlogTags" > OK.
  • Verify ASP.NET Core 2.2 or later is selected.
  • Select Razor Class Library > OK.

Open the .csproj file, make sure the Sdk is Microsoft.NET.Sdk.Razor and TargetFramework is netcoreapp2.2, update them if they are different.

Then copy the following and paste it before the closing Project tag. This will copy necessary artifacts from the plugin project into the Fan.WebApp project at build time.

  <ItemGroup>
    <Content Include="widget.json" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>

  <Target Name="CopyArtifacts" AfterTargets="Build">
    <PropertyGroup>
      <CopyToDir>..\..\Core\Fan.WebApp\Widgets\BlogTags</CopyToDir>
    </PropertyGroup>

    <ItemGroup>
      <ManifestToCopy Include="$(OutputPath)\widget.json" />
    </ItemGroup>

    <RemoveDir Directories="$(CopyToDir)" />
    <MakeDir Directories="$(CopyToDir)" Condition="!Exists('$(CopyToDir)')" />
    <Copy SourceFiles="@(ManifestToCopy)" DestinationFiles="$(CopyToDir)\%(RecursiveDir)%(FileName)%(Extension)" />
  </Target>

Widget Manifest File

Each widget requires a manifest file called widget.json to be at the root of the widget project. The system load the widget based on this file, here is BlogTags' manifest file.

{
  "name": "Blog Tags",
  "description": "A list of blog tags.",
  "type": "BlogTags.BlogTagsWidget, BlogTags",
  "version": "1.0.0",
  "requiresAtLeast": "1.1.0-preview3",
  "widgetUrl": "https://github.com/FanrayMedia/Fanray",
  "author": "Ray Fan",
  "authorUrl": "https://www.fanray.com"
}

Most of the properties are self-explanatory and they represent widget information to be displayed inside the Admin Panel. The type property has the widget's .NET class and assembly names, so this string is written in the format of "Namespace.PluginClass, Assembly". This property must be accurate, it's used by the system to instantiate the actual type of the widget.

Widget Class

A widget is a class that derives from the Widget base class.

/// <summary>
/// Widget base class.
/// </summary>
public class Widget : Extension
{
    /// <summary>
    /// Id of the widget instance.
    /// </summary>
    public int Id { get; set; }
    /// <summary>
    /// The id of the area the widget instance resides in.
    /// </summary>
    public string AreaId { get; set; }
    /// <summary>
    /// Widget title (optional). 
    /// </summary>
    /// <remarks>
    /// The title can be left blank and if so the html will not emit for the title.
    /// </remarks>
    public string Title { get; set; }
}

The base class provides common properties shared by all widgets. Widgets are persisted as JSON and saved in the Core_Meta table, the Id property is the id of a row in this table. Widgets must live inside a Widget Area, the AreaId is the id of that area, it is a string. And all widgets have a title, this is displayed on top of the widget and part of all widget settings. Your derived widget class should have properties specific to your widget.

For example, BlogTagsWidget has additional properties it requires for displaying blog tags. Notice the data annotation attribute is used for validation of its property when user updates the widget's settings.

public class BlogTagsWidget : Widget
{
    public BlogTagsWidget()
    {
        Title = "Tags";
    }

    /// <summary>
    /// Maximum number of tags displayed. Default 100, range must be between 1 and 10,000.
    /// </summary>
    [Range(1, 10000, ErrorMessage = "Value for {0} must be between {1} and {2}.")]
    public int MaxTagsDisplayed { get; set; } = 100;

    /// <summary>
    /// Whether to show post count.
    /// </summary>
    public bool ShowPostCount { get; set; } = true;
}

Widget Settings

Every widget has a settings page. Create the Manage > Widgets folders and a Razor Page named BlogTagsSettings.cshtml. One thing to note is that at runtime this page will seamlessly become part of the Fan.WebApp project's Manage folder, which is under authroization and only administrators can access.

Here is the BlogTagsSettings.cshtml page in its entirety. The page contains a simple Vue.js component named edit-widget, you can name this whatever you like. The component contains other Vuetify.js form components for user to input the widget settings.

@page
@model BlogTags.Manage.Widgets.BlogTagsSettingsModel
@{
    ViewData["Title"] = "BlogTags Widget Settings";
    Layout = "_SettingsLayout";
}

<edit-widget inline-template>
    <v-form v-model="valid">
        <v-text-field label="Title" v-model="widget.title" v-on:keydown.enter.prevent="save"></v-text-field>
        <v-text-field label="Max number of tags to display"
                      v-model="widget.maxTagsDisplayed"
                      :rules="maxTagsDisplayedRules"></v-text-field>
        <v-checkbox label="Show post counts" v-model="widget.showPostCount"></v-checkbox>
        <v-btn @@click="save" :disabled="!valid">Save</v-btn>
    </v-form>
</edit-widget>

@section Scripts {
    <script>
        Vue.component('edit-widget', {
            data: () => ({
                widget: @Html.Raw(Model.WidgetJson),
                valid: false,
                maxTagsDisplayedRules: [
                    v => !!v || 'Required',
                    v => /^[0-9]+$/.test(v) || 'Integer only',
                    v => (parseInt(v) >= 1 && parseInt(v) <= 10000) || 'Must be between 1 and 10000',
                ],
            }),
            methods: {
                save() {
                    axios.post('/widgets/BlogTagsSettings', this.widget, this.$root.headers)
                        .then(resp => this.$root.onExtSettingsUpdated({
                            title: this.widget.title,
                            widgetId: this.widget.id,
                            areaId: this.widget.areaId,
                            msg: resp.data
                        }))
                        .catch(err => this.$root.onExtSettingsUpdateErr(err));
                }
            },
        });
    </script>
}

Upon clicking on the Save button, the widget's settings are persisted to the data source. Here the code for BlogTagsSettings.cshtml.cs.

namespace BlogTags.Manage.Widgets
{
    public class BlogTagsSettingsModel : PageModel
    {
        protected readonly IWidgetService widgetService;
        public BlogTagsSettingsModel(IWidgetService widgetService)
        {
            this.widgetService = widgetService;
        }

        public string WidgetJson { get; set; }

        public async Task OnGet(int widgetId)
        {
            var widget = (BlogTagsWidget)await widgetService.GetExtensionAsync(widgetId);
            WidgetJson = JsonConvert.SerializeObject(widget);
        }

        public async Task<IActionResult> OnPostAsync([FromBody]BlogTagsWidget widget)
        {
            if (ModelState.IsValid)
            {
                await widgetService.UpdateWidgetAsync(widget.Id, widget);
                return new JsonResult("Widget settings updated.");
            }

            return BadRequest("Invalid form values submitted.");
        }
    }
}

Widget UI

Every widget has UI, they are implemented with ViewComponent. Create a Components folder and then create the following a Razor view file and a ViewComponent .cs file.

The BlogTags.cshtml is the html to display on the website.

@using Fan.Blog.Models
@using BlogTags
@model Tuple<IEnumerable<Tag>, BlogTagsWidget>
@{
    var tags = Model.Item1;
    var widget = Model.Item2;
}

<div class="widget">
    @if (!widget.Title.IsNullOrEmpty())
    {
        <h4 class="widget-header">@widget.Title</h4>
    }
    <div class="widget-body">
        <ul class="blog-tags">
            @foreach (var t in tags)
            {
                <li>
                    <a class="tag" rel="tag" title="@t.Description" href="@t.RelativeLink" style="background-color:@t.Color;">@t.Title</a>
                    @if (widget.ShowPostCount)
                    {
                        <span class="tag-post-count">@t.Count</span>
                    }
                </li>
            }
        </ul>
    </div>
</div>

The BlogTagsViewComponent.cs is the view component that return the view.

namespace BlogTags.Components
{
    /// <summary>
    /// The BlogTags view component.
    /// </summary>
    public class BlogTagsViewComponent : ViewComponent
    {
        private readonly ITagService _tagSvc;
        public BlogTagsViewComponent(ITagService tagService)
        {
            _tagSvc = tagService;
        }

        public async Task<IViewComponentResult> InvokeAsync(Widget widget)
        {
            var blogTagsWidget = (BlogTagsWidget)widget;
            var tags = (await _tagSvc.GetAllAsync()).Where(t => t.Count > 0).Take(blogTagsWidget.MaxTagsDisplayed);

            return View("~/Components/BlogTags.cshtml", new Tuple<IEnumerable<Tag>, BlogTagsWidget>(tags, blogTagsWidget));
        }
    }
}

On the Clarity theme's layout and post page, you will find the following tag helpers respectively.

  • <widget-area id="blog-sidebar1" />
  • <widget-area id="blog-after-post" />

The tag helpers will load the each widget's view component and the view component will load the Razor view.

You can’t perform that action at this time.