landmark-serve
Version:
Web Application Framework and Admin GUI / Content Management System built on Express.js and Mongoose
1,201 lines (925 loc) • 26.3 kB
JavaScript
var _ = require('underscore'),
async = require('async'),
moment = require('moment'),
landmark = require('../'),
schemaPlugins = require('./schemaPlugins'),
utils = require('landmark-utils'),
Field = require('./field'),
UpdateHandler = require('./updateHandler'),
queryfilterlib = require('queryfilter');
/**
* List Class
*
* @param {String} key
* @param {Object} options
* @api public
*/
function List(key, options) {
if (!(this instanceof List)) return new List(key, options);
this.options = utils.options({
schema: {
collection: landmark.prefixModel(key)
},
noedit: false,
nocreate: false,
nodelete: false,
autocreate: false,
sortable: false,
hidden: false,
searchFields: '__name__',
defaultSort: '__default__',
defaultColumns: '__name__'
}, options);
this.key = key;
this.path = this.get('path') || utils.keyToPath(key, true);
this.schema = new landmark.mongoose.Schema({}, this.options.schema);
this.uiElements = [];
this.underscoreMethods = {};
this.fields = {};
this.fieldTypes = {};
this.relationships = {};
this.mappings = {
name: null,
createdBy: null,
createdOn: null,
modifiedBy: null,
modifiedOn: null
};
// set mappings
_.each(this.options.map, function(val, key) { this.map(key, val); }, this);
Object.defineProperty(this, 'label', { get: function() {
return this.get('label') || this.set('label', utils.plural(utils.keyToLabel(key)));
}});
Object.defineProperty(this, 'singular', { get: function() {
return this.get('singular') || this.set('singular', utils.singular(this.label));
}});
Object.defineProperty(this, 'plural', { get: function() {
return this.get('plural') || this.set('plural', utils.plural(this.singular));
}});
Object.defineProperty(this, 'namePath', { get: function() {
return this.mappings.name || '_id';
}});
Object.defineProperty(this, 'nameField', { get: function() {
return this.fields[this.mappings.name];
}});
Object.defineProperty(this, 'nameIsVirtual', { get: function() {
return this.model.schema.virtuals[this.mappings.name] ? true : false;
}});
Object.defineProperty(this, 'nameIsEditable', { get: function() {
return (this.fields[this.mappings.name] && this.fields[this.mappings.name].type === 'text') ? !this.fields[this.mappings.name].noedit : false;
}});
Object.defineProperty(this, 'nameIsInitial', { get: function() {
return (this.fields[this.mappings.name] && this.fields[this.mappings.name].options.initial === undefined);
}});
var initialFields; // initialFields values are initialised once, on demand
Object.defineProperty(this, 'initialFields', { get: function() {
return initialFields || (initialFields = _.filter(this.fields, function(i) { return i.initial; }));
}});
}
/**
* Sets list options
*
* ####Example:
*
* list.set('test', value) // sets the 'test' option to `value`
*
* @param {String} key
* @param {String} value
* @api public
*/
List.prototype.set = function(key, value) {
if (arguments.length === 1) {
return this.options[key];
}
this.options[key] = value;
return value;
};
/**
* Gets list options
*
* ####Example:
*
* list.get('test') // returns the 'test' value
*
* @param {String} key
* @method get
* @api public
*/
List.prototype.get = List.prototype.set;
/**
* Maps a built-in field (e.g. name) to a specific path
*
* @api public
*/
List.prototype.map = function(field, path) {
if (path) {
this.mappings[field] = path;
}
return this.mappings[field];
};
/**
* Checks to see if a field path matches a currently unmapped path, and
* if so, adds a mapping for it.
*
* @api private
*/
List.prototype.automap = function(field) {
if (_.has(this.mappings, field.path) && !this.mappings[field.path]) {
this.map(field.path, field.path);
}
};
/**
* Adds one or more fields to the List
* Based on Mongoose's Schema.add
*
* @api public
*/
List.prototype.add = function() {
var add = function(obj, prefix) {
prefix = prefix || '';
var keys = Object.keys(obj);
for (var i = 0; i < keys.length; ++i) {
var key = keys[i];
if (null === obj[key]) {
throw new Error('Invalid value for schema path `'+ prefix + key +'`');
}
if (utils.isObject(obj[key]) && (!obj[key].constructor || 'Object' === obj[key].constructor.name) && (!obj[key].type || obj[key].type.type)) {
if (Object.keys(obj[key]).length) {
// nested object, e.g. { last: { name: String }}
// matches logic in mongoose/Schema:add
this.schema.nested[this.path] = true;
add(obj[key], prefix + key + '.');
} else {
addField(prefix + key, obj[key]); // mixed type field
}
} else {
addField(prefix + key, obj[key]);
}
}
}.bind(this);
var addField = function(path, options) {
this.uiElements.push({
type: 'field',
field: this.field(path, options)
});
}.bind(this);
_.each(arguments, function(def) {
if ('string' === typeof def) {
if (def === '>>>') {
this.uiElements.push({
type: 'indent'
});
} else if (def === '<<<') {
this.uiElements.push({
type: 'outdent'
});
} else {
this.uiElements.push({
type: 'heading',
heading: def,
options: {}
});
}
} else {
if (def.heading && 'string' === typeof def.heading) {
this.uiElements.push({
type: 'heading',
heading: def.heading,
options: def
});
} else {
add(def);
}
}
}, this);
return this;
};
/**
* Adds preset patterns of fields and behaviours to the Schema
*
* @api public
*/
List.prototype.addPattern = function(pattern) {
switch (pattern) {
case 'standard meta':
this.schema.add({
createdOn: { type: Date, 'default': Date.now, hidden: true, index: true },
updatedOn: { type: Date, 'default': Date.now, hidden: true, index: true }
});
this.schema.pre('save', function (next) {
if (!this.isModified('updatedOn')) {
this.updatedOn = new Date();
}
next();
});
break;
}
return this;
};
/**
* Creates a new field at the specified path, with the provided options.
* If no options are provides, returns the field at the specified path.
*
* @api public
*/
List.prototype.field = function(path, options) {
if (arguments.length === 1) {
return this.fields[path];
}
if ('function' === typeof options) {
options = { type: options };
}
if (this.get('noedit')) {
options.noedit = true;
}
if (!options.note && this.get('notes')) {
options.note = this.get('notes')[path];
}
if ('function' !== typeof options.type) {
throw new Error('Fields must be specified with a type function');
}
if (options.type.prototype.__proto__ !== Field.prototype) {
// Convert native field types to their default Landmark counterpart
if (options.type === String)
options.type = Field.Types.Text;
else if (options.type === Number)
options.type = Field.Types.Number;
else if (options.type === Boolean)
options.type = Field.Types.Boolean;
else if (options.type === Date)
options.type = Field.Types.Datetime;
else
throw new Error('Unrecognised field constructor: ' + options.type);
}
// Note the presence of this field type for client-side script optimisation
this.fieldTypes[options.type.name] = true;
// Wysiwyg HTML fields are handled as a special case so we can include TinyMCE as required
if (options.type.name === 'html' && options.wysiwyg) {
this.fieldTypes.wysiwyg = true;
}
var field = new options.type(this, path, options);
this.fields[path] = field;
return field;
};
/**
* Adds a method to the underscoreMethods collection on the list, which is then
* added to the schema before the list is registered with mongoose.
*
* @api public
*/
List.prototype.underscoreMethod = function(path, fn) {
var target = this.underscoreMethods;
path = path.split('.');
var last = path.pop();
path.forEach(function(part) {
if (!target[part]) target[part] = {};
target = target[part];
});
target[last] = fn;
return this;
};
/**
* Default Sort Field
*
* @api public
*/
Object.defineProperty(List.prototype, 'defaultSort', {
get: function() {
var ds = this.get('defaultSort');
return (ds === '__default__') ? (this.get('sortable') ? 'sortOrder' : this.namePath) : ds;
}, set: function(value) {
this.set('defaultSort', value);
}
});
/**
* Expands a comma-separated string or array of columns into valid column objects.
*
* Columns can be:
* - A Field, in the format "field|width"
* - A Field in a single related List, in the format "list:field|width"
* - Any valid path in the Schema, in the format "path|width"
*
* The width part is optional, and can be in the format "n%" or "npx".
*
* The path __name__ is automatically mapped to the namePath of the List.
*
* The field or path for the name of the item (defaults to ID if not set or detected)
* is automatically prepended if not explicitly included.
*
* @api private
*/
List.prototype.expandColumns = function(cols) {
if (typeof cols === 'string') {
cols = cols.split(',');
}
if (!Array.isArray(cols)) {
throw new Error('List.expandColumns: cols must be an array.');
}
var list = this,
expanded = [],
nameCol = false;
var getCol = function(def) {
if (def.path === '__name__') {
def.path = list.namePath;
}
var field = list.fields[def.path],
col = null;
if (field) {
col = {
field: field,
path: field.path,
type: field.type,
label: def.label || field.label
};
if (col.type === 'relationship') {
col.refList = col.field.refList;
if (col.refList) {
col.refPath = def.subpath || col.refList.namePath;
col.subField = col.refList.fields[col.refPath];
col.populate = { path: col.field.path, subpath: col.refPath };
}
if (!def.label && def.subpath) {
col.label = field.label + ': ' + (col.subField ? col.subField.label : utils.keyToLabel(def.subpath));
}
}
} else if (list.model.schema.paths[def.path] || list.model.schema.virtuals[def.path]) {
// column refers to a path in the schema
// TODO: this needs to handle sophisticated types, including arrays, nested Schemas, and mixed types
col = {
path: def.path,
label: def.label || utils.keyToLabel(def.path)
};
}
if (col) {
col.width = def.width;
if (col.path === list.namePath) {
col.isName = true;
nameCol = col;
}
if (field && field.col) {
_.extend(col, field.col);
}
}
return col;
};
for (var i = 0; i < cols.length; i++) {
var def = {};
if (typeof cols[i] === 'string') {
var parts = cols[i].trim().split('|');
def.width = parts[1] || false;
parts = parts[0].split(':');
def.path = parts[0];
def.subpath = parts[1];
}
if (!utils.isObject(def) || !def.path) {
throw new Error('List.expandColumns: column definition must contain a path.');
}
var col = getCol(def);
if (col) {
expanded.push(col);
}
}
if (!nameCol) {
nameCol = getCol({ path: list.namePath });
if (nameCol) {
expanded.unshift(nameCol);
}
}
return expanded;
};
/**
* Specified select and populate options for a query based the provided columns.
*
* @param {Query} query
* @param {Array} columns
* @api private
*/
List.prototype.selectColumns = function(q, cols) {
// Populate relationship columns
var select = [],
populate = {},
path;
cols.forEach(function(col) {
select.push(col.path);
if (col.populate) {
if (!populate[col.populate.path]) {
populate[col.populate.path] = [];
}
populate[col.populate.path].push(col.populate.subpath);
}
});
q.select(select.join(' '));
for (path in populate) {
if ( populate.hasOwnProperty(path) ) {
q.populate(path, populate[path].join(' '));
}
}
};
/**
* Default Column Fields
*
* @api public
*/
Object.defineProperty(List.prototype, 'defaultColumns', {
get: function() {
if (!this._defaultColumns) {
this._defaultColumns = this.expandColumns(this.get('defaultColumns'));
}
return this._defaultColumns;
}, set: function(value) {
this.set('defaultColumns', value);
delete this._defaultColumns;
}
});
/**
* Registers relationships to this list defined on others
*
* @api public
*/
List.prototype.relationship = function(def) {
if (arguments.length > 1) {
_.map(arguments, function(def) { this.relationship(def); }, this);
return this;
}
if ('string' === typeof def) {
def = { ref: def };
}
if (!def.ref) {
throw new Error('List Relationships must be specified with an object containing ref (' + this.key + ')');
}
if (!def.refPath) {
def.refPath = utils.downcase(this.key);
}
if (!def.path) {
def.path = utils.keyToProperty(def.ref, true);
}
Object.defineProperty(def, 'refList', {
get: function() {
return landmark.list(def.ref);
}
});
Object.defineProperty(def, 'isValid', {
get: function() {
return landmark.list(def.ref) ? true : false;
}
});
this.relationships[def.path] = def;
return this;
};
/**
* Registers the Schema with Mongoose, and the List with Landmark
*
* Also adds default fields and virtuals to the schema for the list
*
* @api public
*/
List.prototype.register = function() {
var list = this;
this.schema.virtual('list').get(function () {
return list;
});
if (this.get('sortable')) {
schemaPlugins.sortable.apply(this);
}
if (this.get('autokey')) {
schemaPlugins.autokey.apply(this);
}
if (!_.isEmpty(this.relationships)) {
this.schema.methods.getRelated = schemaPlugins.methods.getRelated;
this.schema.methods.populateRelated = schemaPlugins.methods.populateRelated;
if (!this.schema.options.toObject) this.schema.options.toObject = {};
this.schema.options.toObject.transform = schemaPlugins.options.transform;
}
this.schema.virtual('_').get(function() {
if (!this.__methods) {
this.__methods = utils.bindMethods(list.underscoreMethods, this);
}
return this.__methods;
});
this.schema.method('getUpdateHandler', function(req, res, ops) {
return new UpdateHandler(list, this, req, res, ops);
});
this.model = landmark.mongoose.model(this.key, this.schema);
require('../').list(this);
return this;
};
/**
* Gets the name of the provided document from the correct path
*
* ####Example:
*
* var name = list.getDocumentName(item)
*
* @param {Object} item
* @param {Boolean} escape - causes HTML entities to be encoded
* @api public
*/
List.prototype.getDocumentName = function(doc, escape) {
var name = (this.nameField) ? this.nameField.format(doc) : doc.get(this.namePath);
return (escape) ? utils.encodeHTMLEntities(name) : name;
};
/**
* Processes a filter string into a filters object
*
* @param {String} filters
* @api private
*/
List.prototype.processFilters = function(q) {
var me = this;
var filters = {};
queryfilterlib.QueryFilters.create(q).getFilters().forEach(function(filter){
filter.path = filter.key; // alias for b/c
filter.field = me.fields[filter.key];
filters[filter.path] = filter;
});
return filters;
};
/**
* Gets filters for a Mongoose query that will search for the provided string,
* based on the searchFields List option.
*
* Also accepts a filters object from `processFilters()`, any of which may
* override the search string.
*
* ####Example:
*
* list.getSearchFilters('mike') // returns { name: /mike/i }
*
* @param {String} query
* @param {Object} additional filters
* @api public
*/
List.prototype.getSearchFilters = function(search, add) {
var filters = {},
list = this;
search = String(search || '').trim();
if (search.length) {
var searchFilter,
searchParts = search.split(' '),
searchRx = new RegExp(utils.escapeRegExp(search), 'i'),
splitSearchRx = new RegExp((searchParts.length > 1) ? _.map(searchParts, utils.escapeRegExp).join('|') : search, 'i'),
searchFields = this.get('searchFields'),
searchFilters = [],
searchIdField = utils.isValidObjectId(search);
if ('string' === typeof searchFields) {
searchFields = searchFields.split(',');
}
searchFields.forEach(function(path) {
path = path.trim();
if (path === '__name__') {
path = list.mappings.name;
}
var field = list.fields[path];
if (field && field.type === 'name') {
var first = {};
first[field.paths.first] = splitSearchRx;
var last = {};
last[field.paths.last] = splitSearchRx;
searchFilter = {};
searchFilter.$or = [first, last];
searchFilters.push(searchFilter);
} else {
searchFilter = {};
searchFilter[path] = searchRx;
searchFilters.push(searchFilter);
}
});
if (list.autokey) {
searchFilter = {};
searchFilter[list.autokey.path] = searchRx;
searchFilters.push(searchFilter);
}
if (searchIdField) {
searchFilter = {};
searchFilter._id = search;
searchFilters.push(searchFilter);
}
if (searchFilters.length > 1) {
filters.$or = searchFilters;
} else if (searchFilters.length) {
filters = searchFilters[0];
}
}
if (add) {
_.each(add, function(filter) {
var cond, path = filter.key, value = filter.value;
switch (filter.field.type) {
case 'boolean':
if (value) {
filters[path] = true;
} else {
filters[path] = { $ne: true };
}
break;
case 'localfile':
case 'cloudinaryimage':
case 'cloudinaryimages':
case 's3file':
case 'name':
case 'password':
// TODO
break;
case 'location':
_.each({address:'street1', suburb:'suburb', state:'state', postcode:'postcode', country:'country'}, function(pathKey, valueKey){
var value = filter[valueKey];
if ( value ) {
filters[filter.field.paths[pathKey]] = new RegExp(utils.escapeRegExp(value), 'i');
}
});
break;
case 'relationship':
if (value) {
if (filter.field.many) {
filters[path] = (filter.inverse) ? { $nin: [value] } : { $in: [value] };
} else {
filters[path] = (filter.inverse) ? { $ne: value } : value;
}
} else {
if (filter.field.many) {
filters[path] = (filter.inverse) ? { $not: { $size: 0 } } : { $size: 0 };
} else {
filters[path] = (filter.inverse) ? { $ne: null } : null;
}
}
// TODO: Searching on "not linked to" (null) values seems to return all results.
// console.log(filter.field.path + ':');
// console.log(filters[filter.field.path]);
break;
case 'select':
if (filter.value) {
filters[path] = (filter.inverse) ? { $ne: value } : value;
} else {
filters[path] = (filter.inverse) ? { $nin: ['', null] } : { $in: ['', null] };
}
break;
case 'number':
case 'money':
if (filter.operator === 'bt') {
value = [
utils.number(value[0]),
utils.number(value[1])
];
if ( !isNaN(value[0]) && !isNaN(value[1]) ) {
filters[path] = {
$gte: value[0],
$lte: value[1]
};
}
else {
filters[path] = null;
}
}
else {
value = utils.number(value);
if ( !isNaN(value) ) {
if (filter.operator === 'gt') {
filters[path] = { $gt: value};
}
else if (filter.operator === 'lt') {
filters[path] = { $lt: value};
}
else {
filters[path] = value;
}
}
else {
filters[path] = null;
}
}
break;
case 'date':
case 'datetime':
if (filter.operator === 'bt') {
value = [
moment(value[0]),
moment(value[1])
];
if ( (value[0] && value[0].isValid()) && (value[1] && value[0].isValid()) ) {
filters[path] = {
$gte: moment(value[0]).startOf('day').toDate(),
$lte: moment(value[1]).endOf('day').toDate()
};
}
}
else {
value = moment(value);
if (value && value.isValid()) {
var start = moment(value).startOf('day').toDate();
var end = moment(value).endOf('day').toDate();
if (filter.operator === 'gt') {
filters[path] = { $gt: end };
} else if (filter.operator === 'lt') {
filters[path] = { $lt: start };
} else {
filters[path] = { $lte: end, $gte: start };
}
}
}
break;
case 'text':
case 'textarea':
case 'html':
case 'email':
case 'url':
case 'key':
if (filter.exact) {
if (value) {
cond = new RegExp('^' + utils.escapeRegExp(value) + '$', 'i');
filters[path] = filter.inverse ? { $not: cond } : cond;
} else {
if (filter.inverse) {
filters[path] = { $nin: ['', null] };
} else {
filters[path] = { $in: ['', null] };
}
}
} else if (value) {
cond = new RegExp(utils.escapeRegExp(value), 'i');
filters[path] = filter.inverse ? { $not: cond } : cond;
}
break;
}
});
}
// console.log(util.inspect(filters, { depth: null, colors: true }));
return filters;
};
/**
* Updates every document in a List,
* setting the provided data on each.
*
* @param {Object} data
* @param {Function} callback (optional)
* @api public
*/
List.prototype.updateAll = function(data, callback) {
if ('function' === typeof data) {
callback = data;
data = null;
}
callback = callback || function() {};
this.model.find(function(err, results) {
if (err) return callback(err);
async.eachSeries(results, function(doc, next) {
if (data) {
doc.set(data);
}
doc.save(next);
}, function(err) {
callback(err);
});
});
};
/**
* Gets a unique value from a generator method by checking for documents with the same value.
*
* To avoid infinite loops when a unique value cannot be found, it will bail and pass back an
* undefined value after 10 attemptes.
*
* WARNING: Because there will always be a small amount of time between checking for an
* existing value and saving a document, race conditions can occur and it is possible that
* another document has the 'unique' value assigned at the same time.
*
* Because of this, if true uniqueness is required, you should also create a unique index on
* the database path, and handle duplicate errors thrown on save.
*
* @param {String} path to check for uniqueness
* @param {Function} generator method to call to generate a new value
* @param {Number} the maximum number of attempts (optional, defaults to 10)
* @param {Function} callback(err, uniqueValue)
* @api public
*/
List.prototype.getUniqueValue = function(path, generator, limit, callback) {
var model = this.model,
count = 0,
value;
if (utils.isFunction(limit)) {
callback = limit;
limit = 10;
}
if (utils.isArray(generator)) {
var fn = generator[0],
args = generator.slice(1);
generator = function() {
return fn.apply(this, args);
};
}
var check = function() {
if (count++ > 10) {
return callback(undefined, undefined);
}
value = generator();
model.count().where(path, value).exec(function(err, matches) {
if (err) return callback(err);
if (matches) return check();
callback(undefined, value);
});
};
check();
};
/**
* Generate page array for pagination
*
* @param {Number} the maximum number pages to display in the pagination
* @param {Object} page options
* @api public
*/
List.prototype.getPages = function(options, maxPages) {
var surround = Math.floor(maxPages / 2),
firstPage = maxPages ? Math.max(1, options.currentPage - surround) : 1,
padRight = Math.max(((options.currentPage - surround) - 1) * -1, 0),
lastPage = maxPages ? Math.min(options.totalPages, options.currentPage + surround + padRight) : options.totalPages,
padLeft = Math.max(((options.currentPage + surround) - lastPage), 0);
options.pages = [];
firstPage = Math.max(Math.min(firstPage, firstPage - padLeft), 1);
for (var i = firstPage; i <= lastPage; i++) {
options.pages.push(i);
}
if (firstPage !== 1) {
options.pages.shift();
options.pages.unshift('...');
}
if (lastPage !== Number(options.totalPages)) {
options.pages.pop();
options.pages.push('...');
}
}
/**
* Gets a special Query object that will paginate documents in the list
*
* ####Example:
*
* list.paginate({
* page: 1,
* perPage: 100,
* maxPages: 10
* }).exec(function(err, results) {
* // do something
* });
*
* @param {Object} options
* @param {Function} callback (optional)
* @api public
*/
List.prototype.paginate = function(options, callback) {
var list = this, model = this.model;
options = options || {};
var query = model.find(options.filters);
query._original_exec = query.exec;
query._original_sort = query.sort;
query._original_select = query.select;
var currentPage = Number(options.page) || 1,
resultsPerPage = Number(options.perPage) || 50,
maxPages = Number(options.maxPages) || 10,
skip = (currentPage - 1) * resultsPerPage;
list.pagination = { maxPages: maxPages };
// as of mongoose 3.7.x, we need to defer sorting and field selection
// until after the count has been executed
query.select = function() {
options.select = arguments[0];
return query;
};
query.sort = function() {
options.sort = arguments[0];
return query;
};
query.exec = function(callback) {
query.count(function(err, count) {
if (err) return callback(err);
query.find().limit(resultsPerPage).skip(skip);
// apply the select and sort options before calling exec
if (options.select) {
query._original_select(options.select);
}
if (options.sort) {
query._original_sort(options.sort);
}
query._original_exec(function(err, results) {
if (err) return callback(err);
var totalPages = Math.ceil(count / resultsPerPage);
var rtn = {
total: count,
results: results,
currentPage: currentPage,
totalPages: totalPages,
pages: [],
previous: (currentPage > 1) ? (currentPage - 1) : false,
next: (currentPage < totalPages) ? (currentPage + 1) : false,
first: skip + 1,
last: skip + results.length
};
list.getPages(rtn, maxPages);
callback(err, rtn);
});
});
};
if (callback) {
return query(callback);
} else {
return query;
}
};
/*!
* Export class
*/
exports = module.exports = List;