apostrophe-pages
Version:
Adds trees of pages to the Apostrophe content management system
670 lines (602 loc) • 25 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 = {};
}
if (!options.root) {
options.root = apos.data.aposPages.root;
}
// Allow / or /pages/ to be specified, just quietly fix it
options.root = options.root.replace(/\/$/, '');
// Available in other scopes
aposPages.options = options;
// Shared state closure for the page settings dialogs (new and edit)
(function() {
var oldTypeName;
var otherTypeSettings = {};
// Active dialog
var $el;
$('body').on('click', '.apos-new-page', function() {
var parent = $(this).data('slug');
var pageType = $(this).data('pageType');
$el = apos.modalFromTemplate('.apos-new-page-settings', {
init: function(callback) {
// We now can pass an argument to this function that allows a certain page type
// (identified in the data attribute "page-type") to be automatically selected.
populateType(pageType);
// Copy parent's published status
//
// TODO: refactor this frequently used dance of boolean values
// into editor.js or content.js
var published = apos.data.aposPages.page.published;
if (published === undefined) {
published = 1;
} else {
// Simple POST friendly boolean values
published = published ? '1' : '0';
}
apos.enableTags($el.find('[data-name="tags"]'), []);
refreshType();
// Let's go ahead and try to populate the page type setting
if (pageType) {
type = pageType;
}
// $el.findByName('published').val(apos.data.pages.parent.published)
// Copy parent permissions
enablePermissions(apos.data.aposPages.page);
return callback(null);
},
save: save
});
function save(callback) {
return addOrEdit('new', { parent: parent }, callback);
}
return false;
});
$('body').on('click', '.apos-edit-page', function() {
var slug = $(this).data('slug');
// Get a more robust JSON representation that includes
// joined objects if any
$.getJSON(aposPages.options.root + apos.data.aposPages.page.slug + '?pageInformation=json', function(data) {
apos.data.aposPages.page = data;
$el = apos.modalFromTemplate('.apos-edit-page-settings', {
save: save,
init: function(callback) {
populateType();
// TODO: refactor this frequently used dance of boolean values
// into editor.js or content.js
var published = apos.data.aposPages.page.published;
if (published === undefined) {
published = 1;
} else {
// Simple POST friendly boolean values
published = published ? '1' : '0';
}
$el.find('[name=published]').val(published);
$el.find('[name=type]').val(apos.data.aposPages.page.type);
$el.find('[name=title]').val(apos.data.aposPages.page.title);
var $seoDescription = $el.find('[name=seoDescription]');
$seoDescription.val(apos.data.aposPages.page.seoDescription || '');
$el.find('[name=slug]').val(slug);
apos.enableTags($el.find('[data-name="tags"]'), apos.data.aposPages.page.tags);
// Persistence for settings made when the page had a different type.
// Makes Apostrophe forgiving of otherwise serious mistakes, like
// adding 20 hand-curated choices in custom page settings for a type,
// switching to another type, saving, and then changing your mind
if (apos.data.aposPages.page.otherTypeSettings) {
otherTypeSettings = apos.data.aposPages.page.otherTypeSettings;
}
refreshType();
enablePermissions(apos.data.aposPages.page);
// 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) {
if (!_.find(apos.data.aposPages.types, function(type) {
return apos.data.aposPages.page.type === type.name;
})) {
// Don't let anyone mess with the type of an existing page whose type is not
// on the menu, such as the search page
$el.find('[data-name="type"]').remove();
return;
}
var $type = $el.find('[name=type]');
$type.html('');
_.each(apos.data.aposPages.menu || apos.data.aposPages.types, function(type) {
var $option = $('<option></option>');
$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);
}
$type.append($option);
});
// 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 refreshType() {
var $type = $el.find('[name=type]');
if (!$type.length) {
// Type changes not allowed for this page
return;
}
var typeName = $el.find('[name=type]').val();
if (oldTypeName) {
var oldType = aposPages.getType(oldTypeName);
if (oldType.settings) {
var $details = $el.find('[data-type-details]');
// Don't bomb if the dialog was dismissed and the old type's settings aren't
// present right now
if ($details.length) {
if (oldType.settings.serialize.length === 2) {
otherTypeSettings[oldTypeName] = oldType.settings.serialize($el, $details);
} else {
oldType.settings.serialize($el, $details, function(err, info) {
otherTypeSettings[oldTypeName] = info;
});
}
}
}
$el.find('[data-type-details]').html('');
}
oldTypeName = typeName;
var type = aposPages.getType(typeName);
if (type.settings) {
var $typeEl = apos.fromTemplate('.apos-page-settings-' + type._typeCss);
$el.find('[data-type-details]').html($typeEl);
var typeDefaults = otherTypeSettings[typeName];
if (!typeDefaults) {
if (apos.data.aposPages.page.type === type.name) {
typeDefaults = apos.data.aposPages.page.typeSettings;
}
}
if (!typeDefaults) {
typeDefaults = {};
}
type.settings.unserialize(typeDefaults, $el, $el.find('[data-type-details]'), function(err) {
// NOTE: not all unserialize implementations invoke a callback yet.
// .length should be checked.
//
// TODO: refreshType should take a callback of its own but
// for now there is nothing to invoke and nothing that absolutely
// depends on running after it
});
}
}
function addOrEdit(action, options, callback) {
var typeName = $el.find('[name=type]').val();
var type = aposPages.getType(typeName);
var data = {
title: $el.find('[name=title]').val(),
slug: $el.find('[name=slug]').val(),
seoDescription: $el.find('[name=seoDescription]').val(),
type: $el.find('[name=type]').val(),
published: $el.find('[name=published]').val(),
tags: $el.find('[data-name="tags"]').selective('get'),
otherTypeSettings: otherTypeSettings
};
// Permissions are fancy! But the server does most of the hard work
data.loginRequired = $el.findByName('loginRequired').val();
data.loginRequiredPropagate = $el.findByName('loginRequiredPropagate').is(':checked') ? '1' : '0';
// "certain people" (specific users/groups)
data.viewGroupIds = $el.find('[data-name="viewGroupIds"]').selective('get');
data.viewPersonIds = $el.find('[data-name="viewPersonIds"]').selective('get');
data.editGroupIds = $el.find('[data-name="editGroupIds"]').selective('get');
data.editPersonIds = $el.find('[data-name="editPersonIds"]').selective('get');
_.extend(data, { parent: options.parent, originalSlug: options.slug });
function serializeThenSave() {
if (!(type && type.settings && type.settings.serialize)) {
return save();
}
if (type.settings.serialize.length === 2) {
data.typeSettings = type.settings.serialize($el, $el.find('[data-type-details]'));
return save();
}
// Newfangled serializer takes a callback
return type.settings.serialize($el, $el.find('[data-type-details]'), function(err, typeSettings) {
if (err) {
// Block
return;
}
data.typeSettings = typeSettings;
return save();
});
}
function save() {
$.ajax(
{
url: '/apos-pages/' + action,
data: data,
type: 'POST',
dataType: 'json',
success: function(data) {
window.location.href = aposPages.options.root + data.slug;
},
error: function() {
alert('Server error');
callback('Server error');
}
}
);
}
serializeThenSave();
return false;
}
function enablePermissions(page) {
// Admin users can't manipulate edit permissions. (Hackers could try, but the server
// ignores anything submitted.)
if (!apos.data.permissions.admin) {
$el.find('[data-edit-permissions-container]').hide();
}
$el.find('[data-show-view-permissions]').click(function() {
$(this).closest('.apos-page-settings-toggle').toggleClass('apos-active');
$el.find('.apos-view-permissions').toggle();
return false;
});
var $loginRequired = $el.findByName('loginRequired');
$loginRequired.val(page.loginRequired);
$loginRequired.change(function() {
var $certainPeople = $el.find('.apos-view-certain-people');
if ($(this).val() == 'certainPeople') {
$certainPeople.show();
} else {
$certainPeople.hide();
}
}).trigger('change');
$el.find('[data-show-edit-permissions]').click(function() {
$(this).closest('.apos-page-settings-toggle').toggleClass('apos-active');
$el.find('.apos-edit-permissions').toggle();
return false;
});
// TODO this is a hardcoded dependency on the people and
// groups modules, think about whether that is acceptable
$el.find('[data-name="viewGroupIds"]').selective({
// Unpublished people and groups can still have permissions
source: '/apos-groups/autocomplete?published=any',
data: page.viewGroupIds || [],
propagate: true,
preventDuplicates: true
});
$el.find('[data-name="viewPersonIds"]').selective({
source: '/apos-people/autocomplete?published=any',
data: page.viewPersonIds || [],
propagate: true,
preventDuplicates: true
});
$el.find('[data-name="editGroupIds"]').selective({
source: '/apos-groups/autocomplete?published=any',
data: page.editGroupIds || [],
propagate: true,
preventDuplicates: true
});
$el.find('[data-name="editPersonIds"]').selective({
source: '/apos-people/autocomplete?published=any',
data: page.editPersonIds || [],
propagate: true,
preventDuplicates: true
});
}
})();
$('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: 1,
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');
}
// 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"></a>');
link.attr('data-node-id', node.id);
link.attr('data-visit', '1');
link.attr('href', '#');
link.text('»');
$li.find('.jqtree-element').append(link);
link = $('<a class="apos-delete"></a>');
link.attr('data-node-id', node.id);
link.attr('data-delete', '1');
link.attr('href', '#');
link.text('x');
$li.find('.jqtree-element').append(link);
}
});
$tree.on('click', '[data-visit]', function() {
var nodeId = $(this).attr('data-node-id');
var node = $tree.tree('getNodeById', nodeId);
// TODO: this is an assumption about where the root of the page tree
// is being served
window.location.href = node.slug;
return false;
});
$tree.on('click', '[data-delete]', function() {
var nodeId = $(this).attr('data-node-id');
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');
}
});
return false;
});
$tree.on('tree.move', function(e) {
e.preventDefault();
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();
},
error: function() {
// This didn't work, probably because something
// else has changed in the page tree. Refreshing
// is an appropriate response
apos.afterYield(function() { reload(null); });
}
});
});
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 = (apos.data.aposPages.root + data.slug).replace(/^\/\//, '/');
if (window.location.pathname === newPathname) {
apos.change('tree');
return callback();
} else {
// Navigates away, so don't call the callback
window.location.pathname = newPathname;
}
}).error(function() {
// If the page no longer exists, navigate away to home page
window.location.pathname = apos.data.aposPages.root;
});
}
});
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() {
var slug = $(this).data('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.');
window.location.href = aposPages.options.root + 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(pageId);
});
};
// This method can also be invoked by snippets and anything else that
// is represented by a page.
self.browseVersions = function(pageId) {
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');
$.post('/apos-pages/revert',
{ page_id: pageId, version_id: id },
function(data) {
alert('Switched versions.');
$el.trigger('aposModalHide');
apos.change('revert');
}
).error(function() {
alert('Server error or version no longer available.');
});
});
// Load the available versions
$template = $versions.find('[data-version].apos-template');
$template.detach();
// Easier to render as a nice server side template
$.get('/apos-pages/versions', {
_id: pageId
}, function(data) {
$versions.html(data);
});
return callback();
}
});
};
}
// There is only one instance of AposPages. TODO: provide
// for substituting a subclass
window.aposPages = new AposPages();