apostrophe-pages
Version:
Adds trees of pages to the Apostrophe content management system
731 lines (655 loc) • 26 kB
JavaScript
function AposPages() {
var self = this;
// Return a page type object if one was configured for the given type name.
// JavaScript doesn't iterate over object properties in a defined order,
// so we maintain the list of types as a flat array. This convenience method
// prevents this from being inconvenient and allows us to choose to do more
// optimization later.
self.getType = function(name) {
return _.find(apos.data.aposPages.types, function(item) {
return item.name === name;
});
};
self.addType = function(type) {
apos.data.aposPages.types.push(type);
};
// Replace a type with a new type object. Typically this is done to replace
// a type object we got from the server (with just a name and a label) with a
// type object that includes a settings property with functions for a page
// settings dialog and so on.
//
// We replace rather than extending so that closures with references
// to "self" still do the right thing.
self.replaceType = function(name, object) {
var newTypes = [];
for (var i in apos.data.aposPages.types) {
var type = apos.data.aposPages.types[i];
if (type.name === name) {
object.name = type.name;
object.label = type.label;
newTypes.push(object);
} else {
newTypes.push(type);
}
}
apos.data.aposPages.types = newTypes;
};
// Get the index type objects corresponding to an instance or the name of an
// instance type. Instance types are a relevant concept for snippet pages,
// blog pages, event calendar pages, etc. and everything derived from them.
//
// In this pattern "instance" pages, like individual blogPosts, are outside
// of the page tree but "index" pages, like blogs, are in the page tree and
// display some or all of the blogPosts according to their own criteria.
self.getIndexTypes = function(instanceTypeOrInstance) {
if (!instanceTypeOrInstance) {
throw 'getIndexTypes called with no type. You probably forgot to specify withType in your schema.';
}
var types = apos.data.aposPages.types;
var instanceTypeName = instanceTypeOrInstance.type || instanceTypeOrInstance;
var instanceTypes = [];
var i;
return _.filter(types, function(type) {
return (type._instance === instanceTypeName);
});
};
// Get the names of the index types corresponding to an instance type or the name of an
// instance type
self.getIndexTypeNames = function(instanceTypeOrInstance) {
return _.pluck(self.getIndexTypes(instanceTypeOrInstance), 'name');
};
// Returns the first index type object corresponding to an instance type or the
// name of an instance type. This is the object that is providing backend routes
// and management UI for editing instances
self.getManager = function(instanceTypeOrInstance) {
return self.getIndexTypes(instanceTypeOrInstance)[0];
};
// Right now this is called even for noneditors, but we don't put the
// menu dropdown markup in the admin bar for them. TODO: we need to better
// separate globally useful stuff like apos.data.aposPages.types from
// clearly editor-specific stuff like editing page settings
self.enableUI = function(options) {
if (!options) {
options = {};
}
// Available in other scopes
aposPages.options = options;
// Shared state closure for the page settings dialogs (new and edit)
(function() {
var oldTypeName;
// Active dialog
var $el;
var defaults;
$('body').on('click', '[data-new-page]', function() {
var parent = apos.data.aposPages.page.slug;
var pageType = $(this).data('pageType');
self.newPage(parent, { pageType: pageType });
return false;
});
self.newPage = function(parent, options) {
defaults = {};
options = options || {};
$el = apos.modalFromTemplate('.apos-new-page-settings', {
init: function(callback) {
populateType(options.pageType, true);
// Copy parent's published status
//
// TODO: refactor this frequently used dance of boolean values
// into editor.js or content.js
apos.enableBoolean($el.findByName('published'), apos.data.aposPages.page.published, true);
// We do not copy the parent's orphan status
apos.enableBoolean($el.findByName('notOrphan'), true);
apos.enableTags($el.find('[data-name="tags"]'), []);
refreshType(function() {
// Let's go ahead and try to populate the page type setting
if (options.pageType) {
type = options.pageType;
}
// Copy parent permissions
enablePermissions(apos.data.aposPages.page, true);
if (options.title) {
$el.find('[data-title]').text(options.title);
}
return callback(null);
});
},
save: save
});
function save(callback) {
return addOrEdit('new', { parent: parent }, callback);
}
return $el;
};
$('body').on('click', '[data-edit-page]', function() {
var slug = apos.data.aposPages.page.slug;
// Get a more robust JSON representation that includes
// joined objects if any
$.getJSON(apos.data.aposPages.page.slug + '?pageInformation=json', function(data) {
apos.data.aposPages.page = data;
defaults = data;
$el = apos.modalFromTemplate('.apos-edit-page-settings', {
save: save,
init: function(callback) {
populateType(defaults.type, false);
// TODO: refactor this frequently used dance of boolean values
// into editor.js or content.js
var published = defaults.published;
if (published === undefined) {
published = 1;
} else {
// Simple POST friendly boolean values
published = published ? '1' : '0';
}
apos.enableBoolean($el.findByName('published'), defaults.published, true);
apos.enableBoolean($el.findByName('notOrphan'), !defaults.orphan, true);
$el.find('[name=type]').val(defaults.type);
$el.find('[name=title]').val(defaults.title);
var $seoDescription = $el.find('[name=seoDescription]');
$seoDescription.val(defaults.seoDescription || '');
$el.find('[name=slug]').val(slug);
apos.enableTags($el.find('[data-name="tags"]'), defaults.tags);
refreshType(function() {
enablePermissions(defaults, false);
// Watch the title for changes, update the slug - but only if
// the slug was in sync with the title to start with
var $slug = $el.find('[name=slug]');
var $title = $el.find('[name=title]');
apos.suggestSlugOnTitleEdits($slug, $title);
return callback(null);
});
}
});
});
function save(callback) {
var newSlug = $el.find('[name=slug]').val();
if (newSlug === slug) {
// Slug not edited, we're fine
return go();
}
// Slug edited, make sure it's available; random digits frustrate people
return $.jsonCall('/apos-pages/slug-available', { slug: newSlug }, function(response) {
if (response.status !== 'ok') {
alert('That slug is already in use by another page.');
return callback('error');
}
return go();
});
function go() {
return addOrEdit('edit', { slug: slug }, callback);
}
}
return false;
});
function populateType(presetType, insert) {
var $type = $el.find('[name=type]');
$type.html('');
var found = false;
var $options;
var type;
var choices = apos.data.aposPages.menu || apos.data.aposPages.types;
// Filter choices via childTypes and descendantTypes
// options of our ancestors. These filters are
// cumulative
var parent = insert ? apos.data.aposPages.page : apos.data.aposPages.page.parent;
var ancestors = apos.data.aposPages.page.ancestors;
if (!insert) {
// Make sure we only look at our parent once
ancestors = _.clone(ancestors);
ancestors.pop();
}
var filter;
if (parent) {
type = self.getType(parent.type);
if (type) {
filter = type.childTypes || type.descendantTypes;
if (filter) {
choices = _.filter(choices, function(choice) {
return _.contains(filter, choice.name);
});
}
}
}
if (ancestors.length) {
_.each(ancestors, function(ancestor) {
type = self.getType(ancestor.type);
if (type) {
var filter = type.descendantTypes;
if (filter) {
choices = _.filter(choices, function(choice) {
return _.contains(filter, choice.name);
});
}
}
});
}
_.each(choices, function(type) {
$option = $('<option></option>');
// The label is wrapped in i18n
$option.text( __(type.label) );
$option.attr('value', type.name);
// If we've passed in the presetType, let's select that one.
if (type.name === presetType) {
$option.attr('selected', true);
found = true;
}
$type.append($option);
});
if (presetType && (!found)) {
// if the preset type is not one of the choices, populate the
// menu with that one choice and hide it. It's going to be
// something like blogPost that shouldn't be switched
type = _.find(apos.data.aposPages.types, function(type) {
return (type.name === presetType);
});
if (!type) {
// Even a type not configured in app.js might still be
// around as a page template; some people remove
// "home" from the types option for instance so nobody
// adds a second "home"
type = {
label: presetType,
name: presetType
};
apos.data.aposPages.types.push(type);
}
if (type) {
$type.html('');
$option = $('<option></option>');
// The label is wrapped in i18n
$option.text( __(type.label) );
$option.attr('value', type.name);
$type.append($option);
$el.find('[data-name="type"]').hide();
}
}
// Some types have custom settings of their own. When appropriate
// instantiate the additional template and make it part of the form.
// If the type has a selector for a settings template (settings.sel)
// then it must also have an unserialize function to populate that
// template's form fields from an object and a serialize function
// to return an object based on the form fields. You can set up
// client side javascript to assist with the use of the fields in
// your unserialize function.
$el.on('change', '[name=type]', function() {
refreshType(function() {});
});
}
function refreshType(callback) {
var $type = $el.find('[name=type]');
if (!$type.length) {
// Type changes not allowed for this page
return callback();
}
var typeName = $el.find('[name=type]').val();
if (oldTypeName) {
$el.find('[data-type-details]').html('');
}
var type = aposPages.getType(typeName);
if (type && type.orphan) {
// Locked for this type
$el.find('[data-name="notOrphan"]').hide();
} else {
$el.find('[data-name="notOrphan"]').show();
}
if (type.settings) {
var $typeEl = apos.fromTemplate('.apos-page-settings-' + type._typeCss);
$el.find('[data-type-details]').html($typeEl);
var unserialize = type.settings.unserialize;
// Tolerate unserialize methods without a callback.
// TODO I'd like to kill that off, but let's break one thing
// a day tops if we can.
if (unserialize.length === 3) {
var superUnserialize = unserialize;
unserialize = function(data, $el, $details, callback) {
superUnserialize(data, $el, $details);
return callback();
};
}
unserialize(defaults, $el, $typeEl, function(err) {
apos.emit('enhance', $typeEl);
return callback();
});
} else {
$el.find('[data-type-details]').html('');
return callback();
}
}
function addOrEdit(action, options, callback) {
var typeName = $el.find('[name="type"]').val();
var type = aposPages.getType(typeName);
var data = {
title: $el.findByName('title').val(),
slug: $el.findByName('slug').val(),
seoDescription: $el.findByName('seoDescription').val(),
type: $el.findByName('type').val(),
published: apos.getBoolean($el.findByName('published')),
orphan: !apos.getBoolean($el.findByName('notOrphan')),
tags: $el.find('[data-name="tags"]').selective('get', { incomplete: true })
};
apos.permissions.debrief($el.find('[data-permissions]'), data, { propagate: (action === 'edit') });
_.extend(data, { parent: options.parent, originalSlug: options.slug });
function serializeThenSave() {
if (!(type && type.settings && type.settings.serialize)) {
return save();
}
// Tolerate serialize methods without a callback.
// TODO I'd like to kill that off, but let's break one thing
// a day tops if we can.
var serialize = type.settings.serialize;
if (serialize.length === 2) {
var superSerialize = serialize;
serialize = function($el, $details, callback) {
var ok = superSerialize($el, $details);
return callback(null, ok);
};
}
// This is a hack to allow serializers to detect
// the difference between "New Page" and "Edit Page."
// TODO: implement this in a more civilized fashion.
$el.data('new', $el.hasClass('apos-new-page-settings'));
return serialize($el, $el.find('[data-type-details]'), function(err, result) {
if (err) {
// Block
aposSchemas.scrollToError($el);
return callback('invalid');
}
// Use _.extend to copy top level properties directly and avoid
// a recursive merge and appending of arrays which would prevent us
// from, for instance, clearing a list of tags
_.extend(data, result);
return save();
});
}
// Use jsonCall so that sparse arrays
// (indexed by snippet ID, for instance)
// don't turn into flat arrays, also more
// efficient generally. -Tom
function save() {
$.jsonCall('/apos-pages/' + action,
data,
function(data) {
apos.redirect(data.slug);
},
function() {
alert('Server error');
callback('Server error');
}
);
}
serializeThenSave();
return false;
}
function enablePermissions(page, isNew) {
apos.permissions.brief($el.find('[data-permissions]'), page, { propagate: !isNew });
}
})();
$('body').on('click', '[data-reorganize-page]', function() {
var $tree;
var $el = apos.modalFromTemplate('.apos-reorganize-page', {
init: function(callback) {
$tree = $el.find('[data-tree]');
$tree.tree({
data: [],
autoOpen: 0,
openFolderDelay: 2500,
dragAndDrop: true,
onCanMoveTo: function(moved_node, target_node, position) {
// Cannot create peers of root
if ((target_node.slug === '/') && (position !== 'inside')) {
return false;
}
return true;
},
onCreateLi: function(node, $li) {
// Identify the root trashcan and add a class to its li so that we
// can hide inappropriate controls within the trash
// TODO: do we want to make this slug a constant forever?
if (node.slug == '/trash') {
$li.addClass('apos-trash');
}
$li.find('.jqtree-element').append($('<span class="apos-reorganize-controls"></span>'));
// Append a link to the jqtree-element div.
// The link has a url '#node-[id]' and a data property 'node-id'.
var link = $('<a class="apos-visit" target="_blank"></a>');
link.attr('data-node-id', node.id);
link.attr('data-visit', '1');
link.attr('href', '#');
// link.text('»');
link.append('<i class="icon icon-external-link"></i>');
$li.find('.jqtree-element .apos-reorganize-controls').append(link);
if (node.publish) {
link = $('<a class="apos-delete"></a>');
link.attr('data-node-id', node.id);
link.attr('data-delete', '1');
link.attr('href', '#');
// link.text('x');
link.append('<i class="icon icon-trash"></i>');
}
$li.find('.jqtree-element .apos-reorganize-controls').append(link);
}
});
$tree.on('click', '[data-visit]', function() {
var nodeId = $(this).attr('data-node-id');
var node = $tree.tree('getNodeById', nodeId);
var tab = window.open(apos.data.prefix + node.slug, '_blank');
tab.focus();
// window.location.href = ;
return false;
});
$tree.on('click', '[data-delete]', function() {
self.reorganizeMovePageToTrash($tree, $(this).attr('data-node-id'));
return false;
});
$tree.on('tree.move', function(e) {
e.preventDefault();
$el.find('.apos-reorganize-progress').fadeIn();
var data = {
moved: e.move_info.moved_node.slug,
target: e.move_info.target_node.slug,
position: e.move_info.position
};
$.ajax({
url: '/apos-pages/move-jqtree',
data: data,
type: 'POST',
dataType: 'json',
success: function(data) {
// Reflect changed slugs
_.each(data.changed, function(info) {
var node = $tree.tree('getNodeById', info.id);
if (node) {
node.slug = info.slug;
}
});
e.move_info.do_move();
$el.find('.apos-reorganize-progress').fadeOut();
},
error: function() {
alert('You may only move pages you are allowed to publish. If you move a page to a new parent, you must be allowed to edit the new parent.');
apos.afterYield(function() {
reload(function() {
$el.find('.apos-reorganize-progress').fadeOut();
});
});
}
});
});
reload(callback);
},
// After a reorg the page URL may have changed, be prepared to
// navigate there or to the home page or just refresh to reflect
// possible new tabs
afterHide: function(callback) {
var page = apos.data.aposPages.page;
var _id = page._id;
$.get('/apos-pages/info', { _id: _id }, function(data) {
var newPathname = data.slug.replace(/^\/\//, '/');
apos.redirect(newPathname);
}).error(function() {
// If the page no longer exists, navigate away to home page
apos.redirect('/');
});
}
});
function reload(callback) {
$.getJSON('/apos-pages/get-jqtree', function(data) {
$tree.tree('loadData', data);
if (callback) {
return callback();
}
}).error(function() {
alert('The server did not respond or you do not have the appropriate privileges.');
$el.trigger('aposModalHide');
});
}
});
$('body').on('click', '.apos-delete-page', function() {
if (!confirm('Are you sure you want to move this page to the trash?')) {
return false;
}
var slug = apos.data.aposPages.page.slug;
$.ajax(
{
url: '/apos-pages/delete',
data: {
slug: slug
},
type: 'POST',
dataType: 'json',
success: function(data) {
if(data.status === 'ok') {
alert('Moved to the trash. Select "Reorganize" from the "Page" menu to drag it back out.');
apos.redirect(data.parent);
} else {
alert(data.status);
}
},
error: function() {
alert('Server error');
}
}
);
return false;
});
$('body').on('click', '[data-versions-page]', function() {
var pageId = apos.data.aposPages.page._id;
aposPages.browseVersions({ _id: pageId });
});
};
// Load and display the page versions browser for the
// page specified by options._id or options.slug. If you
// pass a string instead of an object it is assumed
// to be an _id. As a convenience, slugs may include
// area names; however the version editor displayed currently
// affects the entire page.
self.browseVersions = function(options) {
var pageId;
if (typeof(options) === 'string') {
options = { _id: options };
}
if (options.slug) {
// As a convenience accept slugs that include an
// area name and just lop off the area name
options.slug = options.slug.replace(/:\S+$/, '');
}
var $el = apos.modalFromTemplate('.apos-versions-page', {
init: function(callback) {
$versions = $el.find('[data-versions]');
$versions.on('click', '[data-version-id]', function() {
var id = $(this).data('versionId');
$.jsonCall('/apos-pages/revert',
{ pageId: pageId, versionId: id },
function(data) {
if (data.status === 'ok') {
alert('Switched versions.');
$el.trigger('aposModalHide');
apos.change('revert');
} else {
alert('Server error or version no longer available.');
}
}
);
});
$versions.on('click', '[data-review-changes]', function(e) {
e.preventDefault();
var $self = $(this);
var $changes = $self.closest('.apos-changes').find('.apos-change');
$changes.toggleClass('active');
})
//A function for loading thumbnails into our UI.
function loadThumbnails($el){
var $avatars = $el.find('[data-avatar-username]');
$avatars.each(function(){
var $avatar = $(this);
$.get(
'/apos-people/thumbnail',
{username: $avatar.data('avatar-username')},
function(data){
$avatar.attr('src', data);
}
)
})
}
// Load the available versions
$template = $versions.find('[data-version].apos-template');
$template.detach();
// Easier to render as a nice server side template
$.jsonCall(
'/apos-pages/versions',
options,
function(data) {
pageId = data._id;
$versions.html(data.html);
loadThumbnails($versions);
}
);
return callback();
}
});
};
self.reorganizeMovePageToTrash = function($tree, nodeId) {
if (!confirm('Are you sure you want to move this page to the trash?')) {
return false;
}
console.log($tree);
var node = $tree.tree('getNodeById', nodeId);
// Find the trashcan so we can mirror what happened on the server
var trash;
_.each($tree.tree('getTree').children[0].children, function(node) {
if (node.trash) {
trash = node;
}
});
if (!trash) {
alert('No trashcan.');
return false;
}
$.ajax({
url: '/apos-pages/delete',
data: {
slug: node.slug
},
type: 'POST',
dataType: 'json',
success: function(data) {
if (data.status === 'ok') {
$tree.tree('moveNode', node, trash, 'inside');
_.each(data.changed, function(info) {
var node = $tree.tree('getNodeById', info.id);
if (node) {
node.slug = info.slug;
}
});
} else {
alert(data.status);
}
},
error: function() {
alert('Server error');
}
});
}
}
// There is only one instance of AposPages. TODO: provide
// for substituting a subclass
window.aposPages = new AposPages();