UNPKG

apostrophe

Version:

The Apostrophe Content Management System.

1,364 lines (1,258 loc) • 59.3 kB
// 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