#How to Construct Yourself UI in KeystoneJS
KeystoneJS provide Admin UI with one set of route controllers and view templates(list&item) for all of the models.But usually,you will need some custome views other than Admin UI to display models. Although the KeystoneJS documention don't tell us much about how to contruct custome view,we can learn this from the source code in skeleton project generated by yo keystone
,or you can just check out the keystone demo project's source code.We will walk through the blog feature's implementation in this demo application to demonstrate how to construct custome UI in KeystoneJS application.
As KeystoneJS documention described in Routes & Views section,there is a routes/index.js
file, where we bind application's URL patterns to the controllers that load and process data, and render the appropriate template.You can find following code in it:
app.get('/blog/:category?', routes.views.blog);
app.all('/blog/post/:post', routes.views.post);
They bind the routes.views.blog
to URL parttern /blog/:category?
for GET
request,and routes.views.post
to URL pattern /blog/post/:post
for all types of request,include GET
, POST
etc.
Now we are going to check the route controllers for the blog&post page,start from /routes/views/blog.js
.
##Blog route controller
var keystone = require('keystone'),
async = require('async');
exports = module.exports = function(req, res) {
var view = new keystone.View(req, res),
locals = res.locals;
// Init locals
locals.section = 'blog';
locals.filters = {
category: req.params.category
};
locals.data = {
posts: [],
categories: []
};
// Load all categories
view.on('init', function(next) {
...
});
// Load the current category filter
view.on('init', function(next) {
...
});
// Load the posts
view.on('init', function(next) {
var q = keystone.list('Post').paginate({
page: req.query.page || 1,
perPage: 10,
maxPages: 10
})
.where('state', 'published')
.sort('-publishedDate')
.populate('author categories');
if (locals.data.category) {
q.where('categories').in([locals.data.category]);
}
q.exec(function(err, results) {
locals.data.posts = results;
next(err);
});
});
// Render the view
view.render('blog');
}
The most part of code in this controller related with Keyston.View
,we have three view.on('init', callback)
expressions, and there is also a view.render('blog')
at the end.So what is the Keyston.View
?It's a helper class to simplify view logic in a Keystone application,defined in /lib/view.js
.
The View
class makes it easier to write descriptive route handlers without worrying about async code too much. Express routes can get messy when they're handling several different branches, because of the async nature of node.js.View
has four queues:
this.initQueue = []; // executed first in series
this.actionQueue = []; // executed second in parallel, if optional conditions are met
this.queryQueue = []; // executed third in parallel
this.renderQueue = []; // executed fourth in parallel
And there is a on
function,when you call view.on('init', callback)
,you add a method (or array of methods) to the initQueue
, and these method will be executed in series at first.You have a few other options as first argument of on
method:
- init:Add the second argument to
initQueue
,init events are always fired in series, before any other actions; - A function: If it returns truthy then add the second argument to the
actionQueue
; - An object:Do certain actions depending on information in the request object,for example,
view.on({ 'user.name.first': 'Admin' }, function(next)
,ifuser.name.first
in request object isAdmin
,then add the second argument to theactionQueue
; - HTTP verbs:You can use
get
,post
,put
anddelete
as the first argument,sencond argument can be a function or an object.If it's an object,for example,view.on('post', { action: 'theAction' }, function(next)
orview.on('get', { page: 2 }, function(next)
,on aPOST
andPUT
requests,it will search thereq.body
for a matching value,on every other request it will search thereq.query
,if found,then add the third argument to theactionQueue
; - render:Add the second argument to
renderQueue
,render events are always fired last in parallel, after any other actions.
View class also provide a query(key, query, options)
method to queues a mongoose query for execution before the view is rendered.The results of the query are set in locals[key]
.Keys can be nested paths, containing objects will be created as required.The third argument then
can be a method to call after the query is completed like function(err, results, callback)
, or a populatedRelated
definition (string or array).For examples:
view.query('books', keystone.list('Book').model.find());
An array of books from the database will be added to locals.books. You can also nest properties on the locals variable:
view.query(
'admin.books',
keystone.list('Book').model.find().where('user', 'Admin')
);
locals.admin.books
will be the result of the query.views.query().then
is always called if it is available
view.query('books', keystone.list('Book').model.find())
.then(function (err, results, next) {
if (err) return next(err);
console.log(results);
next();
});
At last is render(renderFn, locals, callback)
method,it will executes the current queue of init,action and query methods in series, and then executes the render function. If renderFn is a string, it is provided to res.render
.It is expected that most init stacks require processing in series,but it is safe to execute actions in parallel.If there are several init methods that should be run in parallel, queue
them as an array, e.g. view.on('init', [first, second])
.
There should be one more thing should be noticed in /routes/views/blog.js
,when load the posts,the query used is keystone.list('Post').paginate
.
###Pagination
paginate
is a method defined in List
class,it gets a special Query object that will paginate documents in the list.And the result it return an object contains properties:
{
total: count,
results: results,
currentPage: currentPage,
totalPages: totalPages,
pages: [],
previous: (currentPage > 1) ? (currentPage - 1) : false,
next: (currentPage < totalPages) ? (currentPage + 1) : false,
first: skip + 1,
last: skip + results.length
};
When you put query results
on locals.data.posts
,you can access all of these properties in template,so you can get pagination in /templates/views/blog.jade
very easy:
if data.posts.totalPages > 1
ul.pagination
if data.posts.previous
li: a(href='?page=' + data.posts.previous): span.entypo.entypo-chevron-thin-left
else
li.disabled: a(href='?page=' + 1): span.entypo.entypo-chevron-thin-left
each p, i in data.posts.pages
li(class=data.posts.currentPage == p ? 'active' : null)
a(href='?page=' + (p == '...' ? (i ? data.posts.totalPages : 1) : p ))= p
if data.posts.next
li: a(href='?page=' + data.posts.next): span.entypo.entypo-chevron-thin-right
else
li.disabled: a(href='?page=' + data.posts.totalPages): span.entypo.entypo-chevron-thin-right
###Form Post
In the View class section,we mentioned you can use HTTP verbs as first argument of on
method,there examplse in /routes/views/post.js
.
var keystone = require('keystone'),
async = require('async'),
Post = keystone.list('Post'),
PostComment = keystone.list('PostComment');
exports = module.exports = function(req, res) {
var view = new keystone.View(req, res),
locals = res.locals;
// Init locals
locals.section = 'blog';
locals.filters = {
post: req.params.post
};
locals.data = {
posts: []
};
// Load the current post
view.on('init', function(next) {
...
});
// Load other posts
view.on('init', function(next) {
...
});
// Load comments on the Post
view.on('init', function(next) {
...
});
// Create a Comment
view.on('post', { action: 'comment.create' }, function(next) {
var newComment = new PostComment.model({
state: 'published',
post: locals.data.post.id,
author: locals.user.id
});
var updater = newComment.getUpdateHandler(req);
updater.process(req.body, {
fields: 'content',
flashErrors: true,
logErrors: true
}, function(err) {
if (err) {
data.validationErrors = err.errors;
} else {
req.flash('success', 'Your comment was added.');
return res.redirect('/blog/post/' + locals.data.post.slug + '#comment-id-' + newComment.id);
}
next();
});
});
// Delete a Comment
view.on('get', { remove: 'comment' }, function(next) {
if (!req.user) {
req.flash('error', 'You must be signed in to delete a comment.');
return next();
}
PostComment.model.findOne({
_id: req.query.comment,
post: locals.data.post.id
})
.exec(function(err, comment) {
if (err) {
if (err.name == 'CastError') {
req.flash('error', 'The comment ' + req.query.comment + ' could not be found.');
return next();
}
return res.err(err);
}
if (!comment) {
req.flash('error', 'The comment ' + req.query.comment + ' could not be found.');
return next();
}
if (comment.author != req.user.id) {
req.flash('error', 'Sorry, you must be the author of a comment to delete it.');
return next();
}
comment.commentState = 'archived';
comment.save(function(err) {
if (err) return res.err(err);
req.flash('success', 'Your comment has been deleted.');
return res.redirect('/blog/post/' + locals.data.post.slug);
});
});
});
// Render the view
view.render('post');
}
There two view.on
methods's first arugment is HTTP verbs,one method use post
create a comment,and another use get
delete a comment.And in /templates/mixins/commenting.jade
,you can see action: 'comment.create'
will be post and showed up in req.body
,and { remove: 'comment' }
will showed up in req.query
:
mixin comment-form(action)
form(method='post').comment-form
input(type='hidden', name='action', value='comment.create')
...
mixin comment-post(comment)
if comment.author
...
if user && user.id == comment.author.id
| ·
a(href='?remove=comment&comment=' + comment.id, title='Delete this comment', rel='tooltip', data-placement='left').comment-delete.js-delete-confirm Delete
Holy Moly, My eyes was wide open when I was reading this post. well explained many of key points!