apostrophe
Version:
The Apostrophe Content Management System.
462 lines (409 loc) • 17.5 kB
JavaScript
// `@apostrophecms/piece-page-type` 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 `@apostrophecms/piece-type`.
//
// To learn more and see complete examples, see:
//
// [Reusable content with
// pieces](../../tutorials/getting-started/reusable-content-with-pieces.html)
//
// ## Options
//
// ### `piecesFilters`
//
// If present, this is an array of objects with `name` properties. This works
// only if the corresponding query builders exist and have a `launder` method.
// An array of choices for each is populated in `req.data.piecesFilters`. 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.
const _ = require('lodash');
module.exports = {
extend: '@apostrophecms/page-type',
init(self) {
self.label = self.options.label;
self.perPage = self.options.perPage || 10;
self.pieceModuleName = self.options.pieceModuleName || self.__meta.name.replace(/-page$/, '');
self.pieces = self.apos.modules[self.pieceModuleName];
self.piecesCssName = self.apos.util.cssName(self.pieces.name);
self.piecesFilters = self.options.piecesFilters || [];
self.enableAddUrlsToPieces();
},
methods(self) {
const methods = {
// Extend this method for your piece type to call additional
// query builders by default.
indexQuery(req) {
const query = self.pieces
.find(req, {})
.applyBuildersSafely(req.query)
.perPage(self.perPage);
self.filterByIndexPage(query, req.data.page);
return query;
},
// At the URL of the page, display an index view (list view) of
// the pieces, with support for pagination.
async indexPage(req) {
const query = self.indexQuery(req);
await getFilters();
await totalPieces();
await findPieces();
await self.beforeIndex(req);
async function getFilters() {
return self.populatePiecesFilters(query);
}
async function totalPieces() {
const count = await query.toCount();
if (query.get('page') > query.get('totalPages')) {
req.notFound = true;
return;
}
req.data.totalPieces = count;
req.data.totalPages = query.get('totalPages');
}
async function findPieces() {
const docs = await query.toArray();
if (self.apos.util.isAjaxRequest(req)) {
self.setTemplate(req, 'indexAjax');
} else {
self.setTemplate(req, 'index');
}
req.data.currentPage = query.get('page');
req.data.pieces = docs;
}
},
// Called before `indexPage`. By default, does nothing.
// A convenient way to extend the functionality.
async beforeIndex(req) {
},
// Invokes query builders on the given query to ensure it only fetches
// results appropriate to the given page. By default this method does
// nothing, as it is quite common to have one pieces-page per piece type,
// but if you have more than one you can override this method and
// `chooseParentPage` to map particular pieces to particular pieces-pages.
filterByIndexPage(query, page) {
},
// Extend this method for your piece type to call additional
// query builders by default.
showQuery(req) {
return self.pieces.find(req, { slug: req.params.slug });
},
// Invoked to display a piece by itself, a "show page." Renders
// the `show.html` template after setting `data.piece`.
async showPage(req) {
// We'll try to find the piece as an ordinary reader
let doc;
let previous;
let next;
await findAsReader();
await findAsEditor();
await findPrevious();
await findNext();
if (!doc) {
req.notFound = true;
return;
}
self.setTemplate(req, 'show');
req.data.piece = doc;
req.data.previous = previous;
req.data.next = next;
await self.beforeShow(req);
async function findAsReader() {
const query = self.showQuery(req);
doc = await query.toObject();
}
async function findAsEditor() {
// TODO: Is `contextual` still relevant?
if (doc || !req.user || !self.pieces.contextual) {
return;
}
// Use findForEditing to allow subclasses to extend the set of
// filters that don't apply by default in an editing context. -Tom
const query = self.pieces.findForEditing(req, { slug: req.params.slug });
doc = await query.toObject();
}
async function findPrevious() {
if (!self.options.previous) {
return;
}
if (!doc) {
return;
}
const query = self.indexQuery(req);
previous = await query.previous(doc).applyBuilders(typeof self.options.previous === 'object' ? self.options.previous : {}).toObject();
}
async function findNext() {
if (!self.options.next) {
return;
}
if (!doc) {
return;
}
const query = self.indexQuery(req);
next = await query.next(doc).applyBuilders(typeof self.options.next === 'object' ? self.options.next : {}).toObject();
}
},
// Invoked just before the piece is displayed on its "show page." By
// default, does nothing. A useful override point.
async beforeShow(req) {
},
// TODO dispatch routes should be a module format section
//
// 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
// [@apostrophecms/page-type](../@apostrophecms/page-type/index.html) for
// more about what you can do with dispatch routes.
dispatchAll() {
self.dispatch('/', self.indexPage);
if (self.apos.url.options.static) {
// 1. SEO friendly pagination URLs
self.dispatch('/page/:pagenum', req => {
req.query.page = req.params.pagenum;
return self.indexPage(req);
});
for (const filter of self.piecesFilters) {
// 2. SEO friendly filter URLs
self.dispatch(`/${filter.name}/:filterValue`, req => {
req.query[filter.name] = req.params.filterValue;
return self.indexPage(req);
});
// 3. SEO friendly filter + pagination URLs
self.dispatch(`/${filter.name}/:filterValue/page/:pagenum`, req => {
req.query[filter.name] = req.params.filterValue;
req.query.page = req.params.pagenum;
return self.indexPage(req);
});
}
}
self.dispatch('/:slug', self.showPage);
},
// This method is called for you. You should override it if you will have
// more than one pieces-page on the site for a given type of piece.
//
// In the presence of pieces-pages, queries for the corresponding pieces
// are automatically enhanced to invoke it when building the `_url`
// property of each piece.
//
// Given an array containing all of the index pages of this type that
// exist on the site and an individual piece, your override of this method
// should return the index page that is the best fit for use in the URL of
// the piece. By default this method returns the first page, but
// warns if there is more than one page, as the developer should
// override this method to map pieces to pages in the way that makes sense
// for their design.
chooseParentPage(pages, piece) {
// Complain if this method is called with more than one page without an
// extension to make it smart enough to presumably do something
// intelligent in that situation. Don't complain though if this is just
// a call to _super
if (
(self.originalChooseParentPage === self.chooseParentPage) &&
(pages.length > 1)
) {
self.apos.util.warnDevOnce(`${self.__meta.name}/chooseParentPage`, `Your site has more than one ${self.name} page, but does not extend the chooseParentPage\nmethod in ${self.__meta.name} to choose the right one for individual ${self.pieces.name}. You should also extend filterByIndexPage.\nOtherwise URLs for each ${self.pieces.name} will point to an arbitrarily chosen page.`);
}
return pages[0];
},
// Given req, 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.
buildUrl(req, page, piece) {
if (!page) {
return false;
}
return self.apos.util.addSlashIfNeeded(page._url) + piece.slug;
},
// 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
// [@apostrophecms/piece-type](../@apostrophecms/piece-type/index.html).
async addUrlsToPieces(req, results) {
const pieceName = `${self.pieces.name}:${req.mode}`;
if (!(req.aposParentPageCache && req.aposParentPageCache[pieceName])) {
const pages = await self.findForAddUrlsToPieces(req).toArray();
if (!req.aposParentPageCache) {
req.aposParentPageCache = {};
}
req.aposParentPageCache[pieceName] = pages;
}
results.forEach(function (piece) {
const parentPage = self.chooseParentPage(
req.aposParentPageCache[pieceName] || [],
piece
);
if (parentPage) {
piece._url = self.buildUrl(req, parentPage, piece);
piece._parent = self.pruneParent(parentPage);
piece._parentUrl = parentPage._url;
piece._parentSlug = parentPage.slug;
}
});
},
// The _parent property of a piece is useful for
// breadcrumb navigation but we don't want it to lead
// to runaway recursion etc., so make a shallow clone
// of relevant properties only. Use extendMethods
// if you want to return more (or less)
pruneParent(parent) {
return {
_id: parent._id,
aposDocId: parent.aposDocId,
aposLocale: parent.aposLocale,
aposMode: parent.aposMode,
path: parent.path,
level: parent.level,
type: parent.type,
title: parent.title,
slug: parent.slug,
// These are already pruned projections and
// necessary for various types of navigation
_ancestors: parent._ancestors,
_children: parent._children
};
},
// Returns a query suitable for finding pieces-page-type 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.
//
// The default implementation returns a query with areas
// and relationships shut off for speed but all properties included.
findForAddUrlsToPieces(req) {
return self.find(req).areas(false).relationships(false);
},
// Associates this module with the relevant piece type module
// so that it knows to invoke our `addUrlsToPieces` method.
// Also marks the piece type as previewable.
enableAddUrlsToPieces() {
self.pieces.addUrlsVia(self, true);
},
// Default implementation: we are ready to add URLs to pieces when we have
// at least one reachable piece page. pieceTypeName is guaranteed to
// always be our corresponding piece module name right now, however it is
// provided in case a subclass chooses to invoke `addUrlsVia` for multiple
// piece types
async readyToAddUrlsToPieces(req, pieceTypeName) {
return !!(await self.find(req, {}).areas(false).relationships(false).toObject());
},
// Populate `req.data.filters` based on the `piecesFilters` option.
// Each entry in the option array must have a `name` property, and a
// corresponding query builder with a `launder` method must exist
// (note that most schema fields automatically get one).
//
// `req.data.filters` is an array of filter objects, each containing
// the original configuration properties (e.g. `name`, `counts`) plus
// a `choices` array. Each choice has `label`, `value`, `_url`, and
// optionally `active` and `count` properties. Choices are reduced by
// the other active filters; for instance, "tags" might only reveal
// choices not ruled out by the current "topic" filter setting.
//
// If a filter has its `counts` property set to `true`, each choice
// will also include a `count`. This has a performance impact.
//
// Legacy: `req.data.piecesFilters` is also populated as an object
// keyed by filter name, where each value is that filter's choices
// array. Still supported for backward compatibility but
// `req.data.filters` is preferred.
async populatePiecesFilters(query) {
const req = query.req;
const filtersWithChoices = await self.getFiltersWithChoices(query);
req.data.filters = filtersWithChoices;
// for bc (less useful)
req.data.piecesFilters = {};
for (const filter of filtersWithChoices) {
req.data.piecesFilters[filter.name] = filter.choices;
}
},
async getFiltersWithChoices(query, { allCounts = false } = {}) {
const results = [];
for (const filter of self.piecesFilters) {
// 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)
const _query = query.clone();
_query[filter.name](undefined);
const countsOption = allCounts ? { counts: true } : _.pick(filter, 'counts');
const choices = await _query.toChoices(filter.name, countsOption);
for (const choice of choices) {
if (query.req.data.page) {
choice._url = query.req.data.page._url +
self.apos.url.getChoiceFilter(filter.name, choice.value, 1);
}
if (query.req.query[filter.name] === choice.value) {
choice.active = true;
}
}
results.push({
...filter,
choices
});
}
return results;
}
};
self.originalChooseParentPage = methods.chooseParentPage;
return methods;
},
extendMethods(self) {
return {
getBrowserData(_super, req) {
const data = _super(req);
// TODO: Is `contextual` still relevant?
if (self.pieces.options.contextual && req.data.piece) {
return {
...data,
contextPiece: _.pick(req.data.piece, '_id', 'title', 'slug', 'type')
};
} else {
return data;
}
},
async getUrlMetadata(_super, req, doc) {
const metadata = await _super(req, doc);
if (!metadata.length) {
return metadata;
}
const [ pm ] = metadata;
const query = self.indexQuery(req);
const filters = await self.getFiltersWithChoices(query, { allCounts: true });
// 1. Enumerate every filter + choice combination
for (const filter of filters) {
for (const choice of filter.choices) {
const totalPages = Math.max(1, Math.ceil(choice.count / self.perPage));
for (let p = 1; p <= totalPages; p++) {
metadata.push({
...pm,
i18nId: `${pm.i18nId}.${self.apos.util.slugify(filter.name)}.${self.apos.util.slugify(choice.value)}.${p}`,
url: pm.url + self.apos.url
.getChoiceFilter(filter.name, choice.value, p)
});
}
}
}
await query.toCount();
const totalPages = query.get('totalPages');
// 2. Enumerate pagination (starting at page 2; page 1 is the base URL)
for (let p = 2; p <= totalPages; p++) {
metadata.push({
...pm,
i18nId: `${pm.i18nId}.${p}`,
url: pm.url + self.apos.url.getPageFilter(p)
});
}
return metadata;
}
};
}
};