acklen-keystone
Version:
Web Application Framework and Admin GUI / Content Management System built on Express.js and Mongoose
359 lines (326 loc) • 10.6 kB
JavaScript
/*!
* Module dependencies.
*/
var _ = require('lodash');
var assign = require('object-assign');
var di = require('asyncdi');
var marked = require('marked');
var Path = require('../../lib/path');
var utils = require('keystone-utils');
var evalDependsOn = require('../utils/evalDependsOn.js');
var definePrototypeGetters = require('../utils/definePrototypeGetters.js');
var debug = require('debug')('keystone:fields:types:Type');
var DEFAULT_OPTION_KEYS = [
'path',
'paths',
'type',
'label',
'note',
'size',
'initial',
'required',
'col',
'noedit',
'nocol',
'nosort',
'indent',
'hidden',
'collapse',
'dependsOn',
'autoCleanup',
];
/**
* Field Constructor
* =================
*
* Extended by fieldType Classes, should not be used directly.
*
* @api public
*/
function Field (list, path, options) {
// Set field properties and options
this.list = list;
this._path = new Path(path);
this.path = path;
this.type = this.constructor.name;
this.options = _.defaults({}, options, this.defaults);
this.label = options.label || utils.keyToLabel(this.path);
this.typeDescription = options.typeDescription || this.typeDescription || this.type;
this.list.automap(this);
// Warn on required fields that aren't initial
if (this.options.required
&& this.options.initial === undefined
&& this.options.default === undefined
&& !this.options.value
&& !this.list.get('nocreate')
&& this.path !== this.list.mappings.name
) {
console.error('\nError: Invalid Configuration\n\n'
+ 'Field (' + list.key + '.' + path + ') is required but not initial, and has no default or generated value.\n'
+ 'Please provide a default, remove the required setting, or set initial: false to override this error.\n');
process.exit(1);
}
// if dependsOn and required, set required to a function for validation
if (this.options.dependsOn && this.options.required === true) {
var opts = this.options;
this.options.required = function () {
// `this` refers to the validating document
debug('validate dependsOn required', evalDependsOn(opts.dependsOn, this.toObject()));
return evalDependsOn(opts.dependsOn, this.toObject());
};
}
// Add the field to the schema
this.addToSchema(this.list.schema);
// Add pre-save handler to the list if this field watches others
if (this.options.watch) {
this.list.schema.pre('save', this.getPreSaveWatcher());
}
// Convert notes from markdown to html
var note = null;
Object.defineProperty(this, 'note', {
get: function () {
return (note === null) ? (note = (this.options.note) ? marked(this.options.note) : '') : note;
},
});
}
/**
* Gets the options for the Field, as used by the React components
*/
Field.prototype.getOptions = function () {
if (!this.__options) {
this.__options = {};
var optionKeys = DEFAULT_OPTION_KEYS;
if (_.isArray(this._properties)) {
optionKeys = optionKeys.concat(this._properties);
}
optionKeys.forEach(function (key) {
if (this[key]) {
this.__options[key] = this[key];
} else if (this.options[key]) {
this.__options[key] = this.options[key];
}
}, this);
if (this.getProperties) {
assign(this.__options, this.getProperties());
}
this.__options.hasFilterMethod = this.addFilterToQuery ? true : false;
this.__options.defaultValue = this.getDefaultValue();
}
return this.__options;
};
/**
* Validates and returns the size of the field.
* Defaults to deprecated 'width' option.
*/
Field.prototype.getSize = function () {
if (!this.__size) {
var size = this._fixedSize || this.options.size || this.options.width;
if (size !== 'small' && size !== 'medium' && size !== 'large' && size !== 'full') {
size = this._defaultSize || 'full';
}
this.__size = size;
}
return this.__size;
};
/**
* Gets default value for the field, based on the option or default for the type
*/
Field.prototype.getDefaultValue = function () {
return typeof this.options.default !== 'undefined' ? this.options.default : '';
};
/**
* Gets the field's data from an Item, as used by the React components
*/
Field.prototype.getData = function (item) {
return item.get(this.path);
};
/**
* Field watching implementation
*/
Field.prototype.getPreSaveWatcher = function () {
var field = this;
var applyValue;
if (this.options.watch === true) {
// watch == true means always apply the value method
applyValue = function () { return true; };
} else {
// if watch is a string, convert it to a list of paths to watch
if (typeof this.options.watch === 'string') {
this.options.watch = this.options.watch.split(' ');
}
if (typeof this.options.watch === 'function') {
applyValue = this.options.watch;
} else if (_.isArray(this.options.watch)) {
applyValue = function (item) {
var pass = false;
field.options.watch.forEach(function (path) {
if (item.isModified(path)) pass = true;
});
return pass;
};
} else if (_.isObject(this.options.watch)) {
applyValue = function (item) {
var pass = false;
_.forEach(field.options.watch, function (value, path) {
if (item.isModified(path) && item.get(path) === value) pass = true;
});
return pass;
};
}
}
if (!applyValue) {
console.error('\nError: Invalid Configuration\n\n'
+ 'Invalid watch value (' + this.options.watch + ') provided for ' + this.list.key + '.' + this.path + ' (' + this.type + ')');
process.exit(1);
}
if (typeof this.options.value !== 'function') {
console.error('\nError: Invalid Configuration\n\n'
+ 'Watch set with no value method provided for ' + this.list.key + '.' + this.path + ' (' + this.type + ')');
process.exit(1);
}
return function (next) {
if (!applyValue(this)) {
return next();
}
di(field.options.value).call(this, function (err, val) {
if (err) {
console.error('\nError: '
+ 'Watch set with value method for ' + field.list.key + '.' + field.path + ' (' + field.type + ') throws error:' + err);
} else {
this.set(field.path, val);
}
next();
}.bind(this));
};
};
module.exports = Field;
/** Getter properties for the Field prototype */
definePrototypeGetters(Field, {
size: function () { return this.getSize(); },
initial: function () { return this.options.initial || false; },
required: function () { return this.options.required || false; },
note: function () { return this.options.note || ''; },
col: function () { return this.options.col || false; },
noedit: function () { return this.options.noedit || false; },
nocol: function () { return this.options.nocol || false; },
nosort: function () { return this.options.nosort || false; },
collapse: function () { return this.options.collapse || false; },
hidden: function () { return this.options.hidden || false; },
dependsOn: function () { return this.options.dependsOn || false; },
});
/**
* Default method to register the field on the List's Mongoose Schema.
* Overridden by some fieldType Classes
*/
Field.prototype.addToSchema = function (schema) {
var ops = (this._nativeType) ? _.defaults({ type: this._nativeType }, this.options) : this.options;
schema.path(this.path, ops);
this.bindUnderscoreMethods();
};
/**
* Binds the methods specified by the _underscoreMethods property
* Must be called by the field type's `addToSchema` method
* Always includes the `update` method
*/
Field.prototype.bindUnderscoreMethods = function () {
var field = this;
(this._underscoreMethods || []).concat({ fn: 'updateItem', as: 'update' }).forEach(function (method) {
if (typeof method === 'string') {
method = { fn: method, as: method };
}
if (typeof field[method.fn] !== 'function') {
throw new Error('Invalid underscore method (' + method.fn + ') applied to ' + field.list.key + '.' + field.path + ' (' + field.type + ')');
}
field.underscoreMethod(method.as, function () {
var args = [this].concat(Array.prototype.slice.call(arguments));
return field[method.fn].apply(field, args);
});
});
};
/**
* Adds a method to the underscoreMethods collection on the field's list,
* with a path prefix to match this field's path and bound to the document
*/
Field.prototype.underscoreMethod = function (path, fn) {
this.list.underscoreMethod(this.path + '.' + path, function () {
return fn.apply(this, arguments);
});
};
/**
* Default method to format the field value for display
* Overridden by some fieldType Classes
*
* @api public
*/
Field.prototype.format = function (item) {
var value = item.get(this.path);
if (value === undefined) return '';
return value;
};
/**
* Default method to detect whether the field has been modified in an item
* Overridden by some fieldType Classes
*
* @api public
*/
Field.prototype.isModified = function (item) {
return item.isModified(this.path);
};
/**
* Checks whether a provided value for the field is in a valid format
* Overridden by some fieldType Classes
*
* @api public
*/
Field.prototype.validateInput = function (data, callback) {
utils.defer(callback, this.inputIsValid(data));
};
/**
* Validates that a value for this field has been provided in a data object,
* taking into account existing data in an item
* Overridden by some fieldType Classes
*
* @api public
*/
Field.prototype.validateRequiredInput = function (item, data, callback) {
utils.defer(callback, this.inputIsValid(data, true, item));
};
/**
* Validates that a value for this field has been provided in a data object
* Overridden by some fieldType Classes
*
* Not a reliable public API; use inputIsValid, which is async, instead.
* This method has been deprecated.
*/
Field.prototype.inputIsValid = function (data, required, item) {
if (!required) return true;
var value = this.getValueFromData(data);
if (value === undefined && item && item.get(this.path)) return true;
if (typeof data[this.path] === 'string') {
return (data[this.path].trim()) ? true : false;
} else {
return (data[this.path]) ? true : false;
}
};
/**
* Updates the value for this field in the item from a data object
* Overridden by some fieldType Classes
*
* @api public
*/
Field.prototype.updateItem = function (item, data, callback) {
var value = this.getValueFromData(data);
// This is a deliberate type coercion so that numbers from forms play nice
if (value !== undefined && value != item.get(this.path)) { // eslint-disable-line eqeqeq
item.set(this.path, value);
}
process.nextTick(callback);
};
/**
* Retrieves the value from an object, whether the path is nested or flattened
*
* @api public
*/
Field.prototype.getValueFromData = function (data, subpath) {
return this._path.get(data, subpath);
};