Widget

Overview

Widgets are visual elements displayed inside Widget Areas. Develop a widget when you want to show a visual element that user can drag and drop between Widget Areas.

A typical widget has the follwoing,

  • Widget manifest file widget.json
  • Widget class that derived from Widget.cs which contains the widget’s settings such as widget title etc.
  • Widget UI
    • Client artifacts such as CSS, images, JavaScript, SCSS etc. You can use whatever client-side technologies you want
    • The wwwroot folder that contains the output of the client artifacts
    • Server side components which outputs the actual view
  • Widget Settings a Razor Page used by the Admin Panel for user to configure the widget’s settings

SocialIcons Widget

Widget Areas

Widget areas are containers of widgets that normally surround your main contents on the website like sidebars, footers etc. Widget areas work together with themes, areas that are specified in the theme manifest theme.json will show up in the Admin Panel > Widgets, users can then drag and drop widgets to these areas. See Theme’s Widget Areas section for more information.

Widget Project

A widget project is a Razor Class Library, the .csproj file looks like below. When you build the project it will copy the widget manifest widget.json to the webapp project Fan.WebApp.

<Project Sdk="Microsoft.NET.Sdk.Razor">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <AddRazorSupportForMvc>true</AddRazorSupportForMvc>
  </PropertyGroup>

  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\Core\Fan\Fan.csproj" />
  </ItemGroup>

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

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

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

Widget Manifest File

Each widget requires a manifest file named widget.json to be at the root of the widget project. The system load the widget based on this file, here is SocialIcons widget’s manifest file.

{
  "name": "Social Icons",
  "description": "Add social-media icons to your site.",
  "type": "SocialIcons.SocialIconsWidget, SocialIcons",
  "version": "1.0.0",
  "requiresAtLeast": "1.1.0",
  "widgetUrl": "https://github.com/FanrayMedia/Fanray",
  "author": "Ray Fan",
  "authorUrl": "https://www.fanray.com"
}

The name and description properties represent plugin information to be displayed in the Admin Panel > Widgets. The type property has the widget’s .NET class and assembly names, this string is written in the format of “Namespace.PluginClass, Assembly”.

Widget Class

Every widget needs to have 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

Widget settings is a Razor Page placed inside the Manage > Widgets folder. It’s named with the name of the widget followed by Settings, e.g. BlogTagsSettings.cshtml. The settings are under authroization and accessible only by administrators.

Here is the BlogTagsSettings.cshtml page in its entirety. The page contains a simple Vuejs component named ext-settings, you can name this whatever you like. The component contains a form and a Save button for user save plugin 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 database. 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, here is the Razor View for BlogTags widget BlogTags.cshtml.

@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 view is then returned by an associated view component BlogTagsViewComponent.cs.

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));
        }
    }
}

The widget view is put on the theme’s through a tag helper, like this

<widget-area id="blog-sidebar1" />

The tag helper will load the widget’s view component and the view component will load the Razor view.

Widgets on Theme

Plugin
Data