Skip to content

Instantly share code, notes, and snippets.

@wuhaixing
Last active October 4, 2020 10:43
Show Gist options
  • Save wuhaixing/e90b8497f925ff9c7bfc to your computer and use it in GitHub Desktop.
Save wuhaixing/e90b8497f925ff9c7bfc to your computer and use it in GitHub Desktop.
How to Construct Yourself UI in KeystoneJS

#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');
	
}

View class

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),if user.name.first in request object is Admin,then add the second argument to the actionQueue;
  • HTTP verbs:You can use get,post,put and delete 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) or view.on('get', { page: 2 }, function(next),on a POST and PUT requests,it will search the req.body for a matching value,on every other request it will search the req.query,if found,then add the third argument to the actionQueue;
  • 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
			|  &middot;			
			a(href='?remove=comment&comment=' + comment.id, title='Delete this comment', rel='tooltip', data-placement='left').comment-delete.js-delete-confirm Delete

@max8hine
Copy link

max8hine commented Apr 6, 2018

Holy Moly, My eyes was wide open when I was reading this post. well explained many of key points!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment