UNPKG

apostrophe-schemas

Version:

Schemas for easy editing of properties in Apostrophe objects

673 lines (623 loc) 24.9 kB
function AposSchemas() { var self = this; // Populate form elements corresponding to a set of fields as specified in a schema // (the schema argument). The inverse of self.convertSomeFields self.populateFields = function($el, schema, snippet, callback) { // This is a workaround for the lack of async.each client side. // Think about bringing that into the browser. function populateField(i) { if (i >= schema.length) { return callback(null); } var field = schema[i]; // Utilized by simple displayers that use a simple HTML // element with a name attribute var $field = self.findField($el, field.name); // If this field maps to a plain HTML element set the // required attribute when appropriate. See: // http://stackoverflow.com/questions/18770369/how-to-set-html5-required-attribute-in-javascript // for why I do it this way. if (field.required && $field[0]) { $field[0].required = true; } // This is a hack to implement async.eachSeries. TODO: think about putting // the async module in the browser return self.displayers[field.type](snippet, field.name, $field, $el, field, function() { if (field.autocomplete === false) { $field.attr('autocomplete', 'off'); } return populateField(i + 1); }); } return populateField(0); }; // Gather data from form elements and push it into properties of the data object, // as specified by the schema provided. The inverse of self.populateSomeFields self.convertFields = function($el, schema, data, callback) { self.findSafe($el, '[data-name]').removeClass('apos-error'); var failing; // async for loop var i = 0; function convertField() { if (i === schema.length) { return apos.afterYield(_.partial(callback, failing)); } var field = schema[i]; if (field.contextual) { i++; return apos.afterYield(convertField); } // 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 = self.findField($el, field.legacy); } return self.converters[field.type](data, field.name, $field, $el, field, function(err) { if (err) { self.addError($el, field.name); failing = field; } i++; return apos.afterYield(convertField); }); } convertField(); }; self.enableSingleton = function($el, name, area, type, optionsArg, callback) { if (typeof(optionsArg) === 'function') { callback = optionsArg; optionsArg = {}; } var items = []; if (area && area.items) { items = area.items; } var options = {}; $.extend(options, optionsArg); $.extend(options, { type: type }); var $fieldset = self.findFieldset($el, name); refreshSingleton(items, callback); function refreshSingleton(items, callback) { options.content = items; $.jsonCall('/apos/edit-virtual-singleton', { dataType: 'html' }, options, function(data) { var $editView = self.findSafe($fieldset, '[data-' + name + '-edit-view]'); $editView.html(''); $editView.append(data); // getSingleton will pick it up from here $editView.data('items', items); // If an edit takes place, refresh so we can see the new preview here // in the form. This isn't an issue with areas since they are always // in the edit state in a form. TODO: consider whether it would be // better to create a container that allows widgets to be rendered // inline, without a nested dialog box var $singleton = self.findSafe($editView, '.apos-singleton:first'); $singleton.on('aposEdited', function(e, data) { refreshSingleton([data], function() { // A change event on the singleton's wrapper signifies // that getSingleton can now be // called to see the new data $fieldset.trigger('change'); }); }); if (callback) { return callback(null); } }); } }; // 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; } var $fieldset = self.findFieldset($el, name); $.jsonCall('/apos/edit-virtual-area', { dataType: 'html' }, { content: items, options: options }, function(data) { var $editView = self.findSafe($fieldset, '[data-' + name + '-edit-view]'); $editView.append(data); return callback(null); }); }; // Retrieve a JSON-friendly serialization of the singleton self.getSingleton = function($el, name) { var $fieldset = self.findFieldset($el, name); var items = self.findSafe($fieldset, '[data-' + name + '-edit-view]').data('items'); items = items || []; return items; }; // 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('.apos-area:first').data('editor').serialize(); }; // Methods to convert from a form field of each schema type // to a property of the snippet ready to save. The server does // all the validation of course, since you can't trust a browser // anyway, so this is mostly simple except where the representation // in the form differs greatly from the representation the server wants self.converters = { // Convert the tough cases area: function(data, name, $field, $el, field, callback) { data[name] = self.getArea($el, name); if (field.required && (apos.areaIsEmpty(data[name]))) { return apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, singleton: function(data, name, $field, $el, field, callback) { data[name] = self.getSingleton($el, name); if (field.required && (apos.singletonIsEmpty(data[name], field.type))) { return apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, array: function(data, name, $field, $el, field, callback) { var results = []; var $fieldset = self.findFieldset($el, name); var $elements = self.findSafe($fieldset, '[data-element]:not(.apos-template)'); var i = 0; var err; function convertElement() { if (i === $elements.length) { data[name] = results; return apos.afterYield(_.partial(callback, err)); } var result = {}; var $element = $($elements[i]); return self.convertFields($element, field.schema, result, function(_err) { if (_err) { err = _err; } results.push(result); i++; return apos.afterYield(convertElement); }); } convertElement(); }, joinByOne: function(data, name, $field, $el, field, callback) { // Fix $field since we can't use the regular name attribute here $field = self.findSafe($el, '[data-name="' + name + '"]'); data[field.idField] = $field.selective('get', { incomplete: true })[0]; if (field.required && !data[name]) { return apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, joinByOneReverse: function(data, name, $field, $el, field, callback) { // Not edited on this side of the relation return apos.afterYield(callback); }, joinByArray: function(data, name, $field, $el, field, callback) { // Fix $field since we can't use the regular name attribute here $field = self.findSafe($el, '[data-name="' + name + '"]'); var info = $field.selective('get', { incomplete: true }); if (field.relationshipField) { data[field.idsField] = _.pluck(info, 'value'); data[field.relationshipField] = {}; var relationship = {}; _.each(info, function(e) { relationship[e.value] = _.omit(e, [ 'value', 'label' ]); }); data[field.relationshipField] = relationship; } else { data[field.idsField] = info; } if (field.required && !data[field.idsField].length) { return apos.afterYield(function() { return apos.afterYield(_.partial(callback, 'required')); }); } return apos.afterYield(callback); }, joinByArrayReverse: function(data, name, $field, $el, field, callback) { // Not edited on this side of the relation return apos.afterYield(callback); }, group: function(data, name, $field, $el, field, callback) { // Just a presentation thing return apos.afterYield(callback); }, // The rest are very simple because the server does // the serious sanitization work and the representation in the DOM // is a simple form element string: function(data, name, $field, $el, field, callback) { data[name] = $field.val(); if (field.required && !data[name].length) { return apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, password: function(data, name, $field, $el, field, callback) { data[name] = $field.val(); if (field.required && !data[name].length) { return apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, slug: function(data, name, $field, $el, field, callback) { data[name] = $field.val(); if (field.required && !data[name].length) { return apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, tags: function(data, name, $field, $el, field, callback) { data[name] = self.findSafe($el, '[data-name="' + name + '"]').selective('get', { incomplete: true }); if (field.required && !data[name].length) { return apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, boolean: function(data, name, $field, $el, field, callback) { data[name] = $field.val(); // Seems odd but sometimes used to mandate an "I agree" box if (field.required && !data[name]) { return apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, checkboxes: function(data, name, $field, $el, field, callback) { var values = []; for (var c in field.choices) { var val = field.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 apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, select: function(data, name, $field, $el, field, callback) { data[name] = $field.val(); if (field.required && !data[name].length) { return apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, integer: function(data, name, $field, $el, field, callback) { data[name] = $field.val(); if (field.required && !data[name].length) { return apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, float: function(data, name, $field, $el, field, callback) { data[name] = $field.val(); if (field.required && !data[name].length) { return apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, url: function(data, name, $field, $el, field, callback) { data[name] = $field.val(); if (field.required && !data[name].length) { return apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, date: function(data, name, $field, $el, field, callback) { data[name] = $field.val(); if (field.required && !data[name].length) { return apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, time: function(data, name, $field, $el, field, callback) { data[name] = $field.val(); if (field.required && !data[name].length) { return apos.afterYield(_.partial(callback, 'required')); } return apos.afterYield(callback); }, }; // Methods to display all of the field types supported by the schema self.displayers = { area: function(data, name, $field, $el, field, callback) { return self.enableArea($el, name, data[name], field.options || {}, callback); }, singleton: function(data, name, $field, $el, field, callback) { return self.enableSingleton($el, name, data[name], field.widgetType, field.options || {}, callback); }, array: function(data, name, $field, $el, field, callback) { var $fieldset = self.findFieldset($el, name); var $template = self.findSafe($fieldset, '.apos-template[data-element]'); var $add = self.findSafe($fieldset, '[data-add]'); var $elements = self.findSafe($fieldset, '[data-elements]'); // Add the elements via an async for loop without // the async module. -Tom var i = 0; data = data[name] || []; function nextElement() { if (i === data.length) { $elements.sortable({ handle: '[data-move]' }); return callback(null); } var $element = $template.clone(); $element.removeClass('apos-template'); addRemoveHandler($element); addMoveHandler($element); $elements.append($element); return self.populateFields($element, field.schema, data[i], function() { i++; return nextElement(); }); } nextElement(); $add.on('click', function() { var $element = $template.clone(); $element.removeClass('apos-template'); $elements.prepend($element); addRemoveHandler($element); addMoveHandler($element); var element = {}; _.each(field.schema, function(field) { if (field.def !== undefined) { element[field.name] = field.def; } }); self.populateFields($element, field.schema, element, function() { // Make sure lister gets a crack apos.emit('enhance', $element); }); return false; }); function addRemoveHandler($element) { var $remove = self.findSafe($element, '[data-remove]'); $remove.on('click', function() { $element.remove(); return false; }); } function addMoveHandler($element) { var $move = self.findSafe($element, '[data-move-item]'); $move.on('click', function() { if ($(this).attr('data-move-item') === 'up') { $element.prev().before($element); } else { $element.next().after($element); } return false; }); } }, string: function(data, name, $field, $el, field, callback) { $field.val(data[name]); return apos.afterYield(callback); }, password: function(data, name, $field, $el, field, callback) { $field.val(data[name]); return apos.afterYield(callback); }, slug: function(data, name, $field, $el, field, callback) { $field.val(data[name]); return apos.afterYield(callback); }, tags: function(data, name, $field, $el, field, callback) { apos.enableTags(self.findSafe($el, '[data-name="' + name + '"]'), data[name]); return apos.afterYield(callback); }, url: function(data, name, $field, $el, field, callback) { $field.val(data[name]); return apos.afterYield(callback); }, checkboxes: function(data, name, $field, $el, field, callback) { for(var c in data[name]) { $el.find('input[name="'+name+'"][value="'+data[name][c]+'"]').prop('checked', true); } return apos.afterYield(callback); }, select: function(data, name, $field, $el, field, callback) { var $options = $field.find('option'); // Synthesize options from the choices in the schema, unless // the frontend developer has chosen to do it for us if (!$options.length) { _.each(field.choices, function(choice) { var $option = $('<option></option>'); $option.text(choice.label); $option.attr('value', choice.value); $field.append($option); }); } if ((!data._id) && field.def) { $field.val(field.def); } else { // Always select the first item if no item is selected. // This is consistent with what most browsers do and works around // an issue with lister $field.val(((data[name] === undefined) && field.choices[0]) ? field.choices[0].value : data[name]); } return apos.afterYield(callback); }, integer: function(data, name, $field, $el, field, callback) { $field.val(data[name]); return apos.afterYield(callback); }, float: function(data, name, $field, $el, field, callback) { $field.val(data[name]); return apos.afterYield(callback); }, boolean: function(data, name, $field, $el, field, callback) { $field.val(data[name] ? '1' : '0'); return apos.afterYield(callback); }, joinByOne: function(data, name, $field, $el, field, callback) { // Since we can't use a regular name attribute for a div $field = self.findSafe($el, '[data-name="' + name + '"]'); if (!$field.length) { apos.log('Error: your new.html template for the ' + self.name + ' module does not have a snippetSelective call for the ' + name + ' join yet'); } var selectiveData = []; var id = data[field.idField]; if (id) { // Let jQuery selective call back for the details selectiveData.push(id); } // For now this is still correct on the browser side, getManager // always returns undefined for an index type var manager = aposPages.getManager(field.withType); var autocomplete = '/apos-pages/autocomplete?type=' + field.withType; if (manager) { autocomplete = manager._action + '/autocomplete'; } $field.selective({ limit: 1, data: selectiveData, source: autocomplete }); self.enhanceSelectiveWithSlugs($field); return apos.afterYield(callback); }, joinByOneReverse: function(data, name, $field, $el, field, callback) { // Not edited on the reverse side return apos.afterYield(callback); }, joinByArray: function(data, name, $field, $el, field, callback) { // Since we can't use a regular name attribute for a div $field = self.findSafe($el, '[data-name="' + name + '"]'); if (!$field.length) { apos.log('Error: your new.html template for the ' + self.name + ' module does not have a snippetSelective call for the ' + name + ' join yet'); } var selectiveData = []; // For now this is still correct on the browser side, getManager // always returns undefined for an index type var manager = aposPages.getManager(field.withType); var autocomplete = '/apos-pages/autocomplete?type=' + field.withType; if (manager) { autocomplete = manager._action + '/autocomplete'; } // The server knows the title of the joined things, while we know // about our relationship properties. Solve the puzzle by // passing selective plain old IDs, causing it to call back to its // source for the corresponding labels. Provide a custom source // that queries the server and then merges in the relationship fields. if (field.relationshipField) { var url = autocomplete; autocomplete = function(req, callback) { $.getJSON(url, req, function(results) { // This gives us "label" and "value", add the // relationship info and invoke the original callback _.each(results, function(result) { var relationship = data[field.relationshipField][result.value]; if (relationship) { _.extend(result, relationship); _.each(field.relationship, function(relField) { if (relField.type === 'boolean') { // Fix booleans to work as select elements expect result[relField.name] = result[relField.name] ? '1' : '0'; } }); } }); return callback(results); }); }; } $field.selective({ preventDuplicates: true, sortable: field.sortable, extras: !!field.relationship, data: data[field.idsField] || [], source: autocomplete }); self.enhanceSelectiveWithSlugs($field); return apos.afterYield(callback); }, joinByArrayReverse: function(data, name, $field, $el, field, callback) { // Not edited on the reverse side return apos.afterYield(callback); }, group: function(data, name, $field, $el, field, callback) { // Just a presentation thing return apos.afterYield(callback); }, date: function(data, name, $field, $el, field, callback) { $field.val(data[name]); apos.enhanceDate($field); if (field.legacy) { apos.enhanceDate(self.findField($el, field.legacy)); } return apos.afterYield(callback); }, time: function(data, name, $field, $el, field, callback) { if (data[name] && data[name].length) { // Revert to local time for editing $field.val(apos.formatTime(data[name])); } return apos.afterYield(callback); }, }; self.addFieldType = function(type) { self.displayers[type.name] = type.displayer; self.converters[type.name] = type.converter; }; // A convenience method for calling attention to errors in fields in your own // independent validation code. self.addError = function($el, name) { self.findSafe($el, '[data-name="' + name + '"]').addClass('apos-error'); }; // A convenience allowing you to scroll to the first error present, // if any. Not called automatically. You can call this when // convertFields passes an error or when your own validation code // has invoked addError(). self.scrollToError = function($el) { var $element = self.findSafe($el, '.apos-error'); if (!$element.length) { return; } var offset = $element.offset(); var scrollTop = offset.top - 100; $('html, body').scrollTop(scrollTop); $element.find('input,select,textarea').first().focus(); }; // 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 self.findSafe = function($el, sel) { return $el.find(sel).filter(function() { var $parents = $(this).parents(); var i; for (i = 0; (i < $parents.length); i++) { if ($parents[i] === $el[0]) { return true; } if ($($parents[i]).hasClass('apos-fieldset')) { return false; } } }); }; // 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) { $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) { inner += '<div class="apos-autocomplete-slug">' + item.slug + '</div>'; } inner += '</a>'; return $('<li class="apos-autocomplete-item">') .append(inner) .appendTo(ul); }; }; } // Instantiate the singleton var aposSchemas = new AposSchemas();