apostrophe
Version:
The Apostrophe Content Management System.
1,364 lines (1,258 loc) • 59.3 kB
JavaScript
// The browser-side singleton corresponding to the [apostrophe-schemas](/reference/modules/apostrophe-schemas/) module.
apos.define('apostrophe-schemas', {
extend: 'apostrophe-context',
construct: function(self, options) {
self.fieldTypes = {};
// Populate form elements corresponding to a set of fields as
// specified in a schema (the schema argument). The inverse of
// self.convert.
//
// Must be called only once for a given set of elements. However,
// you can call `convert` many times. Typical practice is to
// create a modal, populate it with existing data, and then
// attempt to convert it on save. If a conversion fails it is
// OK to try again after the user corrects errors such as
// absent required fields.
self.populate = function($el, schema, object, callback) {
self.enableGroupTabs($el);
return async.series([
_.partial(self.beforePopulate, $el, schema, object),
populate,
_.partial(self.afterPopulate, $el, schema, object)
], callback);
function populate(callback) {
return async.eachSeries(schema, function(field, callback) {
// Utilized by simple displayers that use a simple HTML
// element with a name attribute
var $field = self.findField($el, field.name);
if (field.contextual) {
return setImmediate(callback);
}
var fieldType = self.fieldTypes[field.type];
return fieldType.populate(object, field.name, $field, $el, field, function() {
if (field.autocomplete === false) {
$field.attr('autocomplete', 'off');
}
// By popular demand fields can be distinguished as required
// before any input actually takes place, without calling
// showError, which implies the user already did something bad
// apos.utils.log(field);
if (field.required === true) {
self.findFieldset($el, field.name).addClass('apos-required');
}
return setImmediate(callback);
});
}, function() {
self.enableSlugSuggestions($el, schema, object);
return callback();
});
}
};
// Invoked at the start of `populate`. Does nothing by default.
// Provides a convenient way to extend the behavior of all
// schemas. When implementing custom field types you should
// not need to override this method, however it is sometimes useful for
// concerns that cut across multiple methods.
self.beforePopulate = function($el, schema, object, callback) {
return callback(null);
};
// Invoked at the end of `populate`. Does nothing by default.
// Provides a convenient way to extend the behavior of all
// schemas. When implementing custom field types you should
// not need to override this method, however it is sometimes useful for
// concerns that cut across multiple methods.
self.afterPopulate = function($el, schema, object, callback) {
return callback(null);
};
// If the schema contains both `title` and `slug` fields, begin
// monitoring `title` and updating `slug` as the user types,
// provided it reflects the previous content of `title`.
self.enableSlugSuggestions = function($el, schema, object) {
var title = _.find(schema, { name: 'title' });
var slug = _.find(schema, { name: 'slug', type: 'slug' });
if (!(title && slug)) {
return;
}
var $title = self.findField($el, 'title');
var $slug = self.findField($el, 'slug');
if (slug.prefix) {
$slug.data('prefix', slug.prefix);
}
self.enableSlug($title, $slug, title, slug);
self.watchSlugConflicts($title, $slug, title, slug, object);
};
// Gather data from form elements and push it into properties of the data
// object, as specified by the schema provided. The inverse of
// self.populate. Errors, if any, are passed as an array to
// the callback; if the first parameter is null all is well. In the
// event of errors this method will still convert as many fields
// as it can.
//
// The options argument can be completely omitted.
//
// By default, any errors are highlighted via showError().
// If you explicitly set options.showErrors to false, errors are
// not highlighted (but are still reported). This is useful
// for autosave and similar operations.
self.convert = function($el, schema, data, options, callback) {
if (arguments.length === 4) {
callback = options;
options = {};
}
var showErrors = true;
if (options.showErrors !== undefined) {
showErrors = options.showErrors;
}
// remove any class names beginning with apos-error, which
// cleans up apos-error, apos-error-required, etc. from
// a previous convert run. -Tom
self.findSafe($el, '[data-name]').each(function() {
var $fieldset = $(this);
var classes = $fieldset[0].className.split(/\s+/);
_.each(classes, function(c) {
if (c.match(/^apos-error/)) {
$fieldset.removeClass(c);
}
});
self.findSafe($fieldset, '[data-apos-fix-error]').remove();
});
var errors = [];
return async.eachSeries(schema, function(field, callback) {
if (field.contextual || field.readOnly) {
return setImmediate(callback);
}
// This won't be enough for every type of field, so we pass $el too
var $field = self.findField($el, field.name);
if ((!$field.length) && field.legacy) {
$field = self.findField($el, field.legacy);
}
var $fieldset = self.findFieldset($el, field.name);
return self.fieldTypes[field.type].convert(data, field.name, $field, $el, field, function(err) {
if (err) {
if ($fieldset.hasClass('apos-hidden')) {
// If this field is hidden by toggleHiddenFields, the user is not expected to
// populate it, even if it would otherwise be required. This allows showFields to
// function as intended, and as seen in A2 0.5. However if the converter
// succeeds we do allow that to complete so that data is not lost if,
// for instance, an option with a great deal of completed configuration
// is temporarily toggled off via showFields. Just let it go.
return setImmediate(callback);
}
// error can be an object, with field and type properties
// (and room for expansion), or it can be a simple string,
// in which case we invoke self.error to make an error object
if (typeof (err) === 'string') {
err = self.error(field, err);
}
if (showErrors) {
self.showError($el, err);
self.scrollToError($el);
}
errors.push(err);
}
return setImmediate(callback);
});
}, function() {
return callback(errors.length ? errors : null);
});
};
self.returnToError = function($el, schema, errorPath, error, callback) {
var first = errorPath.shift();
var $field = self.findField($el, first);
var field = _.find(schema, { name: first });
if (!field) {
return setImmediate(callback);
}
var fieldType = self.fieldTypes[field.type];
if (fieldType.returnToError) {
return fieldType.returnToError(first, $field, $el, field, errorPath, error, callback);
} else {
self.showError($el, self.error(field, error));
}
return callback(null);
};
// Create a valid error object to be reported from a converter.
// You can also report a string as an error in which case self.convert
// creates one of these for you. The object is nice if you want to
// extend it with extra properties
self.error = function(field, type) {
var error = {
field: field,
type: type
};
switch (type) {
case 'required':
error.message = 'Required';
break;
case 'min':
error.message = 'Min. permitted value is: ' + field.min;
break;
case 'max':
error.message = 'Max. permitted value is: ' + field.max;
break;
case 'invalid':
error.message = 'Value is not valid';
break;
case 'taken':
error.message = 'Already taken';
break;
case 'mandatory':
if (typeof (field.mandatory) === 'string') {
error.message = field.mandatory;
} else {
error.message = 'Consent is required to continue';
}
break;
}
return error;
};
// Add click handlers for group tabs
self.enableGroupTabs = function($el) {
$el.on('click', '[data-apos-open-group]', function() {
var $tab = $(this);
$el.find('[data-apos-open-group],[data-apos-group]').removeClass('apos-active');
$tab.addClass('apos-active');
$el.find('[data-apos-group="' + $tab.data('apos-open-group') + '"]').addClass('apos-active');
return false;
});
};
// Convert an in-context area, storing it in data[name],
// if an editor is active
self.contextualConvertArea = function(data, name, $el, field) {
var $area = $el.findSafe('[data-apos-area][data-dot-path$="' + name + '"]', '[data-apos-area]');
var editor = $area.data('editor');
if (editor) {
data[name] = {
type: 'area',
items: editor.serialize()
};
}
};
// Returns true if an editor is actually active for the given area
self.contextualIsPresentArea = function($el, field) {
var editor = $el.findSafe('[data-apos-area][data-dot-path$="' + field.name + '"]', '[data-apos-area]').data('editor');
return !!editor;
};
self.enableSingleton = function($el, name, area, type, _options, callback) {
if (typeof (_options) === 'function') {
callback = _options;
_options = {};
}
// Singletons are essentially areas with a limit of 1 and only one type
// of widget permitted.
var options = {};
options.type = type;
options.widgets = {};
options.widgets[options.type] = _options;
return self.enableArea($el, name, area, options, callback);
};
self.evaQueue = [];
self.evaQueueTimeout = null;
self.evaQueueRunning = false;
// options argument may be skipped
self.enableArea = function($el, name, area, options, callback) {
if (!callback) {
callback = options;
options = {};
}
var items = [];
if (area && area.items) {
items = area.items;
} else if (_.isArray(area)) {
// If populating an area that hasn't been converted server-side yet,
// it might still be just an array of items instead of an area object
// (this happens in the case of the area editor when switching between
// different items).
items = area;
}
var $fieldset = self.findFieldset($el, name);
self.evaQueue.push({
name: name,
$fieldset: $fieldset,
items: items,
options: options
});
self.scheduleEvaQueue();
return callback(null);
};
self.scheduleEvaQueue = function() {
if (self.evaQueueTimeout) {
clearTimeout(self.evaQueueTimeout);
}
self.evaQueueTimeout = setTimeout(self.runEvaQueue, 250);
};
self.runEvaQueue = function() {
self.evaQueueTimeout = null;
if (self.evaQueueRunning) {
self.scheduleEvaQueue();
return;
}
self.evaQueueRunning = true;
// Avoid race condition by creating a new array for the next batch
var queue = self.evaQueue;
self.evaQueue = [];
$.jsonCall(apos.areas.options.action + '/edit-virtual-areas',
{
areas: _.map(queue, function(area) {
return {
items: area.items,
options: area.options
};
})
},
function(data) {
_.map(data.areas, function(html, i) {
var $fieldset = queue[i].$fieldset;
var $editView = self.findSafe($fieldset, '[data-' + queue[i].name + '-edit-view]');
$editView.append(html);
// Make sure slideshows, videos, etc. get their JS
apos.emit('enhance', $editView);
});
self.evaQueueRunning = false;
},
function(err) {
// We are not pending on a callback so there isn't much else we can do
// in the event of a network error
apos.utils.error(err);
self.evaQueueRunning = false;
apos.notify('An error occurred rendering the dialog box. Please refresh and try again.', { type: 'error' });
}
);
};
self.getSingleton = function($el, name) {
return self.getArea($el, name);
};
// Retrieve a JSON-friendly serialization of the area
self.getArea = function($el, name) {
var $fieldset = self.findFieldset($el, name);
var $property = self.findSafe($fieldset, '[data-' + name + '-edit-view]');
return $property.find('[data-apos-area]:first').data('editor').serialize();
};
self.addFieldType = function(type) {
self.fieldTypes[type.name] = type;
};
// Make an error visible. You can use this in your own validation
// code, see self.error() for an easy way to make an error object
self.showError = function($el, error) {
var $fieldset = self.findFieldset($el, error.field.name);
var type = self.fieldTypes[error.field.type];
if (type.showError) {
return type.showError($fieldset, error);
}
$fieldset.addClass('apos-error');
$fieldset.addClass('apos-error--' + apos.utils.cssName(error.type));
var $first = $fieldset.find('label:first');
if (error.message) {
$first.attr('data-apos-error-message', error.message);
}
$first.find('.apos-schema-fix').remove();
if (error.fix) {
var $fix = $('<a href="#" data-apos-fix-error="' + error.type + '" data-apos-fix-url="' + error.doc._url + '" data-apos-fix-id="' + error.doc._id + '" data-apos-fix-type="' + error.doc.type + '" class="apos-schema-fix">Edit Conflicting Content <span class="fa fa-external-link"></span></a>');
if (error.hint) {
$fix.attr('data-apos-fix-hint', error.hint);
}
$first.append($fix);
}
// makes it easy to do more with particular types of errors
apos.emit('schemaError', $fieldset, error);
};
// A convenience allowing you to scroll to the first error present,
// if any. convert() will call this if an error is present.
// Also ensures the appropriate tab group is actually visible.
self.scrollToError = function($el) {
var $element = self.findSafe($el, '.apos-error');
if ($element.length) {
self.showGroupContaining($element);
$element.scrollintoview();
$element.find('input,select,textarea').first().focus();
}
};
// Remove an error from the given fieldset. Not often required
// because a fresh "convert" takes care of this completely,
// but there are edge cases such as the "taken" error that
// should be optimistically removed if the user elects to
// use the wrench to edit the conflicting document.
self.removeError = function($fieldset, error) {
if ((typeof error) !== 'string') {
error = error.type;
}
$fieldset.removeClass('apos-error--' + apos.utils.cssName(error));
if ($fieldset.attr('class').indexOf('apos-error--') === -1) {
$fieldset.removeClass('apos-error');
}
self.findSafe($fieldset, '[data-apos-fix-error="' + error + '"]').remove();
};
// Show the tab group containing the given element, if not already visible,
// by triggering the appropriate tab button.
self.showGroupContaining = function($element) {
var $group = $element.closest('.apos-schema-group');
if ($group.length) {
if (!$group.hasClass('apos-active')) {
var group = $group.attr('data-apos-group');
var $container = $group.closest('[data-apos-form]');
$container.find('[data-apos-open-group="' + group + '"]').trigger('click');
}
}
};
// Used to search for fieldsets at this level of the schema,
// without false positives for any schemas nested within it.
self.findFieldset = function($el, name) {
return self.findSafe($el, '[data-name="' + name + '"]');
};
// Used to search for elements without false positives from nested
// schemas in unrelated fieldsets, however see `findFieldset` or
// `findSafeInFieldset` for what you probably want.
//
// Optimized implementation at the DOM level, the jQuery
// `findSafe` plugin is much slower. Still returns a
// jQuery object, so there is no incompatibility with
// existing code that uses this method.
// Used to search for elements without false positives from nested
// schemas in unrelated fieldsets, however see `findFieldset` or
// `findSafeInFieldset` for what you probably want.
//
// Optimized implementation at the DOM level, the jQuery
// `findSafe` plugin is much slower. Still returns a
// jQuery object, so there is no incompatibility with
// existing code that uses this method.
self.findSafe = function($el, sel) {
if ((typeof sel) !== 'string') {
// Optimized version only understands searching for
// string selectors, maybe jQuery can do other tricks
return $el.findSafe(sel, '.apos-field');
}
var el = $el[0];
if (!el) {
return $([]);
}
var fields = el.querySelectorAll(sel);
fields = _.filter(fields, function(field) {
var parent = field.parentNode;
while (parent) {
if (parent === el) {
return true;
}
if (parent.classList && parent.classList.contains('apos-field')) {
// A tricky catch: yes we found .apos-field, but
// what if sel looks like '[data-name="tags"] [data-selective]'?
// The first component of that selector IS an apos-field, so
// it shouldn't block itself. The solution is to check whether
// the apos-field we just walked up the tree to actually contains
// our element when filtered for the original selector.
var matches = parent.querySelectorAll(sel);
if (_.find(matches, function(match) {
return match === field;
})) {
return false;
}
}
parent = parent.parentNode;
}
});
return $(fields);
};
// A convenient way to find something safely within a specific fieldset
// (safely means "not inside a nested schema"). The fieldset is found first,
// then `sel` is located within it, without recursing into any nested
// schema forms that may be present.
self.findSafeInFieldset = function($el, name, sel) {
var $fieldset = self.findFieldset($el, name);
return $fieldset.findSafe(sel, '.apos-field');
};
// Used to search for simple elements that have a
// "name" attribute, without false positives from nested
// schemas in unrelated fieldsets.
self.findField = function($el, name) {
var $fieldset = self.findFieldset($el, name);
return self.findSafe($fieldset, '[name="' + name + '"]');
};
self.enhanceSelectiveWithSlugs = function($field) {
// Change the presentation to include the slug.
// Based on: http://jqueryui.com/autocomplete/#custom-data
// I stuck with that markup with a minimum of new markup to
// allow styling. -Tom
var $autocomplete = self.findSafe($field, "[data-autocomplete]");
$autocomplete.data("ui-autocomplete")._renderItem = function(ul, item) {
var inner = '<a><div class="apos-autocomplete-label">' + item.label + '</div>';
if (item.slug && (item.slug.match)) {
inner += '<div class="apos-autocomplete-slug">' + item.slug + '</div>';
}
inner += '</a>';
return $('<li class="apos-autocomplete-item">')
.append(inner)
.appendTo(ul);
};
};
// Return a new object with all default settings
// defined in the schema
self.newInstance = function(schema) {
var def = {};
_.each(schema, function(field) {
var fieldType = self.fieldTypes[field.type];
if (field.def !== undefined) {
def[field.name] = field.def;
} else if (fieldType.getDefault) {
def[field.name] = fieldType.getDefault();
}
});
return def;
};
// Enable autocomplete of tags. Expects the fieldset element
// (not the input element) and an array of existing tags already
// assigned to this item. Exported for the convenience of
// code that is not fully schema-based but wants to enhance
// tag fields in the same way.
self.enableTags = function($el, tags, field) {
tags = tags || [];
field = field || {};
// our fieldsets no longer have a data-selective attribute,
// instead there is a wrapper div inside that does. This code
// works when enableTags is passed either the fieldset or
// the inner wrapper div. -Tom
if (!$el[0].hasAttribute('data-selective')) {
$el = $el.find('[data-selective]:first');
}
$el.on('click', '[data-list]', function() {
$el.find('input').focus();
});
$el.on('change', function() {
$el.find('[data-list]').toggleClass('apos-empty', !$el.find('[data-item]').length);
});
// TODO bring back lock tags
// if (apos.data.lockTags) {
// $el.find('[data-add]').remove();
// }
if (!field.limit) {
field.limit = undefined;
}
if (!field.sortable) {
field.sortable = undefined;
}
$el.selective({ preventDuplicates: true, add: true, /* !apos.data.lockTags, */ data: tags, source: source, addKeyCodes: [ 13, 'U+002C' ], limit: field.limit, sortable: field.sortable, nestGuard: '[data-selective]' });
// Set initial empty state correctly
if (!tags.length) {
$el.find('[data-list]').addClass('apos-empty');
}
function source(request, callback) {
return $.jsonCall('/modules/apostrophe-tags/autocomplete', request, callback);
}
};
// Reusable utility to watch the title and use it to
// suggest valid slugs.
//
// If the initial slug contains slashes, only the last component
// (after the last slash) is changed on title edits.
//
// If the original slug (or its last component) is not in sync with the
// title, it is assumed that the user already made deliberate changes to
// the slug, and the slug is not automatically updated.
//
// `$title` and `$slug` are jQuery objects referencing the actually
// input elements. `title` and `slug` are the schema field definitions.
//
// This has become an implementation detail of enableSlugSuggestions
// but for bc it remains a publicly available API.
self.enableSlug = function($title, $slug, title, slug) {
if (!$title.length || !$slug.length) {
return;
}
// Watch the title for changes, update the slug - but only if
// the slug was in sync with the title to start with
var originalTitle = $title.val();
var currentSlug = $slug.val();
var components = currentSlug.split('/');
var currentSlugTitle = components.pop();
var prefix = '';
if ($slug.data('prefix')) {
prefix = $slug.data('prefix') + '-';
}
if ((originalTitle === '') || (currentSlugTitle === apos.utils.slugify(prefix + originalTitle))) {
$title.on('textchange', function(e) {
$slug.val($slug.val().replace(/[^/]*$/, apos.utils.slugify(prefix + $title.val())));
});
}
};
self.watchSlugConflicts = function($title, $slug, title, slug, object) {
if (!slug.checkTaken) {
return;
}
if (slug.deconflict === false) {
return;
}
var watchSlugTimeout;
$title.off('textchange.watchSlugConflicts');
$title.on('textchange.watchSlugConflicts', function() {
if (watchSlugTimeout) {
return;
}
watchSlugTimeout = setTimeout(watchSlug, 500);
function watchSlug() {
$.jsonCall(apos.docs.options.action + '/slug-deconflict', {
_id: object._id,
slug: $slug.val()
}, function(result) {
clearTimeout(watchSlugTimeout);
watchSlugTimeout = null;
if (result.status === 'ok') {
$slug.val(result.slug);
}
});
}
});
};
self.enableShowFields = function(data, name, $field, $el, field) {
var $fieldset = self.findFieldset($el, name);
// afterChange shows and hides other fieldsets based on
// the current value of this field and its visibility.
// We do this in three situations: at startup, when the
// user changes the value, and when the visibility of this
// field has been affected by another field with the
// showFields option. This allows nested showFields to
// work properly. -Tom
afterChange();
$field.on('change', afterChange);
$fieldset.on('aposShowFields', afterChange);
function afterChange() {
// Implement showFields
if (!_.find(field.choices || [], function(choice) {
return choice.showFields;
})) {
// showFields is not in use for this select
return;
}
var val;
if (field.checkbox) {
val = $field.is(':checked');
} else {
val = $field.val();
}
// Recall if another choice currently active already chose to show each field.
// That way, if two choices show the same field, the fact that the second one
// is not currently selected does not hide the field the first one just showed
var shown = {};
_.each(field.choices || [], function(choice) {
// Show the fields for this value if it is the current value
// *and* the select field itself is currently visible
var show;
if ($fieldset.hasClass('apos-hidden')) {
show = false;
} else if (field.type === 'boolean') {
// Comparing boolean values is hard because
// the string '0' must be considered falsy in
// order to permit use of select elements. -Tom
if (val === choice.value) {
show = true;
} else if (!choice.value) {
if ((!val) || (val === '0')) {
show = true;
}
} else {
if (val && (val !== '0')) {
show = true;
}
}
} else if (field.type === 'checkboxes') {
_.each($field || [], function(checkbox) {
if (checkbox.checked && checkbox.value === choice.value && choice.showFields) {
show = true;
}
});
} else {
// type select
if (val === choice.value.toString()) {
show = true;
}
}
_.each(choice.showFields || [], function(fieldName) {
if (show || shown[fieldName]) {
shown[fieldName] = true;
} else {
shown[fieldName] = false;
}
var $fieldset = self.findFieldset($el, fieldName);
$fieldset.toggleClass('apos-hidden', !shown[fieldName]);
$fieldset.trigger('aposShowFields');
});
});
}
};
self.addFieldType({
name: 'area',
populate: function(data, name, $field, $el, field, callback) {
return self.enableArea($el, name, data[name], field.options || {}, callback);
},
convert: function(data, name, $field, $el, field, callback) {
data[name] = self.getArea($el, name);
if (field.required && (!data[name].length)) {
return setImmediate(_.partial(callback, 'required'));
}
return setImmediate(callback);
},
contextualConvert: self.contextualConvertArea,
contextualIsPresent: self.contextualIsPresentArea
});
self.addFieldType({
name: 'singleton',
populate: function(data, name, $field, $el, field, callback) {
return self.enableSingleton($el, name, data[name], field.widgetType, field.options || {}, callback);
},
convert: function(data, name, $field, $el, field, callback) {
data[name] = self.getSingleton($el, name);
if (field.required && (!data[name].length)) {
return setImmediate(_.partial(callback, 'required'));
}
return setImmediate(callback);
},
contextualConvert: self.contextualConvertArea,
contextualIsPresent: self.contextualIsPresentArea
});
self.addFieldType({
name: 'array',
populate: function(data, name, $field, $el, field, callback) {
var $button = self.findSafeInFieldset($el, name, '[data-apos-edit-array]');
function setArray(value) {
var length = value ? value.length : 0;
$field.data('apos-array', value);
$field.find('.apos-field-label').html(field.label);
$field.find('[data-array-length]')
.html('(' + length + ' Item' + ((length > 1 || length === 0) ? 's)' : ')'));
}
setArray(data[name]);
$button.on('click', function() {
var $fieldset = self.findFieldset($el, name);
// Mimicking .getTool from docs . . .
apos.create('apostrophe-array-editor-modal', {
field: field,
arrayItems: $field.data('apos-array'),
action: options.action,
save: setArray,
error: $fieldset.data('error'),
errorPath: $fieldset.data('errorPath')
}, function(err) {
if (err) {
apos.utils.error(err);
}
});
return false;
});
return setImmediate(callback);
},
returnToError: function(name, $field, $el, field, errorPath, error, callback) {
var $fieldset = self.findFieldset($el, name);
$fieldset.data('error', error);
$fieldset.data('errorPath', errorPath);
var $button = self.findSafeInFieldset($el, name, '[data-apos-edit-array]');
$button.click();
},
convert: function(data, name, $field, $el, field, callback) {
if ($field.data('apos-array')) {
data[name] = $field.data('apos-array');
}
// Confirm that the widget is truly empty.
if (field.required && (!(data[name] && data[name][0]))) {
return callback('required');
}
return setImmediate(callback);
},
contextualConvert: function(data, name, $el, field) {
// We cannot call getWidgetData here because of recursion
var existingData = JSON.parse($el.attr('data') || '{}');
if (!existingData) {
return;
}
var dotPath = existingData.__dotPath;
var docId = existingData.__docId;
if (!dotPath && docId) {
return;
}
existingData = existingData[name];
if (!existingData) {
return;
}
// Contextually convert any areas
// that are fields of this array field
_.each(field.schema, function(sub) {
if ((sub.type !== 'area') && (sub.type !== 'singleton')) {
return;
}
var n = 0;
while (true) {
var selector = '[data-apos-area][data-doc-id="' + docId + '"][data-dot-path="' + dotPath + '.' + name + '.' + n + '.' + sub.name + '"]';
var $area = $el.find(selector);
if (!$area.length) {
break;
}
var editor = $area.data('editor');
if (editor) {
if (existingData[n]) {
existingData[n][sub.name] = {
type: 'area',
items: editor.serialize()
};
}
}
n++;
}
});
data[name] = existingData;
},
// Always returns true because it is designed to work nondestructively
// with fields that don't turn out to be contextual
contextualIsPresent: function($el, field) {
return true;
},
getDefault: function() {
return [];
}
});
self.addFieldType({
name: 'object',
populate: function(data, name, $field, $el, field, callback) {
if (data[name] === undefined) {
data[name] = {};
}
var $fieldset = self.findFieldset($el, name);
self.populate($fieldset, field.schema, data[name], callback);
},
convert: function(data, name, $field, $el, field, callback) {
if (data[name] === undefined) {
data[name] = {};
}
var $fieldset = self.findFieldset($el, name);
self.convert($fieldset, field.schema, data[name], function(errors) {
var err = null;
if (errors) {
$fieldset.data('nestedErrors', errors);
err = 'invalid';
}
return callback(err);
});
},
showError: function($fieldset, err) {
var nestedErrors = $fieldset.data('nestedErrors');
$fieldset.data('nestedErrors', null);
if (nestedErrors) {
self.showError($fieldset, nestedErrors[0]);
}
},
contextualConvert: function(data, name, $el, field) {
// We cannot call getWidgetData here because of recursion
var existingData = JSON.parse($el.attr('data') || '{}');
if (!existingData) {
return;
}
var dotPath = existingData.__dotPath;
var docId = existingData.__docId;
if (!dotPath && docId) {
return;
}
existingData = existingData[name];
if (!existingData) {
return;
}
// Contextually convert any areas
// that are fields of this array field
_.each(field.schema, function(sub) {
if ((sub.type !== 'area') && (sub.type !== 'singleton')) {
return;
}
var selector = '[data-apos-area][data-doc-id="' + docId + '"][data-dot-path="' + dotPath + '.' + name + '.' + sub.name + '"]';
var $area = $el.find(selector);
if (!$area.length) {
return;
}
var editor = $area.data('editor');
if (editor) {
if (existingData) {
existingData[sub.name] = {
type: 'area',
items: editor.serialize()
};
}
}
});
data[name] = existingData;
},
// Always returns true because it is designed to work nondestructively
// with fields that don't turn out to be contextual
contextualIsPresent: function($el, field) {
return true;
},
getDefault: function() {
return {};
}
});
self.addFieldType({
name: 'joinByOne',
populate: function(data, name, $field, $el, field, callback) {
var manager;
var $fieldset = self.findFieldset($el, name);
var chooser = $fieldset.data('aposChooser');
var chooserGetter;
if (!chooser) {
if (Array.isArray(field.withType)) {
manager = apos.docs.getManager('apostrophe-polymorphic');
chooserGetter = _.partial(manager.getTool, 'chooser');
} else {
manager = apos.docs.getManager(field.withType);
chooserGetter = _.partial(manager.getTool, 'chooser');
}
return chooserGetter({ field: field, $el: $fieldset.find('[data-chooser]') }, function(err, _chooser) {
if (err) {
return callback(err);
}
chooser = _chooser;
var choices = [];
if (data[field.idField]) {
choices.push({ value: data[field.idField] });
}
chooser.set(choices);
$fieldset.data('aposChooser', chooser);
return callback(null);
});
}
},
convert: function(data, name, $field, $el, field, callback) {
var $fieldset = self.findFieldset($el, name);
var chooser = $fieldset.data('aposChooser');
return chooser.getFinal(function(err, choices) {
if (err) {
return callback(err);
}
data[field.idField] = null;
if (choices[0]) {
data[field.idField] = choices[0].value;
}
if ((field.required) && (!data[field.idField])) {
return callback('required');
}
return callback(null);
});
}
});
self.addFieldType({
name: 'joinByOneReverse',
populate: function(data, name, $field, $el, field, callback) {
// Not edited on the reverse side
return setImmediate(callback);
},
convert: function(data, name, $field, $el, field, callback) {
// Not edited on this side of the relation
return setImmediate(callback);
}
});
self.addFieldType({
name: 'joinByArray',
populate: function(data, name, $field, $el, field, callback) {
var chooser;
var manager;
var $fieldset = self.findFieldset($el, name);
return async.series({
getChooser: function(callback) {
chooser = $fieldset.data('aposChooser');
var chooserGetter;
if (!chooser) {
if (Array.isArray(field.withType)) {
manager = apos.docs.getManager('apostrophe-polymorphic');
chooserGetter = _.partial(manager.getTool, 'chooser');
} else {
manager = apos.docs.getManager(field.withType);
chooserGetter = _.partial(manager.getTool, 'chooser');
}
return chooserGetter({ field: field, $el: $fieldset.find('[data-chooser]') }, function(err, _chooser) {
if (err) {
return callback(err);
}
chooser = _chooser;
var choices = [];
if (data[field.idField]) {
choices.push({ value: data[field.idField] });
}
chooser.set(choices);
$fieldset.data('aposChooser', chooser);
return callback(null);
});
}
},
set: function(callback) {
var choices = [];
if (data[field.idsField]) {
choices = _.map(data[field.idsField], function(id) {
return { value: id };
});
}
if (field.relationship) {
var relationships = data[field.relationshipsField] || {};
_.each(choices, function(choice) {
var relationship = relationships[choice.value] || {};
_.assign(choice, relationship);
});
}
chooser.set(choices);
return setImmediate(callback);
}
}, callback);
},
convert: function(data, name, $field, $el, field, callback) {
var $fieldset = self.findFieldset($el, name);
var chooser = $fieldset.data('aposChooser');
return chooser.getFinal(function(err, choices) {
if (err) {
return callback(err);
}
var removed = _.filter(choices, { __removed: true });
choices = _.difference(choices, removed);
data[field.idsField] = _.pluck(choices, 'value');
data[field.removedIdsField] = _.pluck(removed, 'value');
if (field.relationship) {
data[field.relationshipsField] = data[field.relationshipsField] || {};
var relationships = data[field.relationshipsField];
// Yes, we send up the relationships for the removed fields too.
// This is because some relationships are interesting only at the
// moment of removal, like an "apply to subpages" checkbox for
// permissions. -Tom
_.each(choices.concat(removed), function(choice) {
if (!relationships[choice.value]) {
relationships[choice.value] = {};
}
var relationship = relationships[choice.value];
_.each(choice, function(value, key) {
if (key !== 'value') {
relationship[key] = value;
}
});
});
}
if ((field.required) && (!(data[field.idsField] && data[field.idsField][0]))) {
return callback('required');
}
return callback(null);
});
}
});
self.addFieldType({
name: 'joinByArrayReverse',
populate: function(data, name, $field, $el, field, callback) {
// Not edited on the reverse side
return setImmediate(callback);
},
convert: function(data, name, $field, $el, field, callback) {
// Not edited on this side of the relation
return setImmediate(callback);
}
});
self.addFieldType({
name: 'group',
populate: function(data, name, $field, $el, field, callback) {
// Just a presentation thing
return setImmediate(callback);
},
convert: function(data, name, $field, $el, field, callback) {
// Just a presentation thing
return setImmediate(callback);
}
});
function convertString(data, name, $field, $el, field, callback) {
var err;
data[name] = $field.val();
if (field.required && (!(data[name] && data[name].length))) {
err = self.error(field, 'required');
return setImmediate(_.partial(callback, err));
}
if (data[name] && field.max && (data[name].length > field.max)) {
err = self.error(field, 'max');
err.message = 'Maximum of ' + field.max + ' characters';
return setImmediate(_.partial(callback, err));
}
if (data[name] && field.min && (data[name].length < field.min)) {
err = self.error(field, 'min');
err.message = 'Minimum of ' + field.min + ' characters';
return setImmediate(_.partial(callback, err));
}
return setImmediate(callback);
}
function populateString(data, name, $field, $el, field, callback) {
$field.val(data[name]);
if (field.max) {
if (field.textarea) {
if (field.max) {
var $fieldset = self.findFieldset($el, name);
$fieldset.removeClass('apos-max');
$field.off('textchange.schema');
$field.on('textchange.schema', function() {
var length = $field.val().length;
if (length > field.max) {
$fieldset.addClass('apos-max');
} else {
$fieldset.removeClass('apos-max');
}
});
}
} else {
$field.attr('maxlength', field.max);
}
}
return setImmediate(callback);
}
self.addFieldType({
name: 'string',
populate: populateString,
convert: convertString
});
self.addFieldType({
name: 'password',
populate: populateString,
convert: convertString
});
self.addFieldType({
name: 'slug',
populate: populateString,
convert: function(data, name, $field, $el, field, callback) {
return convertString(data, name, $field, $el, field, function(err) {
if (field.prefix) {
if (data[name].length && (data[name].substring(0, field.prefix.length) !== field.prefix)) {
var error = self.error(field, 'prefix');
error.message = 'The slug must begin with: ' + field.prefix;
return callback(error);
}
}
if (!field.checkTaken) {
// A slug field being used for a purpose other
// than an apostrophe doc
return callback(null);
}
if (err) {
return callback(err);
}
$.jsonCall(
apos.docs.options.action + '/slug-taken',
{ slug: data[name], _id: data._id },
function(data) {
if (data.status === 'ok') {
return callback(null);
} else if (data.status === 'taken') {
var error = self.error(field, 'taken');
error.doc = data.doc;
error.hint = data.hint;
error.fix = true;
return callback(error);
} else {
return callback('error');
}
}, function(err) {
apos.utils.error(err);
return callback(err);
}
);
});
}
});
self.addFieldType({
name: 'tags',
populate: function(data, name, $field, $el, field, callback) {
self.enableTags(self.findSafe($el, '[data-name="' + name + '"]'), data[name], field || {});
return setImmediate(callback);
},
convert: function(data, name, $field, $el, field, callback) {
data[name] = self.findSafeInFieldset($el, name, '[data-selective]').selective('get', { incomplete: true });
if (field.required && !data[name].length) {
return setImmediate(_.partial(callback, 'required'));
}
return setImmediate(callback);
}
});
self.addFieldType({
name: 'boolean',
populate: function(data, name, $field, $el, field, callback) {
if (data[name] === true || data[name] === '1') {
$field.val('1');
} else {
$field.val('0');
}
self.enableShowFields(data, name, $field, $el, field);
return setImmediate(callback);
},
convert: function(data, name, $field, $el, field, callback) {
data[name] = $field.val();
// `mandatory` property used for consent checks (I agree)
if (field.mandatory && (!data[name] || data[name] === '0')) {
return setImmediate(_.partial(callback, 'mandatory'));
}
return setImmediate(callback);
}
});
self.addFieldType({
name: 'checkboxes',
populate: function(data, name, $field, $el, field, callback) {
var $fieldset = self.findFieldset($el, name);
return async.series([
getChoices,
renderChoices
], callback);
function getChoices(callback) {
if ((typeof field.choices) !== 'string') {
$fieldset.data('aposChoices', field.choices);
return callback(null);
}
// Dynamic choices
apos.ui.globalBusy(true);
return self.api('choices', {
field: field
}, function(result) {
apos.ui.globalBusy(false);
if (result.status !== 'ok') {
return callback(result.status);
}
// Replace the markup with a server side
// rendering of the actual dynamic choices.
// Preserve the original field label because of
// the precedent that workflow controls may
// be appended to it and already have
// behaviors bound.
var markup = result.markup;
var $newFieldset = $(markup);
var $fieldset = self.findFieldset($el, field.name);
var $label = $fieldset.find('label:first');
var $newLabel = $newFieldset.find('label:first');
$newLabel.replaceWith($label);
$fieldset.html('');
$fieldset.append($newFieldset.children());
$fieldset.data('aposChoices', result.choices);
return callback(null);
}, function(err) {
apos.ui.globalBusy(false);
return callback(err);
});
}
function renderChoices(callback) {
// Server did the actual rendering, we just need to
// select the existing choices
var $fieldset = self.findFieldset($el, name);
for (var c in data[name]) {
self.findSafe($fieldset, 'input[name="' + name + '"][value="' + data[name][c] + '"]', '.apos-field').prop('checked', true);
}
self.enableShowFields(data, name, $field, $el, field);
return setImmediate(callback);
}
},
convert: function(data, name, $field, $el, field, callback) {
var $fieldset = self.findFieldset($el, field.name);
var values = [];
var choices = $fieldset.data('aposChoices');
for (var c in choices) {
var val = choices[c].value;
var checked = $field.filter('[value="' + val + '"]').prop('checked');
if (checked) {
values.push(val);
}
}
data[name] = values;
if (field.required && !data[name]) {
return setImmediate(_.partial(callback, 'required'));
}
return setImmediate(callback);
}
});
self.addFieldType({
name: 'radioTable',
populate: function(data, name, $field, $e