apostrophe
Version:
The Apostrophe Content Management System.
486 lines (433 loc) • 16.9 kB
JavaScript
// `apostrophe-pieces-pages` implements "index pages" that display pieces of a
// particular type in a paginated, filterable way. It's great for implementing
// blogs, event listings, project listings, staff directories... almost any
// content type.
//
// You will `extend` this module in new modules corresponding to your modules
// that extend `apostrophe-pieces`.
//
// To learn more and see complete examples, see:
//
// [Reusable content with pieces](/core-concepts/reusable-content-pieces)
//
// ## Options
//
// ### `piecesFilters`
//
// If present, this is an array of objects with `name` properties. The named cursor filters are
// marked as `safeFor: "public"` if they exist, and an array of choices for each is populated
// in `req.data.piecesFilters.tags` (if the field in question is `tags`), etc. The choices in the
// array are objects with `label` and `value` properties.
//
// If a filter configuration has a `counts` property set to `true`, then the array provided for
// that filter will also have a `count` property for each value. This has a performance
// impact.
var _ = require('@sailshq/lodash');
var async = require('async');
module.exports = {
extend: 'apostrophe-custom-pages',
afterConstruct: function(self) {
self.dispatchAll();
self.enableAddUrlsToPieces();
},
construct: function(self, options) {
self.options.addFields = [
{
type: 'tags',
name: 'withTags',
label: 'Show Content With These Tags'
}
].concat(self.options.addFields || []);
self.label = self.options.label;
self.perPage = options.perPage || 10;
self.piecesModuleName = self.options.piecesModuleName || self.__meta.name.replace(/-pages$/, '');
self.pieces = self.apos.modules[self.piecesModuleName];
self.piecesCssName = self.apos.utils.cssName(self.pieces.name);
self.piecesFilters = self.options.piecesFilters || [];
self.contextMenu = options.contextMenu || [
{
action: 'create-' + self.piecesCssName,
label: 'Create ' + self.pieces.label
},
{
action: 'edit-' + self.piecesCssName,
label: 'Update ' + self.pieces.label,
value: true
},
{
action: 'manage-' + self.piecesCssName,
label: 'Manage ' + self.pieces.pluralLabel
},
{
action: 'versions-' + self.piecesCssName,
label: self.pieces.label + ' Versions',
value: true
},
{
action: 'trash-' + self.piecesCssName,
label: 'Trash ' + self.pieces.label,
value: true
}
];
// Extend this method for your piece type to call additional
// chainable filters by default. You should not add entirely new
// filters here. For that, define the appropriate subclass of
// `apostrophe-pieces-cursor` in your subclass of
// `apostrophe-pieces`.
self.indexCursor = function(req) {
var cursor = self.pieces.find(req, {})
.safeFilters(_.pluck(self.piecesFilters, 'name'))
.queryToFilters(req.query, 'public')
.perPage(self.perPage);
self.filterByIndexPageTags(cursor, req.data.page);
return cursor;
};
// At the URL of the page, display an index view (list view) of
// the pieces, with support for pagination.
self.indexPage = function(req, callback) {
if (self.pieces.contextual) {
req.contextMenu = _.filter(self.contextMenu, function(item) {
// Items specific to a particular piece don't make sense
// on the index page
return !item.value;
// Add the standard items, they have to be available sometime
// when working with an index page
}).concat(self.apos.pages.options.contextMenu);
}
var cursor = self.indexCursor(req);
function getFilters(callback) {
return self.populatePiecesFilters(cursor, callback);
}
function totalPieces(callback) {
cursor.toCount(function(err, count) {
if (err) {
return callback(err);
}
if (cursor.get('page') > cursor.get('totalPages')) {
req.notFound = true;
return callback(null);
}
req.data.totalPieces = count;
req.data.totalPages = cursor.get('totalPages');
return callback();
});
}
function findPieces(callback) {
cursor.toArray(function(err, docs) {
if (err) {
return callback(err);
}
if (self.apos.utils.isAjaxRequest(req)) {
req.template = self.renderer('indexAjax');
} else {
req.template = self.renderer('index');
}
req.data.currentPage = cursor.get('page');
req.data.pieces = docs;
return callback();
});
}
return async.series([getFilters, totalPieces, findPieces], function(err) {
if (err) {
return callback(err);
}
return self.beforeIndex(req, callback);
});
};
// Called before `indexPage`. By default, does nothing.
// A convenient way to extend the functionality.
self.beforeIndex = function(req, callback) {
return setImmediate(callback);
};
// Invokes filters on the given cursor to ensure it only fetches
// results with the tags this page has been locked down to via
// page settings. If it has not been locked down, no filtering occurs.
// Override to change the behavior.
self.filterByIndexPageTags = function(cursor, page) {
if (page.withTags && page.withTags.length) {
cursor.and({ tags: { $in: page.withTags } });
}
};
// Invoked to display a piece by itself, a "show page." Renders
// the `show.html` template after setting `data.piece`.
//
// If the pieces module is set `contextual: true`, the context menu
// (the gear at lower left) is updated appropriately if the user has
// editing permissions.
self.showPage = function(req, callback) {
// We'll try to find the piece as an ordinary reader and, if the piece type
// is contextual, we'll also try it as an editor if needed
var doc;
var previous;
var next;
return async.series([
findAsReader,
findAsEditor,
findPrevious,
findNext
], function(err) {
if (err) {
return callback(err);
}
if (!doc) {
req.notFound = true;
return callback(null);
}
if (self.pieces.contextual) {
req.contextMenu = _.map(self.contextMenu, function(item) {
if (item.value) {
// Don't modify a shared item, race conditions
// could give us the wrong ids
item = _.clone(item);
item.value = doc._id;
}
return item;
});
req.publishMenu = [
{
action: 'publish-' + self.piecesCssName,
label: 'Publish ' + self.pieces.label,
value: doc._id
}
];
}
req.template = self.renderer('show');
req.data.piece = doc;
req.data.previous = previous;
req.data.next = next;
return self.beforeShow(req, callback);
});
function findAsReader(callback) {
var cursor = self.pieces.find(req, { slug: req.params.slug });
return cursor.sort(false).toObject(function(err, _doc) {
if (err) {
return callback(err);
}
doc = _doc;
return callback(null);
});
}
function findAsEditor(callback) {
if (doc || (!req.user) || (!self.pieces.contextual)) {
return callback(null);
}
// Use findForEditing to allow subclasses to extend the set of filters that
// don't apply by default in an editing context. -Tom
var cursor = self.pieces.findForEditing(req, { slug: req.params.slug });
return cursor.sort(false).toObject(function(err, _doc) {
if (err) {
return callback(err);
}
doc = _doc;
return callback(null);
});
}
function findPrevious(callback) {
if (!self.options.previous) {
return callback(null);
}
if (!doc) {
return callback(null);
}
var cursor = self.indexCursor(req);
return cursor.previous(doc)
.applyFilters(
typeof (self.options.previous) === 'object'
? self.options.previous : {}
)
.toObject(function(err, _previous) {
if (err) {
return callback(err);
}
previous = _previous;
return callback(null);
});
}
function findNext(callback) {
if (!self.options.next) {
return callback(null);
}
if (!doc) {
return callback(null);
}
var cursor = self.indexCursor(req);
return cursor.next(doc)
.applyFilters(
typeof (self.options.next) === 'object'
? self.options.next : {}
)
.toObject(function(err, _next) {
if (err) {
return callback(err);
}
next = _next;
return callback(null);
});
}
};
// Invoked just before the piece is displayed on its "show page." By default,
// does nothing. A useful override point.
self.beforeShow = function(req, callback) {
return setImmediate(callback);
};
// Set the dispatch routes. By default, the bare URL of the page displays
// the index view via `indexPage`; if the URL has an additional component,
// e.g. `/blog/good-article`, it is assumed to be the slug of the
// article and `showPage` is invoked. You can override this method,
// for instance to also accept `/:year/:month/:day/:slug` as a way of
// invoking `self.showPage`. See [apostrophe-custom-pages](/reference/modules/apostrophe-custom-pages)
// for more about what you can do with dispatch routes.
self.dispatchAll = function() {
self.dispatch('/', self.indexPage);
self.dispatch('/:slug', self.showPage);
};
// Given an array containing all of the index pages of this type that
// exist on the site and an individual piece, return the
// index page that is the best fit for use in the URL of
// the piece. The algorithm is based on shared tags, with
// an emphasis on matching tags, and also favors an index
// page with no preferred tags over bad matches. Override to
// replace the algorithm.
//
// This method is called for you. In the presence of index pages, the
// cursors for the corresponding pieces are automatically enhanced to
// invoke it when building URLs.
self.chooseParentPage = function(pages, piece) {
var property = self.options.chooseParentPageBy || 'tags';
// The "tags" property was moved to "withTags" to distinguish
// what a page *shows* from what a page *is*
var pageProperty = (property === 'tags') ? 'withTags' : property;
var tags = piece[property] || [];
var bestScore;
var best = null;
_.each(pages, function(page) {
var score = 0;
var pageTags = page[pageProperty] ? page[pageProperty] : [];
if (!pageTags.length) {
score = 1;
}
var intersect = _.intersection(tags, pageTags);
var diff = _.difference(tags, pageTags);
score += intersect.length * 2 - diff.length;
if ((!best) || (score > bestScore)) {
bestScore = score;
best = page;
}
});
return best;
};
// Given an index page and a piece, build a complete URL to
// the piece. If you override `dispatch` to change how
// "show page" URLs are matched, you will also want to override
// this method to build them differently.
self.buildUrl = function(page, piece) {
if (!page) {
return false;
}
// Don't double-slash if the pieces-page is the home page
return self.apos.utils.addSlashIfNeeded(page._url) + piece.slug;
};
// Make the browser-side `apos` object aware of the current
// in-context piece, as `apos.contextPiece`. Just enough to
// help the contextual editing tools in various modules
self.pushContextPiece = function(req) {
if (self.pieces.options.contextual && req.data.piece) {
req.browserCall('apos.contextPiece = ?', _.pick(req.data.piece, '_id', 'title', 'slug', 'type'));
}
};
var superPageBeforeSend = self.pageBeforeSend;
// Calls `pushContextPiece` to make `apos.contextPiece` available
// in the browser
self.pageBeforeSend = function(req) {
self.pushContextPiece(req);
return superPageBeforeSend(req);
};
// Adds the `._url` property to all of the provided pieces,
// which are assumed to be of the appropriate type for this module.
// Aliased as the `addUrls` method of [apostrophe-pieces](/reference/modules/apostrophe-pieces), which
// is invoked by the `addUrls` filter of [apostrophe-cursor](/reference/modules/apostrophe-docs/server-apostrophe-cursor.md).
self.addUrlsToPieces = function(req, results, callback) {
var pieceName = self.pieces.name;
return async.series({
getIndexPages: function(callback) {
if (req.aposParentPageCache && req.aposParentPageCache[pieceName]) {
return setImmediate(callback);
}
return self.findForAddUrlsToPieces(req)
.toArray(function(err, pages) {
if (err) {
return callback(err);
}
if (!req.aposParentPageCache) {
req.aposParentPageCache = {};
}
req.aposParentPageCache[pieceName] = pages;
return callback(null);
}
);
}
}, function(err) {
if (err) {
return callback(err);
}
_.each(results, function(piece) {
var parentPage = self.chooseParentPage(req.aposParentPageCache[pieceName], piece);
if (parentPage) {
piece._url = self.buildUrl(parentPage, piece);
piece._parentUrl = parentPage._url;
}
});
return callback(null);
});
};
// Returns a cursor suitable for finding pieces-pages for the
// purposes of assigning URLs to pieces based on the best match.
//
// Should be as fast as possible while still returning enough
// information to do that. For instance, tags are essential for the standard
// `chooseParentPage` algorithm, but joins and areas are not.
//
// The default implementation returns a cursor with areas
// and joins shut off.
self.findForAddUrlsToPieces = function(req) {
return self.find(req)
.areas(false)
.joins(false);
};
// Configure our `addUrlsToPieces` method as the `addUrls` method
// of the related pieces module.
self.enableAddUrlsToPieces = function() {
self.pieces.setAddUrls(self.addUrlsToPieces);
};
// Populate `req.data.piecesFilters` with arrays of choice objects,
// with label and value properties, for each filter configured in the
// `piecesFilters` array option. Each filter in that array must have a
// `name` property. Distinct values are fetched for the corresponding
// cursor filter (note that most schema fields automatically get a
// corresponding cursor filter method). Each filter's choices are
// reduced by the other filters; for instance, "tags" might only reveal
// choices not ruled out by the current "topic" filter setting.
//
// If a filter in the array has its `counts` property set to true,
// Apostrophe will supply a `count` property for each distinct value,
// whenever possible. This has a performance impact.
self.populatePiecesFilters = function(cursor, callback) {
var req = cursor.get('req');
req.data.piecesFilters = req.data.piecesFilters || {};
return async.eachSeries(self.piecesFilters, function(filter, callback) {
// The choices for each filter should reflect the effect of all filters
// except this one (filtering by topic pares down the list of categories and
// vice versa)
var _cursor = cursor.clone();
_cursor[filter.name](undefined);
return _cursor.toChoices(filter.name, _.pick(filter, 'counts'), function(err, choices) {
if (err) {
return callback(err);
}
req.data.piecesFilters[filter.name] = choices;
return callback(null);
});
}, callback);
};
}
};