docs: 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) {
      schema = self.allowedSubset($el, schema);
  
      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 apos.afterYield(callback);
        }
  
        var displayer = self.displayers[field.type];
  
        return displayer(snippet, field.name, $field, $el, field, function() {
          if (field.autocomplete === false) {
            $field.attr('autocomplete', 'off');
          }
          if(field.required === true) {
            self.addError($el, field.name, true, true);
          }
          return apos.afterYield(callback);
        });
  
      }, afterPopulateFields);
  
      function afterPopulateFields() {
        callback(null);
      }
    };
  
    // Gather data from form elements and push it into properties of the data object,
    // as specified by the schema provided. The inverse of self.populateFields
    self.convertFields = function($el, schema, data, callback) {
      schema = self.allowedSubset($el, schema);
  
      self.findSafe($el, '[data-name]').removeClass('apos-error').removeClass('apos-error-required').removeClass('apos-error-in-advance').find('.apos-error-message').remove();
      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);
        }
        var $fieldset = self.findFieldset($el, field.name);
        if ($fieldset.hasClass('apos-hidden')) {
          // If this field is hidden, it is not reasonable to
          // expect the user to populate it. This allows "required"
          // and "showFields" to interact in a reasonable way. -Tom
          i++;
          return apos.afterYield(convertField);
        }
        return self.converters[field.type](data, field.name, $field, $el, field, function(err) {
          if (err) {
            self.addError($el, field.name, field.required);
            failing = field;
          }
          i++;
          return apos.afterYield(convertField);
        });
      }
      convertField();
    };
  
    self.allowedSubset = function($el, schema) {
      return _.filter(schema, function(field) {
        if (!field.permission) {
          return true;
        }
  
        if(apos.data.permissions[field.permission] || apos.data.permissions.admin) {
          return true;
        }
  
        var $fieldset = self.findFieldset($el, field.name);
        $fieldset.addClass('apos-schemas-restricted');
        return false;
      });
    };
  
    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);
          // Make sure slideshows, videos, etc. get their JS
          apos.enablePlayers($editView);
  
          // 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);
        // Make sure slideshows, videos, etc. get their JS
        apos.enablePlayers($editView);
        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]);
          result.id = $element.attr('data-id');
          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[field.idField])) {
          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 + '"]');
        $field.off('afterGetItem');
        // Implement custom relationship field types (tags)
        $field.on('afterGetItem', function(e, item, $item) {
          _.each(field.relationship || [], function(field) {
            if (field.type === 'tags') {
              var $tags = $item.findSafe('[data-name="' + field.name + '"]', '[data-selective]');
              item[field.name] = $tags.selective('get');
            }
          });
        });
  
        var info = $field.selective('get', { incomplete: true });
        if (field.relationshipsField) {
          data[field.idsField] = _.pluck(info, 'value');
          data[field.relationshipsField] = {};
          var relationship = {};
          _.each(info, function(e) {
            relationship[e.value] = _.omit(e, [ 'value', 'label' ]);
          });
          data[field.relationshipsField] = 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'));
        }
        if (field.max && (data[name].length > field.max)) {
          var $fieldset = self.findFieldset($el, name);
          $fieldset.addClass('apos-error-max');
          return apos.afterYield(_.partial(callback, 'max'));
        }
        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) {
        if (field.checkbox) {
          data[name] = $field.prop('checked');
        } else {
          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].length)) {
          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) {
        var $fieldset;
        if (field.select) {
          $fieldset = self.findFieldset($el, name);
          var year = $fieldset.find('[data-component="year"]').val();
          var month = $fieldset.find('[data-component="month"]').val();
          var day = $fieldset.find('[data-component="day"]').val();
          if (!(year || month || day)) {
            if (field.required) {
              return apos.afterYield(_.partial(callback, 'required'));
            }
            data[name] = '';
            return apos.afterYield(callback);
          }
          if (!(year && month && day)) {
            // You can't pick only *some* of the fields
            return apos.afterYield(_.partial(callback, 'required'));
          }
          data[name] = year + '-' + month + '-' + day;
          return apos.afterYield(callback);
        } else {
          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);
  
          // If the minimize option is passed to an array schema field, it
          // will automatically hide all element fields except for the first
          // one. The other fields will be revealed upon toggling the '+'
          // button or focussing the first field.
          if (field.minimize) {
            addOpenHandler($element);
            // Default state for existing elements is closed
            toggleOpen($element, self.findSafe($element, '[data-open-item]'), false);
          } else {
            alwaysOpen($element);
          }
  
          $element.attr('data-id', data[i].id);
  
          $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);
  
          if (field.minimize) {
            addOpenHandler($element);
            // Default state for new elements is expanded
            toggleOpen($element, self.findSafe($element, '[data-open-item]'), true);
          } else {
            alwaysOpen($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;
          });
        }
  
        function addOpenHandler($element) {
          var $open = self.findSafe($element, '[data-open-item]');
          $open.on('click', function() {
            toggleOpen($element, $(this));
            return false;
          });
        }
  
        function toggleOpen($element, $open, state) {
          var expanded = _.isBoolean(state) ? state : undefined
          $element.toggleClass('apos-array-item--open', expanded);
          self.findSafe($open, 'i').toggleClass('icon-minus', expanded);
          self.findSafe($element, '[data-name]:first-of-type input').focus(function() {
            toggleOpen($element, $open, true);
          });
        }
  
        function alwaysOpen($element) {
          var $open = self.findSafe($element, '[data-open-item]');
          toggleOpen($element, self.findSafe($element, '[data-open-item]'), true);
          $open.hide();
        }
  
      },
      string: function(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-error-max');
              $field.off('textchange.schema');
              $field.on('textchange.schema', function() {
                var length = $field.val().length;
                if (length > field.max) {
                  $fieldset.addClass('apos-error-max');
                } else {
                  $fieldset.removeClass('apos-error-max');
                }
              });
            }
          } else {
            $field.attr('maxlength', field.max);
          }
        }
        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], field || {});
        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 $fieldset = self.findFieldset($el, name);
        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);
          });
        }
  
        // Set the default value, taking selectize into account, ugh
        var val = ((data[name] === undefined) && field.choices[0]) ? field.choices[0].value : data[name];
        if ($field[0] && $field[0].selectize) {
         $field[0].selectize.setValue(val);
        } else {
         $field.val(val);
        }
  
        self.enableShowFields(data, name, $field, $el, field);
        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) {
        if (field.checkbox) {
          $field.prop('checked', data[name]);
        } else {
          $field.val(data[name] ? '1' : '0');
        }
  
        self.enableShowFields(data, name, $field, $el, field);
        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, nestGuard: '[data-selective]' });
  
        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');
        }
  
        // Implement custom relationship field types (tags)
        $field.off('afterAddItem');
        $field.on('afterAddItem', function(e, item, $item) {
          apos.emit('enhance', $item);
          _.each(field.relationship || [], function(field) {
            if (field.type === 'tags') {
              var $tags = $item.findSafe('[data-name="' + field.name + '"]', '[data-selective]');
              apos.enableTags($tags, item[field.name] || []);
            }
          });
        });
  
        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.relationshipsField) {
          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.relationshipsField] && data[field.relationshipsField][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);
            });
          };
        }
        var selectiveOptions = {
          preventDuplicates: true,
          sortable: field.sortable,
          extras: !!field.relationship,
          data: data[field.idsField] || [],
          source: autocomplete,
          nestGuard: '[data-selective]'
        };
        if (field.limit !== undefined) {
          selectiveOptions.limit = field.limit;
        }
        $field.selective(selectiveOptions);
        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) {
        var $fieldset;
        if (field.select) {
          $fieldset = self.findFieldset($el, name);
          var current = data[name];
          var components;
          var year, month, day;
          if (typeof(current) === 'string') {
            components = current.split(/\-/);
            year = components[0];
            month = components[1];
            day = components[2];
            $fieldset.find('[data-component="year"]').val(year);
            $fieldset.find('[data-component="month"]').val(month);
            $fieldset.find('[data-component="day"]').val(day);
          }
        } else {
          $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, required, inAdvance) {
      var $fieldset = self.findFieldset($el, name);
      $fieldset.addClass('apos-error');
      if (required === true) {
        $fieldset.addClass('apos-error-required');
        self.findSafe($el, '[data-name="' + name + '"]').find('label').append('<span class="apos-error-message"> * required</span>');
      }
      if (inAdvance) {
        $fieldset.addClass('apos-error-in-advance');
      }
    };
  
    // 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) {
      // Awesome plugin
  
      var $element = self.findSafe($el, '.apos-error');
      if (!$element.is(':visible')) {
        // Make sure parent tab is active
        var tabId = $element.closest('[data-tab-id]').attr('data-tab-id');
        if (tabId) {
          apos.activateTab($element, tabId);
        }
      }
      $element.scrollintoview();
      $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.findSafe(sel, '.apos-fieldset');
    };
  
    // 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) {
          inner += '<div class="apos-autocomplete-slug">' + item.slug + '</div>';
        }
        inner += '</a>';
        return $('<li class="apos-autocomplete-item">')
          .append(inner)
          .appendTo(ul);
      };
    };
  
    self.newInstance = function(schema) {
      var def = {};
      _.each(schema, function(field) {
        if (field.def !== undefined) {
          def[field.name] = field.def;
        }
      });
      return def;
    };
  
    // Enable the showFields option for the choices associated
    // with the given field (boolean or select)
  
    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();
        }
  
        var allShowFields = {};
  
        _.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 {
            // type select
            if (val === choice.value) {
              show = true;
            }
          }
  
          _.each(choice.showFields || [], function(fieldName) {
            if (!_.has(allShowFields, fieldName)) {
              allShowFields[fieldName] = false;
            }
            allShowFields[fieldName] = allShowFields[fieldName] || show;
          });
  
        });
  
        _.each(allShowFields, function(val, key) {
          var $fieldset = self.findFieldset($el, key);
          $fieldset.toggleClass('apos-hidden', !val);
          $fieldset.trigger('aposShowFields');
          var $helpText = $fieldset.next('p.apos-help');
          $helpText.toggleClass('apos-hidden', !val);
        });
  
      }
    };
  }