Extending the Episerver editor interface
This blog post will summarize the sessions I held at Episerver Ascend Nordic and Episerver Ascend Europe and you will also have to opportunity to get hold of the code yourself to try it out.
I am publishing the code on GitHub and I would love for you all great developers out there to do pull request if you see something that I have done that can be done better or if you have another great way to extend the editor that you like to share.
Scenarios
The code contains now three different scenarios and will be updated with more.
Scenario one: Custom report with excel export
I have described that in another blog post that you can find here:
I will therefore not go into that but you can find the code for it inside this solution.
Scenario two: Custom visitor group for browser
I have described that in another blog post and it is still working in version 10. You can find it here:
I will therefore not go into that but you can find the code for it inside this solution.
Scenario three: Implement custom admin part to editor
Sometimes you have a solution that has content that are not Episerver content, or you need some extra admin view for more complex things that are hard to do as Episerver properties. For those times, you can use the technique that I show in this post and I will show here a simple example that are good to get inspired from.
In this example, I have added a new table to the Episerver database that are called UserProfile where I save first name, last name and phone number of the users in the database (the one created by the sql membership provider). This is a pretty simple example but is a good base that you can take inspiration from.
Create the table
In this example, I create a table inside the existing database and to do that use this code:
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[UserProfile](
[UserId] [uniqueidentifier] NOT NULL,
[FirstName] [nvarchar](200) NULL,
[LastName] [nvarchar](200) NULL,
[PhoneNumber] [varchar](50) NULL,
CONSTRAINT [PK_UserProfile] PRIMARY KEY CLUSTERED
(
[UserId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
Create the menu
Even though we want to use MVC with bootstrap and angularjs we want to use and keep the menu so the first thing we need to do is to create a class that implements the IMenuProvider and are decorated with the MenuProvider attribute. It looks like this:
using System.Collections.Generic;
using EPiServer.Security;
using EPiServer.Shell.Navigation;
namespace ExtendingEditUi.Business.Providers
{
[MenuProvider]
public class ProfileMenuProvider : IMenuProvider
{
public IEnumerable<MenuItem> GetMenuItems()
{
var toolbox = new SectionMenuItem("Profile-Admin", "/global/profileadmin")
{
IsAvailable = (request) => PrincipalInfo.HasEditorAccess
};
var profies = new UrlMenuItem("User profiles", "/global/profileadmin/profiles", "/profileadmin/profiles")
{
IsAvailable = (request) => PrincipalInfo.HasEditorAccess
};
return new MenuItem[] { toolbox, profies };
}
}
}
In this file, we start by creating a SectionMenuItem and that is the one that will be shown in the main menu part (next to CMS, Find and so on). It is important to set the parameter IsAvailable on that to the proper level so you do not expose the menu to people that should not be able to see it. In this example, I make it visible to everyone that has edit access
Then we create a UrlMenuItem that are the submenu and for this example we are only creating one. We set the access-level on this one as well and as you see it is possible to have different level for different sub menu items. This constructor also has three parameters and the last one is the actual url that will be used when a user click on that menu item.
Implement routing
When you are using Episerver then epi will take over all the routing for that website and by default it will not be possible to be routed to anything that are not created content. Since our view for this will be regular mvc we need to implement our own routing. You can read more about how Episerver are implementing routing here: http://world.episerver.com/documentation/Items/Developers-Guide/Episerver-CMS/9/Routing/Routing/
One very important thing to remember here is that by doing our own routing we are skipping some of the build in security in Episerver so it is very important that we think of this and implement the built-in security in Episerver our self.
Since we are also going to use Web Api inside this solution we also need to set up routing for that.
You can do this in an initialization module or in global.asax and in this code, I do it in global.asax.
protected override void RegisterRoutes(RouteCollection routes)
{
base.RegisterRoutes(routes);
routes.MapHttpRoute("webapi", "api/{controller}/{action}/{id}", new { id = RouteParameter.Optional });
routes.MapRoute("ProfileAdmin", "profileadmin/{action}", new { controller = "ProfileAdmin", action = "index" });
routes.MapRoute("ExistingPagesReport", "existingpagesreport/{action}", new { controller = "ExistingPagesReport", action = "Index" });
}
We start by implementing the incoming routes and then we add a MapHttpRoute to be able to handle all web api calls. We name it to webapi and make it handle all calls to /api/*
Then we add a MapRoute to handle the calls to the new custom view we are creating and we set it to handle all request to profileadmin/* and set the default controller to ProfileAdmin controller we are going to create.
The last route is to handle request to our custom report that are described in scenario one.
Create the controller
Since we are using MVC we need a controller to handle the request to the custom view so we create an ordinary MVC controller.
using System.Web.Mvc;
namespace ExtendingEditUi.Controllers
{
[Authorize(Roles = "Administrators, WebAdmins, WebEditors")]
public class ProfileAdminController : Controller
{
// GET: ProfileAdmin
public ActionResult Index()
{
return View();
}
public ActionResult Profiles()
{
return View();
}
}
}
Inside the controller there are two actions and if you compare it to the Episerver controller there are no currentPage as parameter and we cannot have that since there are no currentPage for this. The standard Index action are not used in this example but if you have a bigger implementation you might use this as an index view to list all the other views available.
I am decorating the class with the Authorize attribute and this is very important to do since if you do not do that, it will be public available for everybody and you probably do not want that! In this example, I make it available for administrators and editors.
For this simple example, I am not using any logic in the controller at all but you can do it and if you need to pass some data to the view then create a viewmodel for that.
Create the view
To make it easier to separate the logic I have created a layout for this page even that it is only one page. You will probably have more than one page so it is a good practice to have a layout for your views.
The layout:
@using EPiServer.Framework.Web.Resources
@using EPiServer.Shell.Navigation
@{
Layout = null;
}
<!DOCTYPE html>
<html ng-app="ProfilesApp">
<head>
<title>@ViewBag.Title</title>
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<!-- Shell -->
@Html.Raw(ClientResources.RenderResources("ShellCore"))
@Html.Raw(ClientResources.RenderResources("ShellWidgets"))
<!-- LightTheme -->
@Html.Raw(ClientResources.RenderResources("ShellCoreLightTheme"))
<!-- Navigation -->
@Html.Raw(ClientResources.RenderResources("Navigation"))
<!-- Dojo Dashboard -->
@Html.Raw(ClientResources.RenderResources("DojoDashboardCompatibility", new[] { ClientResourceType.Style }))
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.5/angular.min.js"></script>
<script src="https://code.jquery.com/jquery-3.1.1.min.js" integrity="sha256-hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<script src="~/Static/js/ProfileAdmin/profiles.js"></script>
<link href="~/Static/css/ProfileAdmin/profiles.css" rel="stylesheet" />
</head>
<body>
@Html.Raw(Html.ShellInitializationScript())
@Html.Raw(Html.GlobalMenu())
<div class="container-fluid" ng-controller="profilesCtrl">
@RenderBody()
</div>
</body>
</html>
Here there are some magic, or more hacky coding since the only way I figured out how to be able to implement the menu, styles and css were to look in the master pages there are in the cms.zip file below modules/_protected folder. This is not good since I have no control of knowing what will happen if Episerver decides to change or delete some files I use here. I am still feeling so comfortable with this solution that I use it in real solutions I have in production since the code are coming from the admin-part and those parts has not been changed in forever… But I will try to figure out if there are a better way and if there are, I will update this code.
There are a couple of key things in this file and the first is the implementation of the Episerver parts and that are pretty much all the RenderResources and the Html.Raw you see in the file. I need the RenderResources an initialization script to be able to render the menu and other parts correctly.
After that I am using CDN’s for the bootstrap and angularjs parts and it is up to you what you want to do, I like using CDN, but there is a risk of the CDN being down so if you want to be 100% of always have access to the files then you should download them.
I am using AngularJs in this solution and if you have never used AngularJs please read this and you will understand what I am doing:
http://www.w3schools.com/angular/
I define my angular app in the html head tag and then my angular controller in the wrapping div of the content and this works fine in this simple example but if you have a lot of different controllers and so on you so look more into where to define them.
The view:
In this view, I am going to present a search field and after the user has done a search I will show a list of profiles and also make it possible to edit them inside the list.
@{
Layout = "Layout/ProfilesLayout.cshtml";
}
<div class="row">
<div class="col-sm-12 col-md-12 main">
<div id="ListView">
<h1>Administrate user profiles</h1>
<div class="row no-margin main-form">
<form name="searchProfiles" id="searchProfiles">
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>
<label for="searchString">Name or Username</label>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<input type="text" ng-model="searchString" id="searchString" class="form-control">
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td><input type="button" class="btn btn-default btn-md" value="Search" ng-click="search()" /></td>
</tr>
</tfoot>
</table>
</form>
<table class="table table-striped table-bordered">
<thead>
<tr>
<th class="col-md-1"></th>
<th class="col-md-2">UserName</th>
<th class="col-md-2">Email</th>
<th class="col-md-2">FirstName</th>
<th class="col-md-2">LastName</th>
<th class="col-md-3">Phone</th>
</tr>
</thead>
<tbody>
<tr class="pointer" ng-repeat-start="userProfile in userProfiles">
<td class="col-md-1"><button type="button" class="btn btn-default" ng-click="EditUserProfile(userProfile)"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></button></td>
<td class="col-md-2">{{userProfile.UserName}}</td>
<td class="col-md-2">{{userProfile.Email}}</td>
<td class="col-md-2">{{userProfile.FirstName}}</td>
<td class="col-md-2">{{userProfile.LastName}}</td>
<td class="col-md-2">{{userProfile.PhoneNumber}}</td>
</tr>
<tr ng-show="currentObject != null && editObject != null && currentObject.UserId == userProfile.UserId" ng-repeat-end="">
<td colspan="6">
<div class="panel panel-default">
<div class="panel-body">
<div class="main-form">
<form name="editProfileForm" id="editProfileForm" role="form" ng-submit="submitEditUserProfile()">
<div class="form-group">
<label>FirstName</label>
<input class="form-control" type="text" name="FirstName" id="FirstName" ng-model="currentObject.FirstName">
</div>
<div class="form-group">
<label>LastName</label>
<input class="form-control" type="text" name="LastName" id="LastName" ng-model="currentObject.LastName">
</div>
<div class="form-group">
<label>Phone</label>
<input class="form-control" type="text" name="PhoneNumber" id="PhoneNumber" ng-model="currentObject.PhoneNumber">
</div>
<button type="submit" class="btn btn-default" ng-disabled="editProfileForm.$invalid">Spara</button>
<button type="button" class="btn btn-default" ng-click="CancelEditUserProfile()">Avbryt</button>
</form>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
The code should be self-explaining and for the angular parts I urge you to look at the angular tutorial I linked earlier in this blog.
One little trick on this is that I start the angular repeat with ng-repeat-start instead of just ng-repeat and then I add another tr where I add ng-repeat-end. This will add an additional hidden row after each profile row that I fill up and show when a user clicks edit this makes it look like the row are expanding.
Create the js-file with logic
To be able to connect the front with some logic I need to create a javascript file where I declare the angular app, controller and logic.
var profilesApp = angular.module('ProfilesApp', []);
profilesApp.controller('profilesCtrl', function ($scope, $http) {
$scope.userProfiles = null;
$scope.currentObject = null;
$scope.editObject = false;
$scope.totalUserProfiles = 0;
$scope.search = function () {
var query = "";
if ($scope.searchString) {
query = $scope.searchString;
}
$scope.userProfiles = null;
$http.get('/api/ProfileApi/SearchUserProfiles/?searchString=' + query)
.then(function (result) {
$scope.totalUserProfiles = result.data.Count;
$scope.userProfiles = result.data;
});
};
$scope.EditUserProfile = function (userProfile) {
if ($scope.editObject === true) {
$scope.currentObject = null;
$scope.editObject = false;
} else {
$http.get('/api/ProfileApi/GetUserProfile?userId=' + userProfile.UserId)
.success(function (data) {
$scope.currentObject = data;
$scope.editObject = true;
});
}
};
$scope.CancelEditUserProfile = function () {
$scope.currentObject = null;
$scope.editObject = false;
};
$scope.submitEditUserProfile = function () {
if ($scope.currentObject != null && $scope.currentObject.UserId != null) {
$http.post('/api/ProfileApi/UpsertUserProfile', $scope.currentObject)
.success(function (data) {
$scope.currentObject = null;
$scope.editObject = false;
$scope.search($scope.searchString);
})
.error(function () {
alert("Error on update profile");
});
}
};
});
In this file, I have four functions, search, edit, cancelEdit and submitEdit and in all of them I am using the data binding from the view through the scope variable.
To be able to communicate with the database I am doing ajax request to a web api and then updating the scope variable with the returned data.
Creating the Web Api
I am using web api 2 to create my REST api functions and that helps me a lot, since so much are built into the code from Microsoft.
using System;
using System.Web.Http;
using ExtendingEditUi.Business.Repositories;
using ExtendingEditUi.Models.Entities;
namespace ExtendingEditUi.Controllers.Api
{
[Authorize(Roles = "Administrators, WebAdmins, WebEditors")]
public class ProfileApiController : ApiController
{
private readonly IUserProfileRepository userProfileRepository;
public ProfileApiController(IUserProfileRepository userProfileRepository)
{
this.userProfileRepository = userProfileRepository;
}
[AcceptVerbs("GET")]
public IHttpActionResult SearchUserProfiles(string searchString)
{
return this.Ok(this.userProfileRepository.SearchUsers(searchString));
}
[AcceptVerbs("GET")]
public IHttpActionResult GetUserProfile(string userId)
{
return this.Ok(this.userProfileRepository.GetUserProfile(userId));
}
[AcceptVerbs("POST")]
public IHttpActionResult UpsertUserProfile(UserProfile userProfile)
{
try
{
this.userProfileRepository.UpsertUserProfile(userProfile);
}
catch (Exception ex)
{
return this.InternalServerError(ex);
}
return this.Ok("Update done");
}
}
}
As you can see I am using dependency injection inside the controller and to make this work with web api there are a couple of things you need to do and I have write a blog post about that and you can find it here:
Other than that, it is a simple controller that almost just send through the request to the repository but there is one very important thing and that is to also here add the Authorize attribute because otherwise you will expose it to all the web, and you do not want to do that.
Creating the repository
To be able to talk to the database there are many ways you can do that. I am not a big fan of big ORM solutions like Entity Framework och nHibernate since I think they often write ugly SQL code and also when not seeing the SQL code it tends to often be non-performance effective solutions. But this is only my personal view, if you want to use for example EF, please do so, the only important thing is that you NEVER write SQL code by concatenating string since that will make your site open for SQL Injections and that you absolutely do not want.
For this solution, I am using a micro ORM called Dapper (https://github.com/StackExchange/dapper-dot-net) that gives me back typed object but I still have full control over the SQL and it’s also encourage me to use parametrized queries and that will protect me against SQL Injections. I tried to hack myself with this solution with the help of an application called Havij and the solution passed. If you like to know more about how to use that, Troy Hunt has made a great video on SQL Injection with Havij (https://www.youtube.com/watch?v=Fp47G4MQFvA).
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Linq;
using Dapper;
using ExtendingEditUi.Models.Entities;
namespace ExtendingEditUi.Business.Repositories
{
public class UserProfileRepository : IUserProfileRepository
{
private readonly IConfigRepository _configRepository;
private readonly string _constringKey;
public UserProfileRepository(IConfigRepository configRepository)
{
_configRepository = configRepository;
_constringKey = "EPiServerDB";
}
public IEnumerable<UserProfile> SearchUsers(string searchString)
{
const string query = @"Select u.UserId, u.UserName, p.PropertyValueStrings as Email, up.FirstName, up.LastName, up.PhoneNumber
From Users u
Inner join Profiles p on (u.UserId = p.UserId AND p.PropertyNames like 'Email:%')
Left outer join UserProfile up on u.UserId = up.UserId
Where UserName like @searchString OR (ISNULL(up.FirstName, '') + ' ' + ISNULL(up.LastName, '')) like @searchString";
using (var conn = new SqlConnection(_configRepository.GetConnectionString(_constringKey)))
{
conn.Open();
return conn.Query<UserProfile>(query, new { SearchString = string.Format("%{0}%", searchString)});
}
}
public UserProfile GetUserProfile(string userId)
{
const string query = @"Select u.UserId, u.UserName, p.PropertyValueStrings as Email, up.FirstName, up.LastName, up.PhoneNumber
From Users u
Inner join Profiles p on (u.UserId = p.UserId AND p.PropertyNames like 'Email:%')
Left outer join UserProfile up on u.UserId = up.UserId
Where u.UserId = @UserId";
using (var conn = new SqlConnection(_configRepository.GetConnectionString(_constringKey)))
{
conn.Open();
return conn.Query<UserProfile>(query, new { UserId = userId }).SingleOrDefault();
}
}
public UserProfile UpsertUserProfile(UserProfile userProfile)
{
const string query = @"Update UserProfile
Set FirstName = @FirstName, LastName = @LastName, PhoneNumber = @PhoneNumber
Where UserId = @UserId
If @@ROWCOUNT = 0
BEGIN
Insert Into UserProfile
(UserId, FirstName, LastName, PhoneNumber)
Values
(@UserId, @FirstName, @LastName, @PhoneNumber)
END
Select u.UserId, u.UserName, p.PropertyValueStrings as Email, up.FirstName, up.LastName, up.PhoneNumber
From Users u
Inner join Profiles p on (u.UserId = p.UserId AND p.PropertyNames like 'Email:%')
Left outer join UserProfile up on u.UserId = up.UserId
Where u.UserId = @UserId";
using (var conn = new SqlConnection(_configRepository.GetConnectionString(_constringKey)))
{
conn.Open();
return conn.Query<UserProfile>(query, new {userProfile.UserId, userProfile.FirstName, userProfile.LastName, userProfile.PhoneNumber }).SingleOrDefault();
}
}
}
}
There are not so much special with this repository, I started to make it testable and tried to get rid of the using (.. new SqlConnection) but did not get the whole way, so that is one thing that will be updated later. Other than that, the only thing that might seem strange is the Upsert function.
It is a pattern to move the logic of knowing if it should be an update or insert further down the code and it works like this. First it tries to do an update and if it gets zero rows effected back there are no row like that in the database so then it does an insert instead. This will give an extra execution for an insert but I think it’s worth it because it saves me a lot of if-statements in the c# code.
Conclusion
After all this are done, you will have a new view that looks like this:
This is a simple example on how you can create this views and even if the example feels unusable the technique is very usable and I use it a lot and my clients loves it.
My goal with this post is for you to not being afraid of adding admin/edit of external data into your Episerver solution and for you to see that you can do it without learning DOJO and how to hook into the ordinary editor.
I have written a couple of tests but I will write more and I will try to use this codebase to do some good example on how to do unit testing on a Episerver solution but I am in no way there yet, there are a long way to go.
I publish all the source code on GitHub and I would love if anyone would do pull request to add their own extensions or to fix the code I have written or anything else. You can find the source code here:
https://github.com/hesta96/ExtendingEditUi
I hope this post will inspire you and please add comments and ask stuff or tell me stuff you think might not be good or might be very good, all comments are great comments!
Happy coding!
Big thanks for the interesting topic! I was actually searching for profile settings in editor interface, but your topic also helped to understand different aspects of extending it.
How do you do this without manually registering each and every controller action????? Like, say, by convention? Or area? If I have say an Area "ProductCatalogSynchronisation", with tons of controllers and actions, how can I register it as a plugin which automatically picks up the appropriate views? No cms content is involved. just plain old MVC controllers?
It is just plain MVC Controllers and you do not need to register it, you decorate the menu part with attribute and then all other is routing