Extend the Admin Interface in NopCommerce 4.2

What if you wanted to create a plugin that extends, for example, the product entity? This tutorial shows an alternative way to the classical approach of adding a configuration site.

Product editing page with custom section for related blog posts.

Basically, you could create a basic configuration page that offers standard CRUD operations, as described in the nopCommerce docs. This approach might be adequate for a plugin that adds new functionality to the system, but I find it cumbersome in the case of an extension. Instead, I prefer to extend the existing editing page of an entity. For this example, I am going to add a tab to add or edit related blog posts - similar to the concept of related products. I am going to skip the data layer part to keep this post as short as possible (see https://docs.nopcommerce.com/­developer/­plugins/­plugin-with-data-access.html for information on how to extend the data layer).

I wrote this tutorial for NopCommerce 4.2, but its concepts also apply for older versions. The most noticeable difference between the current and older versions is the approach on how to extend an existing admin page. While older versions required implementation of an event consumer and adding the HTML within that code, the most recent release of nopCommerce offers a much more beautiful way by using a widget zone.

Create a new plugin that implements IWidgetPlugin and configure the widget zone to use (How to create a new plugin in NopCommerce: https://docs.nopcommerce.com/­developer/­plugins/­index.html).

using ...

namespace VIU.Plugin.Extensions.Product.Admin {
  public class ProductAdminPlugin : BasePlugin, IWidgetPlugin {
    public bool HideInWidgetList => false;

    public IList<string> GetWidgetZones() {
      return new List<string> {
        AdminWidgetZones.ProductDetailsBlock
      };
    }

    public string GetWidgetViewComponentName(string widgetZone) {
      if (widgetZone.Equals(AdminWidgetZones.ProductDetailsBlock))
        return "RelatedBlogPosts";

      return string.Empty;
    }
  }
}

NopCommerce requires you to create a bunch of models for this to work. The first three models back the newly added section on the editing page and its contained list of existing related blogs. To keep things tidy, I decided to pack those models into one file:

using ...

namespace VIU.Plugin.Extensions.Product.Admin.Model.RelatedBlogPost {
  public class RelatedBlogPostModel : BaseNopEntityModel {
    public int RelatedBlogPostId { get; set; }
    public string Title { get; set; }
    public string LanguageName { get; set; }
    public int DisplayOrder { get; set; }
  }

  public class RelatedBlogPostSearchModel : BaseSearchModel {
    public int ProductId { get; set; }
  }

  public class RelatedBlogPostListModel : BasePagedListModel<RelatedBlogPostModel> { }
}

The class RelatedBlogPostModel defines the list data, the system uses RelatedBlogPostSearchModel to load the list data, and RelatedBlogPostListModel holds the list data.

The three following models do the same as the latter but for the popup to add a new entry. They look pretty similar except for the search model since we want to have a search interface in the popup.

using ...

namespace VIU.Plugin.Extensions.Product.Admin.Model.RelatedBlogPost {
  public class AddRelatedBlogPostModel : BaseNopModel {
    public AddRelatedBlogPostModel() {
      SelectedBlogPostIds = new List<int>();
    }

    public int ProductId { get; set; }

    public IList<int> SelectedBlogPostIds { get; set; }
  }

  public class AddRelatedBlogPostSearchModel : BaseSearchModel {
    public AddRelatedBlogPostSearchModel() {
      AvailableStores = new List<SelectListItem>();
    }

    [NopResourceDisplayName("Plugins.VIU.Extensions.Product.RelatedBlogPosts.SearchBlogTitle")]
    public string SearchBlogTitle { get; set; }

    [NopResourceDisplayName("Admin.Catalog.Products.List.SearchStore")]
    public int SearchStoreId { get; set; }

    public IList<SelectListItem> AvailableStores { get; set; }
  }

  public class AddRelatedBlogPostListModel : BasePagedListModel<BlogPostModel> { }
}

Lastly, we extend the product model with an instance of RelatedBlogPostSearchModel (I don't extend the product model in a programmatic sense as it would add overhead).

using ...

namespace VIU.Plugin.Extensions.Product.Admin.Model {
  public class ExtendedProductModel {
    public ExtendedProductModel() {
      RelatedBlogPostSearchModel = new RelatedBlogPostSearchModel();
    }

    public int ProductId { get; set; }

    public RelatedBlogPostSearchModel RelatedBlogPostSearchModel { get; set; }
  }
}

In the next step, we implement the necessary views. The first one is the components default view (we define the component later), so we call it Default.cshtml. It contains quite a bit of code but basically what it does is it defines the data grid and fills it with data fetched from the controller via RelatedBlogPostList (We implement this method in the next step). The rest should be self-explanatory.

@model ExtendedProductModel
<nop-panel asp-name="product-related-blog-posts"
           asp-icon="fa fa-cart-arrow-down"
           asp-title="@T("VIU.Plugin.Extensions.Product.RelatedBlogPostsTabTitle")"
           asp-hide-block-attribute-name="ProductPage.HideRelatedBlogPostsBlock"
           asp-hide="false"
           asp-advanced="false">
    <div class="panel-body">
        <p>@T("Admin.Catalog.Products.RelatedBlogPosts.Hint")</p>
        @if (Model.ProductId > 0) {
            <div class="panel panel-default">
                <div class="panel-body">
                    @await Html.PartialAsync("Table", new DataTablesModel {
                        Name = "relatedblogposts-grid",
                        UrlRead = new DataUrl("RelatedBlogPostList", "ExtendedProduct", new RouteValueDictionary {[nameof(Model.RelatedBlogPostSearchModel.ProductId)] = Model.RelatedBlogPostSearchModel.ProductId}),
                        UrlDelete = new DataUrl("RelatedBlogPostDelete", "ExtendedProduct", null),
                        UrlUpdate = new DataUrl("RelatedBlogPostUpdate", "ExtendedProduct", null),
                        Length = Model.RelatedBlogPostSearchModel.PageSize,
                        LengthMenu = Model.RelatedBlogPostSearchModel.AvailablePageSizes,
                        ColumnCollection = new List<ColumnProperty> {
                            new ColumnProperty(nameof(RelatedBlogPostModel.Title)) {
                                Title = T("Plugins.VIU.Extensions.Product.RelatedBlogPosts.Fields.Title").Text
                            },
                            new ColumnProperty(nameof(RelatedBlogPostModel.LanguageName)) {
                                Title = T("Plugins.VIU.Extensions.Product.RelatedBlogPosts.Fields.LanguageName").Text
                            },
                            new ColumnProperty(nameof(RelatedBlogPostModel.DisplayOrder)) {
                                Title = T("Plugins.VIU.Extensions.Product.RelatedBlogPosts.Fields.DisplayOrder").Text,
                                Width = "150",
                                ClassName = NopColumnClassDefaults.CenterAll,
                                Editable = true,
                                EditType = EditType.Number
                            },
                            new ColumnProperty(nameof(RelatedBlogPostModel.RelatedBlogPostId)) {
                                Title = T("Admin.Common.View").Text,
                                Width = "150",
                                ClassName = NopColumnClassDefaults.Button,
                                Render = new RenderButtonView(new DataUrl("~/Admin/Blog/BlogPostEdit/", nameof(RelatedBlogPostModel.Title)))
                            },
                            new ColumnProperty(nameof(RelatedBlogPostModel.Id)) {
                                Title = T("Admin.Common.Edit").Text,
                                Width = "200",
                                ClassName = NopColumnClassDefaults.Button,
                                Render = new RenderButtonsInlineEdit()
                            },
                            new ColumnProperty(nameof(RelatedBlogPostModel.Id)) {
                                Title = T("Admin.Common.Delete").Text,
                                Width = "100",
                                Render = new RenderButtonRemove(T("Admin.Common.Delete").Text),
                                ClassName = NopColumnClassDefaults.Button
                            }
                        }
                    })
                </div>
                <div class="panel-footer">
                    <button type="submit" id="btnAddNewRelatedBlogPost" class="btn btn-primary" onclick="javascript:OpenWindow('@Url.Action("RelatedBlogPostAddPopup", "ExtendedProduct", new {productId = Model.ProductId, btnId = "btnRefreshRelatedBlogPosts", formId = "product-form"})', 800, 800, true); return false;">
                        @T("Admin.Catalog.Products.RelatedBlogPosts.AddNew")
                    </button>
                    <input type="submit" id="btnRefreshRelatedBlogPosts" style="display: none"/>
                    <script>
                        $(document).ready(function () {
                            $('#btnRefreshRelatedBlogPosts').click(function () {
                                updateTable('#relatedblogposts-grid');
                                return false;
                            });
                        });
                    </script>
                </div>
            </div>
        } else {
            <div class="panel panel-default">
                <div class="panel-body">
                    @T("Admin.Catalog.Products.RelatedBlogPosts.SaveBeforeEdit")
                </div>
            </div>
        }
    </div>
</nop-panel>

The popup view consists of two parts. The first one is the panel with the class panel-search and contains the search mask, the second one lists the search results (shows everything by default). Feel free to modify the code to your liking. I use the default implementation here.

@model AddRelatedBlogPostSearchModel
@{
    Layout = "_AdminPopupLayout";
    ViewBag.Title = T("VIU.Plugin.Extensions.Product.RelatedBlogPosts.AddNew").Text;
}
@if (ViewBag.RefreshPage == true)
{
    <script>
        try {window.opener.document.forms['@(Context.Request.Query["formId"])'].@(Context.Request.Query["btnId"]).click();}
        catch (e){}
        window.close();
    </script>
}
else
{
    <form asp-controller="ExtendedProduct" asp-action="RelatedBlogPostAddPopup"
          asp-route-productId="@Context.Request.Query["productId"]"
          asp-route-btnId="@Context.Request.Query["btnId"]"
          asp-route-formId="@Context.Request.Query["formId"]">
        <div class="content-header clearfix">
            <h1 class="pull-left">@T("VIU.Plugin.Extensions.Product.RelatedBlogPosts.AddNew")</h1>
            <div class="pull-right"> </div>
        </div>
        <div class="content">
            <div class="form-horizontal">
                <div class="panel-group">
                    <div class="panel panel-default panel-search panel-popup">
                        <div class="panel-body">
                            <div class="row">
                                <div class="col-sm-6">
                                    <div class="form-group">
                                        <div class="col-sm-5">
                                            <nop-label asp-for="SearchBlogTitle"/>
                                        </div>
                                        <div class="col-sm-7">
                                            <nop-editor asp-for="SearchBlogTitle"/>
                                        </div>
                                    </div>
                                </div>
                                <div class="col-sm-6">
                                    <div class="form-group" @(Model.AvailableStores.SelectionIsNotPossible() ? Html.Raw("style=\"display:none\"") : null)>
                                        <div class="col-sm-5">
                                            <nop-label asp-for="SearchStoreId"/>
                                        </div>
                                        <div class="col-sm-7">
                                            <nop-select asp-for="SearchStoreId" asp-items="Model.AvailableStores"/>
                                        </div>
                                    </div>
                                </div>
                            </div>
                            <div class="row">
                                <div class="col-sm-12">
                                    <button type="button" id="search-blogposts" class="btn btn-primary btn-search">
                                        <i class="fa fa-search"></i>
                                        @T("Admin.Common.Search")
                                    </button>
                                </div>
                            </div>
                        </div>
                    </div>
                    <div class="panel panel-default">
                        <div class="panel-body">
                            @await Html.PartialAsync("Table", new DataTablesModel {
                                Name = "blogposts-grid",
                                UrlRead = new DataUrl("RelatedBlogPostAddPopupList", "ExtendedProduct", null),
                                SearchButtonId = "search-blogposts",
                                Length = Model.PageSize,
                                LengthMenu = Model.AvailablePageSizes,
                                Filters = new List<FilterParameter> {
                                    new FilterParameter(nameof(Model.SearchBlogTitle)),
                                    new FilterParameter(nameof(Model.SearchStoreId))
                                },
                                ColumnCollection = new List<ColumnProperty> {
                                    new ColumnProperty(nameof(BlogPostModel.Id)) {
                                        IsMasterCheckBox = true,
                                        Render = new RenderCheckBox(nameof(AddRelatedBlogPostModel.SelectedBlogPostIds)),
                                        ClassName = NopColumnClassDefaults.CenterAll,
                                        Width = "50",
                                    },
                                    new ColumnProperty(nameof(BlogPostModel.Title)) {
                                        Title = T("VIU.Plugin.Extensions.Product.RelatedBlogPost.Title").Text
                                    },
                                    new ColumnProperty(nameof(BlogPostModel.LanguageName)) {
                                        Title = T("VIU.Plugin.Extensions.Product.RelatedBlogPost.LanguageName").Text,
                                        Width = "100",
                                        ClassName = NopColumnClassDefaults.CenterAll,
                                        Render = new RenderBoolean()
                                    }
                                }
                            })
                        </div>
                        <div class="panel-footer">
                            <button type="submit" name="save" class="btn bg-blue">
                                <i class="fa fa-floppy-o"></i>
                                @T("Admin.Common.Save")
                            </button>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </form>
}

Now, we create the code for the component that loads the previously generated view into the placeholder of the product's editing page. The placeholder passes the product model which we then use to fill our models.

using ...

namespace VIU.Plugin.Extensions.Product.Admin.Components {
    [ViewComponent(Name = "ProductExtension")]
    public class ProductExtensionComponent : NopViewComponent {
        public IViewComponentResult Invoke(string widgetZone, object additionalData) {

            // Make sure the passed object is of type ProductModel and save it as "productModel"
            if (!(additionalData is ProductModel productModel)) return Content("");

            var model = new ExtendedProductModel {
                ProductId = productModel.Id,
                RelatedBlogPostSearchModel = new RelatedBlogPostSearchModel{
                    ProductId = productModel.Id
                }
            };

            return View("pathToYourView", model);
        }
    }
}

The controller is the biggest piece of code in this tutorial as it orchestrates most of the logic. First we create the structure of the controller by defining the necessary methods.

using ...

namespace VIU.Plugin.Extensions.Product.Admin.Controller {
    public class ExtendedProductController : BaseAdminController {

        // Returns blog posts associated with the product (the product id is part of "searchModel") as JSON. Invoked by the data grid in the default view.
        [HttpPost]
        public IActionResult RelatedBlogPostList(RelatedBlogPostSearchModel searchModel) { }

        // Updates a related blog post reference. The only editable property is DisplayOrder.
        [HttpPost]
        public IActionResult RelatedBlogPostUpdate(RelatedBlogPostModel model) { }

        // Deletes a related blog post reference.
        [HttpPost]
        public IActionResult RelatedBlogPostDelete(int id) { }

        // Returns the popup view
        public IActionResult RelatedBlogPostAddPopup(int productId) { }

        // Returns blog posts filtered by the search model
        [HttpPost]
        public IActionResult RelatedBlogPostAddPopupList(AddRelatedBlogPostSearchModel searchModel) { }

        // This action is invoked when "Save" is clicked in the popup
        [HttpPost]
        [FormValueRequired("save")]
        public IActionResult RelatedBlogPostAddPopup(AddRelatedBlogPostModel model) { }
    }
}

Let's go through all the methods in detail.

The RelatedBlogPostList method:

public IActionResult RelatedBlogPostList(RelatedBlogPostSearchModel searchModel) {
    if (!_permissionService.Authorize(StandardPermissionProvider.ManageProducts))
        return AccessDeniedDataTablesJson();

    // Get all blog posts related to this product. The ExtendedProductService is not part of this tutorial.
    var relatedBlogPosts = _extendedProductService.GetRelatedBlogPostsByProductId(searchModel.ProductId,
        searchModel.Page - 1, searchModel.PageSize);

    // Map the data from the previously loaded list to a new list of RelatedBlogPostModel with the help of the BlogService
    var model = new RelatedBlogPostListModel().PrepareToGrid(searchModel, relatedBlogPosts, () =>
        relatedBlogPosts.Select(relatedBlogPost => new RelatedBlogPostModel {
            Id = relatedBlogPost.Id,
            RelatedBlogPostId = relatedBlogPost.BlogPostId,
            Title = _blogService.GetBlogPostById(relatedBlogPost.BlogPostId).Title,
            LanguageName = _blogService.GetBlogPostById(relatedBlogPost.BlogPostId).Language.Name,
            DisplayOrder = relatedBlogPost.DisplayOrder
        }));

    return Json(model);
}

The RelatedBlogPostUpdate method:

public IActionResult RelatedBlogPostUpdate(RelatedBlogPostModel model) {
    // Check permission
    if (!_permissionService.Authorize(StandardPermissionProvider.ManageProducts))
        return AccessDeniedView();

    // Get instance of the related blog post reference to update. The ExtendedProductService is not part of this tutorial.
    var relatedBlogPost = _extendedProductService.GetRelatedBlogPostById(model.Id)
                          ?? throw new ArgumentException("No related blog post found with the specified id");

    // The only editable property
    relatedBlogPost.DisplayOrder = model.DisplayOrder;
    _extendedProductService.UpdateRelatedBlogPost(relatedBlogPost);
    return new NullJsonResult();
}

The RelatedBlogPostDelete method:

public IActionResult RelatedBlogPostDelete(int id) {
    // Check permission
    if (!_permissionService.Authorize(StandardPermissionProvider.ManageProducts))
        return AccessDeniedView();

    // Get instance of the related blog post reference to delete. The ExtendedProductService is not part of this tutorial.
    var relatedBlogPost = _extendedProductService.GetRelatedBlogPostById(id)
                          ?? throw new ArgumentException("No related blog post found with the specified id");

    _extendedProductService.DeleteRelatedBlogPost(relatedBlogPost);
    return new NullJsonResult();
}

The RelatedBlogPostAddPopup (GET) method:

public IActionResult RelatedBlogPostAddPopup(int productId) {
    // Check permission
    if (!_permissionService.Authorize(StandardPermissionProvider.ManageProducts))
        return AccessDeniedView();

    var model = new AddRelatedBlogPostSearchModel();

    // To be able to filter by store, we load the AvailableStores property via the new BaseAdminFactory
    _baseAdminModelFactory.PrepareStores(model.AvailableStores);
    model.SetPopupGridPageSize();

    return View(RelatedBlogPostAddPopupView, model);
}

The RelatedBlogPostAddPopupList method:

public IActionResult RelatedBlogPostAddPopupList(AddRelatedBlogPostSearchModel searchModel) {
    // Check permission
    if (!_permissionService.Authorize(StandardPermissionProvider.ManageProducts))
        return AccessDeniedDataTablesJson();

    // Search for blog posts filtered by store and title. Since BlogService doesn't offer a Search method,
    // we have to implement it ourselves, however this is not part of this project.
    var blogPosts = _extendedBlogService.SearchBlogPosts(
        storeId: searchModel.SearchStoreId,
        keywords: searchModel.SearchBlogTitle,
        pageIndex: searchModel.Page - 1,
        pageSize: searchModel.PageSize,
        showHidden: true);

    var model = new AddRelatedBlogPostListModel().PrepareToGrid(searchModel, blogPosts, () => {
        return blogPosts.Select(blogPost => {
            var blogPostModel = blogPost.ToModel<BlogPostModel>();
            blogPostModel.SeName = _urlRecordService.GetSeName(blogPost, 0, true, false);
            return blogPostModel;
        });
    });
    return Json(model);
}

The RelatedBlogPostAddPopup (POST) method:

public IActionResult RelatedBlogPostAddPopup(AddRelatedBlogPostModel model) {
    // Check permission
    if (!_permissionService.Authorize(StandardPermissionProvider.ManageProducts))
        return AccessDeniedView();

    // Associates all selected blog posts with the current product
    var selectedBlogPosts = _blogService.GetBlogPostsByIds(model.SelectedBlogPostIds.ToArray());
    if (selectedBlogPosts.Any()) {
        var existingRelatedBlogPosts = _extendedProductService.GetRelatedBlogPostsByProductId(model.ProductId);
        foreach (var blogPost in selectedBlogPosts) {
            // Check if the relationship already exists
            if (existingRelatedBlogPosts.FirstOrDefault(relatedProduct => relatedProduct.ProductId == model.ProductId && relatedProduct.BlogPostId == blogPost.Id) != null)
                continue;
            // Yet again, the ExtendedProductService is not part of this post
            _extendedProductService.InsertRelatedBlogPost(new RelatedBlogPost {
                ProductId = model.ProductId,
                BlogPostId = blogPost.Id,
                DisplayOrder = 1
            });
        }
    }
    ViewBag.RefreshPage = true;
    return View(RelatedBlogPostAddPopupView, new AddRelatedBlogPostSearchModel());
}

I omitted the constructor to save space. If you are not sure how write it, have a look at any other constructor in NopCommerce. You will see that they define all the necessary services first and then load them via dependency injection.

Rolf Isler
VIU AG Rennweg 38 8001 Zürich CH-Switzerland
Imprint