Recreating the listview in a custom section
Heads Up!
This article is several years old now, and much has happened since then, so please keep that in mind while reading it.
Introduction
In this article I will show how easy it is to add the Umbraco list view to your custom section.
I was always curious how you could use the Umbraco listview in a custom section of your own. A few days ago I had a little talk with Tim Geyssens about his new package UI-O-Matic (if you haven't checked it out already i recommend you doing it!) and we talk about how to enable the list view for nodes in the custom section of this package. A day later and Tim had implemented a basic list view as it was nothing. So again a big #h5yr!
So to be honest the list view is not the built-in list view of Umbraco. That's because it's so specific to all things umbraco does when editing content that it's not easy to implement it in a custom section. I really think it's not possible at all. But proof me wrong if you can! So to make it more clear we will implement the listview behaviour on our own!
The result we want to achieve is this:
After our section is ready, I need a tree that is responsible for the new section. I won't get into detail here as it is plain simple. It just returns one node as the root node, which has the view assigned with our listview. So nothing special here.
[Tree("demoSection", "demoTree", "ListView Demo")]
[PluginController("ListViewDemo")]
public class DemoTreeController : TreeController
{
protected override Umbraco.Web.Models.Trees.MenuItemCollection GetMenuForNode(string id, System.Net.Http.Formatting.FormDataCollection queryStrings)
{
return null;
}
protected override Umbraco.Web.Models.Trees.TreeNodeCollection GetTreeNodes(string id, System.Net.Http.Formatting.FormDataCollection queryStrings)
{
//check if we're rendering the root node's children
if (id == "-1")
{
var nodes = new TreeNodeCollection();
nodes.Add(CreateTreeNode("people", id, queryStrings, "People", "icon-people", false, FormDataCollectionExtensions.GetValue<string>(queryStrings, "application") + StringExtensions.EnsureStartsWith(this.TreeAlias, '/') + "/overviewPeople/all"));
return nodes;
}
//this tree doesn't suport rendering more than 2 levels
throw new NotSupportedException();
}
}
For some convenience I use the ServerVariableParser to register the path to our API-Controller to reuse it in our AngularJS code a bit more easier. If you haven't, you should take a look at that way to store your paths.
public class ServerVariableParserEvent : ApplicationEventHandler
{
protected override void ApplicationStarted(UmbracoApplicationBase umbracoApplication, ApplicationContext applicationContext)
{
ServerVariablesParser.Parsing += ServerVariablesParser_Parsing;
}
void ServerVariablesParser_Parsing(object sender, Dictionary<string, object> e)
{
if (HttpContext.Current == null) return;
var urlHelper = new UrlHelper(new RequestContext(new HttpContextWrapper(HttpContext.Current), new RouteData()));
var mainDictionary = new Dictionary<string, object>();
mainDictionary.Add("demoBaseUrl", urlHelper.GetUmbracoApiServiceBaseUrl<PeopleApiController>(controller => controller.GetAll()));
if (!e.Keys.Contains("demoSection"))
{
e.Add("demoSection", mainDictionary);
}
}
}
To have some dummy data I create some on startup. This is using PetaPoco to drop and create the necessary table and insert some dummy pocos. For some additional information about PetaPoco I made a blog post and there are some additional information on the web.
public class Startup : ApplicationEventHandler
{
protected override void ApplicationStarted(UmbracoApplicationBase umbracoApplication, ApplicationContext applicationContext)
{
var db = applicationContext.DatabaseContext.Database;
if (db.TableExist("demo_people"))
{
db.DropTable<People>();
}
db.CreateTable<People>(false);
var objects = Enumerable.Range(0, 10).Select(i => new People() { FirstName = "Firstname" + i, LastName = "Lastname" + i, Age = i + 20 });
objects.ForEach(p => db.Save(p));
}
}
Models
Let's have a look at the models that are used. As can be seen I use a "People" model for this demo. So the listview will display some people information. It's dead simple for this demo. The model is marked by different attributes which are used by PetaPoco to know the database structure and mapping of table columns to properties. Also I'm using DataContract for the json serialisation.
/// <summary>
/// Class which represents our people
/// </summary>
[TableName("demo_people")]
[PrimaryKey("id", autoIncrement = true)]
[ExplicitColumns]
[DataContract(Name = "people", Namespace = "")]
public class People
{
[Column("id")]
[PrimaryKeyColumn(AutoIncrement = true)]
[DataMember(Name = "id")]
public int Id { get; set; }
[Column("firstname")]
[DataMember(Name = "firstName")]
public string FirstName { get; set; }
[Column("lastname")]
[DataMember(Name = "lastName")]
public string LastName { get; set; }
[Column("age")]
[DataMember(Name = "age")]
public int Age { get; set; }
}
I added just a few properties to the model to have at least three columns that I can show in the listview.
[DataContract(Name = "pagedPeople", Namespace = "")]
public class PagedPeopleResult
{
[DataMember(Name = "people")]
public List<People> People { get; set; }
[DataMember(Name = "currentPage")]
public long CurrentPage { get; set; }
[DataMember(Name = "itemsPerPage")]
public long ItemsPerPage { get; set; }
[DataMember(Name = "totalPages")]
public long TotalPages { get; set; }
[DataMember(Name = "totalItems")]
public long TotalItems { get; set; }
}
Additionally we have a secondary model which is used for the listview by providing information for paging and the people objects we want to show regarding to search and paging.
API controller
The next thing I need is an API-Controller which returns the information for our listview as the PagedPeopleModel seen above.
The controller takes some arguments like the search term, itemsPerPage, order and so on.
With this information it gathers the right objects from the database. For this the controller generates an sql statement that considers the search term, sort column and sort order.
/// <summary>
/// People Api controller used by the people resource to get people for our custom section
/// </summary>
[PluginController("ListViewDemo")]
public class PeopleApiController : UmbracoAuthorizedJsonController
{
public IEnumerable<People> GetAll()
{
return Enumerable.Empty<People>();
}
public PagedPeopleResult GetPaged(int itemsPerPage, int pageNumber, string sortColumn,
string sortOrder, string searchTerm)
{
var items = new List<People>();
var db = DatabaseContext.Database;
var currentType = typeof(People);
var query = new Sql().Select("*").From("demo_people");
if (!string.IsNullOrEmpty(searchTerm))
{
int c = 0;
foreach (var property in currentType.GetProperties())
{
string before = "WHERE";
if (c > 0)
{
before = "OR";
}
var columnAttri =
property.GetCustomAttributes(typeof(ColumnAttribute), false);
var columnName = property.Name;
if (columnAttri.Any())
{
columnName = ((ColumnAttribute)columnAttri.FirstOrDefault()).Name;
}
query.Append(before + " [" + columnName + "] like @0", "%" + searchTerm + "%");
c++;
}
}
if (!string.IsNullOrEmpty(sortColumn) && !string.IsNullOrEmpty(sortOrder))
query.OrderBy(sortColumn + " " + sortOrder);
else
{
query.OrderBy("id asc");
}
var p = db.Page<People>(pageNumber, itemsPerPage, query);
var result = new PagedPeopleResult
{
TotalPages = p.TotalPages,
TotalItems = p.TotalItems,
ItemsPerPage = p.ItemsPerPage,
CurrentPage = p.CurrentPage,
People = p.Items.ToList()
};
return result;
}
}
So what magic does happen here. First we check if a search term was set and if yes we need to build the where clause. To do this we go through each property of our poco and check if we find any column-attributes on them. If yes we enlarge the where clause with the name/value of that attribute, if not we just using the property name.
After that I check if sort column and sort order is set and adding the OrderBy part to the query with the column and sort order.If they are not provided the ORderBy is defined with default values.
With the finished statement a query is run to the database to fetch all objects. To consider our pagination state the Paged()-method is used which takes the current page number, how many items per page should be used and the build statement.
Package
As for each (or nearly each) extension built for Umbraco like grid or property editors there is the need of a package.manifest file. This is used to register our AngularJS controller and resource needed for the listview.
{
propertyEditors: [],
parameterEditors:[],
javascript: [
'~/App_Plugins/ListViewDemo/Resources/people.resource.js',
'~/App_Plugins/ListViewDemo/Controller/people.overview.controller.js'
],
css: []
}
As this is a fairly simple demo only two js files are registered and nothing more.
AngularJS
First have a look at the resource file. It used to have all calls to our API-Controller bundled in one file for more convenience and a better code structure. The resource provides two methods to fetch data from the API. The only one used in this demo is in fact the getPaged()-method.
angular.module("umbraco.resources")
.factory("peopleResource", function ($http) {
return {
getall: function () {
return $http.get(Umbraco.Sys.ServerVariables.demoSection.demoBaseUrl + "GetAll");
},
getPaged: function (itemsPerPage, pageNumber, sortColumn, sortOrder, searchTerm) {
if (sortColumn == undefined)
sortColumn = "";
if (sortOrder == undefined)
sortOrder = "";
return $http.get(Umbraco.Sys.ServerVariables.demoSection.demoBaseUrl + "GetPaged?itemsPerPage=" + itemsPerPage + "&pageNumber=" + pageNumber + "&sortColumn=" + sortColumn + "&sortOrder=" + sortOrder + "&searchTerm=" + searchTerm);
}
};
});
I reuse the paths that I saved through the ServerVariableParser to not have to type and remember the correct path for every method the resource has.
The next big thing is the controller which calls the resource file and handles the user input on the listview.
angular.module('umbraco')
.controller('DemoSection.PeopleOverviewController', function ($scope, peopleResource, notificationsService) {
$scope.selectedIds = [];
$scope.currentPage = 1;
$scope.itemsPerPage = 10;
$scope.totalPages = 1;
$scope.reverse = false;
$scope.searchTerm = "";
$scope.predicate = 'id';
function fetchData() {
peopleResource.getPaged($scope.itemsPerPage, $scope.currentPage, $scope.predicate, $scope.reverse ? "desc" : "asc", $scope.searchTerm).then(function (response) {
$scope.people = response.data.people;
$scope.totalPages = response.data.totalPages;
}, function (response) {
notificationsService.error("Error", "Could not load people");
});
};
$scope.order = function (predicate) {
$scope.reverse = ($scope.predicate === predicate) ? !$scope.reverse : false;
$scope.predicate = predicate;
$scope.currentPage = 1;
fetchData();
};
$scope.toggleSelection = function (val) {
var idx = $scope.selectedIds.indexOf(val);
if (idx > -1) {
$scope.selectedIds.splice(idx, 1);
} else {
$scope.selectedIds.push(val);
}
};
$scope.isRowSelected = function (id) {
return $scope.selectedIds.indexOf(id) > -1;
};
$scope.isAnythingSelected = function () {
return $scope.selectedIds.length > 0;
};
$scope.prevPage = function () {
if ($scope.currentPage > 1) {
$scope.currentPage--;
fetchData();
}
};
$scope.nextPage = function () {
if ($scope.currentPage < $scope.totalPages) {
$scope.currentPage++;
fetchData();
}
};
$scope.setPage = function (pageNumber) {
$scope.currentPage = pageNumber;
fetchData();
};
$scope.search = function (searchFilter) {
$scope.searchTerm = searchFilter;
$scope.currentPage = 1;
fetchData();
};
$scope.delete = function () {
if (confirm("Are you sure you want to delete " + $scope.selectedIds.length + " calendar?")) {
$scope.actionInProgress = true;
//TODO: do the real deleting here
//This should be done by calling the api controller with the peopleResource using $scope.selectedIds
$scope.people = _.reject($scope.people, function (el) { return $scope.selectedIds.indexOf(el.id) > -1; });
$scope.selectedIds = [];
$scope.actionInProgress = false;
}
};
fetchData();
});
Let's have a look at the most important ones:
The fetchData()-method is obviously used to fetch the data from the API by using the resource created previously by calling the getPaged-method with some $scope variables which are holding information about the search term, itemsPerPage, current page and so on.
The order()-method is used for ordering the data (yeah captain obvious). The variables reverse and predicate are used in the view which is described a bit later.
The toogleSelection()-method is used to store the ids of the items we checked in the listview. This is needed for bulk deleting, updating or whatever else.
The prevPage()-, nextPage()- and setPage()-methods are used for the pagination to set on which page we currently are.
The search()-method is of course used to set the search term.
All of these methods are calling fetchData() at the end to get the right objects from the database regarding the variables we set through them.
HTML
The html view is divided into two main parts. The first one is the header above the table which holds the search box and the delete button for bulk deleting.
<div class="umb-sub-header">
<div class="btn-group" ng-show="isAnythingSelected()">
<a class="btn btn-danger" ng-disabled="actionInProgress" ng-click="delete()" prevent-default="">
<localize key="actions_delete">Delete</localize>
</a>
</div>
<form class="form-search pull-right ng-pristine ng-valid" novalidate="">
<div class="inner-addon left-addon">
<i class="icon icon-search"></i>
<input type="text" class="form-control ng-pristine ng-valid" localize="placeholder" placeholder="Type to search..." ng-model="searchFilter" prevent-enter-submit="" no-dirty-check="" ng-change="search(searchFilter)">
</div>
</form>
</div>
The second one is of course the table itself. For the right look and feel for the pagination and the sorting of columns the <thead>, <tbody> and <tfoot> parts of the table are used.
The head part hold the column names with the sort mechanism. One small tip is to use the names for the order()-method which are set in the column-attributes.
<thead>
<tr>
<td style="width:35px"></td>
<td>
<a href="#" prevent-default class="sortable" ng-click="order('firstname')">FirstName</a>
<i class="icon" ng-class="{'icon-navigation-up': reverse, 'icon-navigation-down': !reverse}" ng-show="predicate == 'firstname'"></i>
</td>
<td>
<a href="#" prevent-default class="sortable" ng-click="order('lastname')">LastName</a>
<i class="icon" ng-class="{'icon-navigation-up': reverse, 'icon-navigation-down': !reverse}" ng-show="predicate == 'lastname'"></i>
</td>
<td>
<a href="#" prevent-default class="sortable" ng-click="order('age')">Age</a>
<i class="icon" ng-class="{'icon-navigation-up': reverse, 'icon-navigation-down': !reverse}" ng-show="predicate == 'age'"></i>
</td>
</tr>
</thead>
The body holds the data rendered with the angularjs directive ng-repeat. The first column is used for the checkbox with which we can select multiple rows for deleting.
<tbody>
<tr ng-repeat="p in people" ng-class="{selected: isRowSelected(p.id)}">
<td style="width: 35px">
<i class="icon icon-edit"></i>
<input type="checkbox" name="selectedItems[]" value="{{p.id}}" ng-checked="isRowSelected(p.id)" ng-click="toggleSelection(p.id)" />
</td>
<td>
<a href="#">{{p.firstName}}</a>
</td>
<td>{{p.lastName}}</td>
<td>{{p.age}}</td>
</tr>
</tbody>
The footer holds the pagination. The available page numbers and the prev/next buttons are rendered. Of course this could be tweaked to only show a certain number of pages or hide the prev/next buttons if on the first/last page.
<tfoot ng-show="totalPages > 1">
<tr>
<th colspan="5">
<div class="pagination pagination-right">
<ul>
<li ng-class="{disabled: currentPage == 1}">
<a href ng-click="prevPage()">Prev</a>
</li>
<li ng-repeat="i in getNumber(totalPages) track by $index" ng-click="setPage($index+1)"><span>{{$index+1}}</span></li>
<li ng-class="{disabled: currentPage == totalPages}">
<a href ng-click="nextPage()">Next</a>
</li>
</ul>
</div>
</th>
</tr>
</tfoot>
Here is the complete view:
<umb-panel ng-controller="DemoSection.PeopleOverviewController">
<umb-header>
<h1>Overview</h1>
</umb-header>
<div class="umb-panel-body">
<div class="umb-pane">
<div class="umb-sub-header">
<div class="btn-group" ng-show="isAnythingSelected()">
<a class="btn btn-danger" ng-disabled="actionInProgress" ng-click="delete()" prevent-default="">
<localize key="actions_delete">Delete</localize>
</a>
</div>
<form class="form-search pull-right ng-pristine ng-valid" novalidate="">
<div class="inner-addon left-addon">
<i class="icon icon-search"></i>
<input type="text" class="form-control ng-pristine ng-valid" localize="placeholder" placeholder="Type to search..." ng-model="searchFilter" prevent-enter-submit="" no-dirty-check="" ng-change="search(searchFilter)">
</div>
</form>
</div>
<div class="umb-listview">
<table class="table table-striped">
<thead>
<tr>
<td style="width:35px"></td>
<td>
<a href="#" prevent-default class="sortable" ng-click="order('firstname')">FirstName</a>
<i class="icon" ng-class="{'icon-navigation-up': reverse, 'icon-navigation-down': !reverse}" ng-show="predicate == 'firstname'"></i>
</td>
<td>
<a href="#" prevent-default class="sortable" ng-click="order('lastname')">LastName</a>
<i class="icon" ng-class="{'icon-navigation-up': reverse, 'icon-navigation-down': !reverse}" ng-show="predicate == 'lastname'"></i>
</td>
<td>
<a href="#" prevent-default class="sortable" ng-click="order('age')">Age</a>
<i class="icon" ng-class="{'icon-navigation-up': reverse, 'icon-navigation-down': !reverse}" ng-show="predicate == 'age'"></i>
</td>
</tr>
</thead>
<tbody>
<tr ng-repeat="p in people" ng-class="{selected: isRowSelected(p.id)}">
<td style="width: 35px">
<i class="icon icon-edit"></i>
<input type="checkbox" name="selectedItems[]" value="{{p.id}}" ng-checked="isRowSelected(p.id)" ng-click="toggleSelection(p.id)" />
</td>
<td>
<a href="#">{{p.firstName}}</a>
</td>
<td>{{p.lastName}}</td>
<td>{{p.age}}</td>
</tr>
</tbody>
<tfoot ng-show="totalPages > 1">
<tr>
<th colspan="5">
<div class="pagination pagination-right">
<ul>
<li ng-class="{disabled: currentPage == 1}">
<a href ng-click="prevPage()">Prev</a>
</li>
<li ng-repeat="i in getNumber(totalPages) track by $index" ng-click="setPage($index+1)"><span>{{$index+1}}</span></li>
<li ng-class="{disabled: currentPage == totalPages}">
<a href ng-click="nextPage()">Next</a>
</li>
</ul>
</div>
</th>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</umb-panel>
Wrapping up
As you can see it is possible with fairly simple methods to enhance the user experience of your packages or websites by reusing a functionality that is already used by umbraco itself and thus loved by our editors.
For a better jumb start I created a github repo which is ready to go, so give it a try!
David Brendel
David is on Twitter as @byte5db