UNPKG

apostrophe

Version:
364 lines (338 loc) • 13.2 kB
// 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; }