apostrophe
Version:
The Apostrophe Content Management System.
364 lines (338 loc) • 13.2 kB
JavaScript
// This module provides a special doc type manager,
// `@apostrophecms/any-page-type`, a virtual type which actually refers to any
// page in the tree, regardless of type. This allows you to create
// [@apostrophecms/schema](Apostrophe schema relationships) that can link to any
// page in the page tree, rather than one specific page type.
const _ = require('lodash');
module.exports = {
extend: '@apostrophecms/doc-type',
options: {
pluralLabel: 'apostrophe:pages'
},
init(self) {
self.addManagerModal();
self.addEditorModal();
self.enableBrowserData();
},
methods(self) {
return {
// Returns a string to represent the given `doc` in an autocomplete menu.
// `doc` will contain only the fields returned by
// `getAutocompleteProjection`. `query.field` will contain the schema
// field definition for the relationship the user is attempting to match
// titles from. The default behavior is to return the `title` property,
// but since this is a page we are including the slug as well.
getAutocompleteTitle(doc, query) {
// TODO Remove in next major version.
self.apos.util.warnDevOnce(
'deprecate-get-autocomplete-title',
'self.getAutocompleteTitle() is deprecated. Use the autocomplete(\'...\') query builder instead. More info at https://v3.docs.apostrophecms.org/reference/query-builders.html#autocomplete'
);
return doc.title + ' (' + doc.slug + ')';
},
getBrowserData(req) {
const browserData = self.apos.page.getBrowserData(req);
browserData.name = self.__meta.name;
browserData.quickCreate = false;
return browserData;
},
addManagerModal() {
self.apos.modal.add(
`${self.__meta.name}:manager`,
self.getComponentName('managerModal', 'AposPagesManager'),
{ moduleName: self.__meta.name }
);
},
addEditorModal() {
self.apos.modal.add(
`${self.__meta.name}:editor`,
self.getComponentName('editorModal', 'AposDocEditor'),
{ moduleName: self.__meta.name }
);
}
};
},
extendMethods(self) {
return {
find(_super, req, criteria, options) {
return _super(req, criteria, options).type(false).isPage(true);
},
// Returns a MongoDB projection object to be used when querying
// for this type if all that is needed is a title for display
// in an autocomplete menu. Since this is a page, we are including
// the slug as well. `query.field` will contain the schema field
// definition for the relationship we're trying to autocomplete.
getAutocompleteProjection(_super, query) {
const projection = _super(query);
projection.slug = 1;
return projection;
}
};
},
queries(self, query) {
return {
builders: {
// `.isPage(true)` filters to only documents that are pages. Defaults
// to true
isPage: {
def: true,
finalize() {
const state = query.get('isPage');
if (state) {
query.and({
slug: /^\//
});
}
}
},
// `.ancestors(true)` retrieves the ancestors of each returned page and
// assigns them to the `._ancestors` property. The home page is
// `._ancestors[0]`. The page itself is not included in its own
// `._ancestors` array.
//
// If the argument is an object, do all of the above, and also call the
// query builders present in the object on the query that fetches the
// ancestors. For example, you can pass `{ children: true }` to fetch
// the children of each ancestor as the `._children` property of each
// ancestor, or pass `{ children: { depth: 2 } }` to get really fancy.
//
// `ancestors` also has its own `depth` option, but it doesn't do what
// you think. If the `depth` option is present as a top-level property
// of the object passed to `ancestors`, then only that many ancestors
// are retrieved, counting backwards from the immediate parent of each
// page.
ancestors: {
def: false,
async after(results) {
const req = query.req;
const options = query.get('ancestors');
if (!options && req.aposAncestors !== true) {
return;
}
for (const page of results) {
if (!page.path) {
// Projection is too limited, don't crash trying to get
// ancestors
continue;
}
const subquery = self.apos.page.find(req);
if (req.aposAncestors === true && req.aposAncestorsApiProjection) {
subquery.project(req.aposAncestorsApiProjection);
}
subquery.ancestorPerformanceRestrictions();
const parameters = applySubqueryOptions(subquery, options, [ 'depth' ]);
const components = page.path.split('/');
let paths = [];
let path = '';
_.each(components, function(component) {
path += component;
// Special case: the path of the homepage
// is /, not an empty string
let queryPath = path;
if (queryPath === '') {
queryPath = '/';
}
// Don't redundantly load ourselves
if (queryPath === page.path) {
return;
}
paths.push(queryPath);
path += '/';
});
if (parameters.depth !== undefined) {
paths = paths.slice(-parameters.depth);
}
if (!paths.length) {
page._ancestors = [];
continue;
}
subquery.and({
path: { $in: paths }
});
subquery.sort({ path: 1 });
page._ancestors = await subquery.toArray();
}
}
},
// If `.orphan(null)` or `undefined` is called or this method
// is never called, return docs regardless of
// orphan status. if flag is `true`, return only
// orphan docs. If flag is `false`, return only
// docs that are not orphans. Orphans are pages that
// are not returned by the default behavior of the
// `children` query builder implemented by
// `@apostrophecms/any-page-type` and thus are left out of standard
// navigation.
orphan: {
finalize() {
const orphan = query.get('orphan');
if ((orphan === undefined) || (orphan === null)) {
return;
}
if (!orphan) {
query.and({
orphan: { $ne: true }
});
return;
}
query.and({
orphan: true
});
}
},
// If `.children(true)` is called, return all children of a given page
// as a `._children` array property of the page. If the argument is an
// object, it may have a `depth` property to fetch nested children. Any
// other properties are passed on to the query builder when making the
// query for the children, which you may use to filter them.
children: {
def: false,
launder(input) {
let value = null;
if (input) {
if ((typeof input) === 'object') {
value = {};
if (input.depth) {
value.depth = self.apos.launder.integer(input.depth);
}
} else {
value = true;
}
}
return value;
},
async after(results) {
const value = query.get('children');
if ((!value) || (!results.length)) {
return;
}
const subquery = self.apos.page
.find(query.req)
.areas(false)
.relationships(false)
.orphan(false);
const parameters = applySubqueryOptions(subquery, value, [ 'depth' ]);
let depth = parameters.depth;
// Careful, let them specify a depth of 0 but still have a good
// default
if (depth === undefined) {
depth = 1;
}
if (!depth) {
return;
}
const clauses = [];
if (!results.find(page => page.path)) {
// Gracefully bow out if the projection is too limited to get
// children
return;
}
results.forEach(page => {
clauses.push({
$and: [
{
path: new RegExp('^' + self.apos.util.regExpQuote(self.apos.util.addSlashIfNeeded(page.path))),
level: {
$gt: page.level,
$lte: page.level + depth
}
}
]
});
});
subquery.and({ $or: clauses });
subquery.sort({
level: 1,
rank: 1
});
// pagesByPath is a lookup table of all the page objects we've seen
// so far indexed by their path. An important wrinkle: two page
// objects can exist for the same page if we're fetching descendants
// of ancestors with a depth of 2. For instance, if foo is the first
// child of the home page, then /foo/bar should appear as a child of
// _ancestors[0]._children[0], but also of _ancestors[1]. We address
// that by building an array of page objects with the same path and
// adding appropriate children to all of them. We don't try to get
// cute and reuse the same page object because the other filters
// specified for fetching the ancestors may be different from those
// used to fetch their children. -Tom
const pagesByPath = {};
_.each(results, function(page) {
pagesByPath[page.path] = [ page ];
page._children = [];
});
const descendants = await subquery.toArray();
for (const page of descendants) {
page._children = [];
if (!_.has(pagesByPath, page.path)) {
pagesByPath[page.path] = [];
}
pagesByPath[page.path].push(page);
const last = page.path.lastIndexOf('/');
const parentPath = page.path.substr(0, last);
if (pagesByPath[parentPath]) {
_.each(pagesByPath[parentPath], function(parent) {
parent._children.push(page);
});
} else {
// Parent page is an orphan, so it doesn't make sense
// to return this page either, even though it's not an orphan
}
}
}
},
// Use .reorganize(true) to return only pages that
// are suitable for display in the reorganize view.
// The only pages excluded are those with a `reorganize`
// property explicitly set to `false`.
// NOTE: This query builder is deprecated and will be removed in the
// next major version.
reorganize: {
def: null,
finalize() {
const state = query.get('reorganize');
if (state === null) {
// Do nothing
} else if (state === true) {
query.and({
reorganize: { $ne: false }
});
} else if (state === false) {
query.and({
reorganize: false
});
}
}
}
},
// Apply default restrictions suitable for fetching ancestor pages to the
// query as a starting point before applying the ancestor options. Called
// by the ancestors filter here and also by pages.pageBeforeSend when it
// fetches just the home page using the same options, in the event
// ancestors were not loaded, such as on the home page itself. You should
// not need to modify or invoke this.
methods: {
ancestorPerformanceRestrictions() {
query.areas(false).relationships(false);
return query;
}
}
};
}
};
function applySubqueryOptions(subquery, options, ours) {
let parameters = {};
if (options === true) {
// OK, go with defaults
} else if (typeof (options) === 'object') {
parameters = _.pick(options, ours);
// Pass everything that's not a parameter to
// query used to get ancestors
_.each(_.omit(options, ours), function(val, key) {
subquery[key](val);
});
} else {
// something else truthy, go with defaults
}
return parameters;
}