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.
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 as well as new 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 GetWidgetZones() {
return new List {
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 { }
}
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();
}
public int ProductId { get; set; }
public IList SelectedBlogPostIds { get; set; }
}
public class AddRelatedBlogPostSearchModel : BaseSearchModel {
public AddRelatedBlogPostSearchModel() {
AvailableStores = new List();
}
[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 AvailableStores { get; set; }
}
public class AddRelatedBlogPostListModel : BasePagedListModel { }
}
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
@T("Admin.Catalog.Products.RelatedBlogPosts.Hint")
@if (Model.ProductId > 0) {
@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 {
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
}
}
})
} else {
@T("Admin.Catalog.Products.RelatedBlogPosts.SaveBeforeEdit")
}
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)
{
}
else
{
}
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.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 to 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.
I have uploaded the complete source code to this tutorial as a working plugin on GitLab. You can clone it from this URL: https://gitlab.com/rolfisler/nopcommerce-product-extension
©viu AG
Imprint