Last active
March 8, 2016 19:57
-
-
Save cpsubrian/3b6e1d7da1ba4bf5c7fc to your computer and use it in GitHub Desktop.
Immutable.Record for models
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import _ from 'lodash' | |
import idgen from 'idgen' | |
import {List, Map, Record} from 'immutable' | |
import serialize from '../lib/serialize' | |
// Wraps a schema to provide the default values that an Immutable Record needs. | |
export default function CreateModel (schema, name = 'Model') { | |
// Create a 'defaults' object for the Record factory. | |
let defaults = _.reduce(schema, (result, opts, key) => { | |
return _.extend(result, { | |
[key]: _.isUndefined(opts.default) ? null : opts.default | |
}) | |
}, {}) | |
// Some defaults model-construction stuff. | |
class Model extends Record(defaults, name) { | |
// Store the schema as a static property. | |
static _schema = schema | |
// Iterate over a plain JS object, converting nested objects into sub-models | |
// according to the schema. | |
static modelize (data) { | |
return _.reduce(data, (result, val, key) => { | |
if (schema[key] && schema[key].model && _.isPlainObject(val)) { | |
let SubModel = schema[key].model | |
result[key] = new SubModel(val) | |
} else if (schema[key] && schema[key].models && _.isArray(val)) { | |
let SubModel = schema[key].models | |
result[key] = List(val.map((item) => new SubModel(item))) | |
} else if (schema[key] && schema[key].models && _.isPlainObject(val)) { | |
let SubModel = schema[key].models | |
result[key] = Map(_.mapValues(val, (item) => new SubModel(item))) | |
} else { | |
result[key] = val | |
} | |
return result | |
}, {}) | |
} | |
// Fill in missing submodel defaults. | |
static defaultize (data) { | |
return _.reduce(schema, (results, opts, key) => { | |
if (opts.model && !data[key] && !_.isUndefined(opts.default)) { | |
results[key] = opts.default | |
} | |
if (opts.models && !data[key] && !_.isUndefined(opts.default)) { | |
results[key] = opts.default | |
} | |
return results | |
}, _.clone(data)) | |
} | |
// Constructor override. | |
constructor (data) { | |
// Clone data to avoid changes by reference. | |
data = _.clone(data || {}) | |
// Fill in an id. | |
if (schema.id && !data.id) { | |
data.id = idgen() | |
} | |
// Fill in submodel defaults. | |
data = Model.defaultize(data) | |
// Convert submodels to model instances. | |
data = Model.modelize(data) | |
super(data) | |
} | |
// Serialize a model for transfer between client<-->server. | |
serialize (options = {}) { | |
return serialize(this) | |
} | |
} | |
// Add schema as a prototype property also. | |
Model.prototype._schema = schema | |
return Model | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Model from './model' | |
import {state} from './_fields' | |
export default class Signup extends Model({ | |
id: { | |
default: null, | |
validators: { | |
type: 'String' | |
} | |
}, | |
created: { | |
default: null | |
}, | |
campaignId: { | |
default: null, | |
validators: { | |
type: 'String' | |
} | |
}, | |
first: { | |
default: '', | |
label: 'First Name', | |
validators: { | |
presence: true, | |
type: 'String' | |
} | |
}, | |
last: { | |
default: '', | |
label: 'Last Name', | |
validators: { | |
presence: true, | |
type: 'String' | |
} | |
}, | |
email: { | |
default: '', | |
label: 'Email', | |
validators: { | |
presence: true, | |
type: 'String', | |
email: true | |
} | |
}, | |
address1: { | |
default: '', | |
label: 'Address 1', | |
validators: { | |
presence: true, | |
type: 'String' | |
} | |
}, | |
address2: { | |
default: '', | |
label: 'Address 2', | |
validators: { | |
type: 'String' | |
} | |
}, | |
city: { | |
default: '', | |
label: 'City', | |
validators: { | |
presence: true, | |
type: 'String' | |
} | |
}, | |
state: state, | |
zip: { | |
default: '', | |
label: 'Zip Code', | |
validators: { | |
presence: true, | |
type: 'String', | |
format: /\d{5}(-\d{4})?/ | |
} | |
}, | |
wallOptIn: { | |
default: false, | |
label: 'Opt-in to Walls', | |
validators: { | |
type: 'Boolean' | |
} | |
}, | |
emailOptIn: { | |
default: true, | |
label: 'Opt-in to Emails', | |
validators: { | |
type: 'Boolean' | |
} | |
}, | |
avatar: { | |
default: { | |
image: null, | |
video: null | |
}, | |
label: 'Picture' | |
}, | |
social: { | |
default: { | |
facebook: null, | |
twitter: null | |
} | |
} | |
}, 'Signup') {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import _ from 'lodash' | |
import transitImmutable from 'transit-immutable-js' | |
import {REHYDRATE} from '../actions' | |
// Import all models types. | |
import Campaign from '../models/campaign' | |
import CampaignStats from '../models/campaignStats' | |
import Cell from '../models/cell' | |
import Donation from '../models/donation' | |
import Feature from '../models/feature' | |
import Model from '../models/model' | |
import Panel from '../models/panel' | |
import Question from '../models/question' | |
import Row from '../models/row' | |
import Signup from '../models/signup' | |
import Target from '../models/target' | |
import User from '../models/user' | |
// 'Safe words' that sentry filters. DONT CHANGE ORDER, add to end. | |
export const SAFE_WORDS = [ | |
'password', | |
'secret', | |
'passwd', | |
'authorization', | |
'api_key', | |
'apikey', | |
'access_token' | |
] | |
// Create the transit-immutable-js API with support for our records. | |
export const transit = transitImmutable.withRecords([ | |
Campaign, | |
CampaignStats, | |
Cell, | |
Donation, | |
Feature, | |
Model, | |
Panel, | |
Question, | |
Row, | |
Signup, | |
Target, | |
User | |
]) | |
// Export a 'store enhancer' that responds to REHYDRATE actions. | |
export function rehydrator () { | |
// Rehydration reducer. | |
function createRehydrationReducer (reducer) { | |
return (state, action) => { | |
if (action.type === REHYDRATE) { | |
return { | |
...state, | |
...transit.fromJSON(action.json) | |
} | |
} else { | |
return reducer(state, action) | |
} | |
} | |
} | |
// Return the store enhancer. | |
return (createStore) => (reducer, initialState, enhancer) => { | |
const rehydrationReducer = createRehydrationReducer(reducer) | |
const store = createStore(rehydrationReducer, initialState, enhancer) | |
const dispatch = store.dispatch | |
return { | |
...store, | |
dispatch | |
} | |
} | |
} | |
export function dehydrate (data) { | |
let json = transit.toJSON(data || window.store.getState()) | |
// Encode 'safe words' so sentry doesn't filter. | |
// NOTE: We need make sure passwords and whatnot are not being sent. | |
return _.reduce(SAFE_WORDS, (result, word, i) => { | |
return result.replace(word, `__SW${i}__`) | |
}, json) | |
} | |
export function rehydrate (json) { | |
// Decode 'safe words'. | |
_.each(SAFE_WORDS, (word, i) => { | |
json = json.replace(`__SW${i}__`, word) | |
}) | |
// Dispatch. | |
window.store.dispatch({type: REHYDRATE, json}) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import _ from 'lodash' | |
import async from 'async' | |
import validatejs from 'validate.js' | |
import {sanitizeValues} from './sanitize' | |
export class ValidationError extends Error { | |
constructor (fields) { | |
let message = `Validation Error(s)` | |
super(message) | |
this.name = this.constructor.name | |
this.message = message | |
this.fields = fields | |
Error.captureStackTrace(this, this.constructor.name) | |
} | |
} | |
/** | |
* Validate a model. | |
*/ | |
export default function validate (model, options, cb) { | |
if (!model._schema) { | |
throw new Error('Model does not have a schema so cannot validate') | |
} | |
if (typeof options === 'function') { | |
cb = options | |
options = {} | |
} | |
let values = model.toJS() | |
let tasks = [] | |
// Top-level model. | |
tasks.push((next) => { | |
// Validate model. | |
validatejs.async(values, getValidators(model, options)).then( | |
// Success. | |
function () { | |
next(null, model) | |
}, | |
// Errors. | |
function (errors) { | |
if (errors instanceof Error) { | |
next(errors) | |
} else { | |
next(new ValidationError(errors)) | |
} | |
} | |
) | |
}) | |
// Sub-models. | |
_.each(values, (value, key) => { | |
if (value && value._schema) { | |
tasks.push((next) => { | |
validate(value, options, next) | |
}) | |
} | |
}) | |
// Run the tasks. | |
return new Promise((resolve, reject) => { | |
async.parallel(tasks, (err) => { | |
if (err) reject(err) | |
if (cb) cb(err, model) | |
resolve(model) | |
}) | |
}) | |
} | |
/** | |
* Validate sepcific values against a model (mostly for form validation). | |
* Optionally supports a callback or returns a promise (if cb === true). | |
*/ | |
export function validateValues (values, model, options = {}, cb) { | |
if (!model._schema) { | |
throw new Error('Model does not have a schema so cannot validate') | |
} | |
if (typeof options === 'function') { | |
cb = options | |
options = {} | |
} | |
if (options === true) { | |
options = {} | |
cb = true | |
} | |
let result = _.extend({}, | |
// Validate top-level values. | |
validatejs( | |
sanitizeValues(values, model), | |
_.pick(getValidators(model, options), _.keys(values)) | |
) || {}, | |
// Validate nested models. | |
_.reduce(values, (nestedValues, value, key) => { | |
if (model._schema[key] && model._schema[key].model && _.isObject(value)) { | |
nestedValues[key] = validateValues(value, model._schema[key].model, options) | |
} | |
return nestedValues | |
}, {}) | |
) | |
if (cb) { | |
if (typeof cb === 'function') { | |
cb(hasError(result) ? result : null) | |
} else { | |
if (hasError(result)) { | |
return Promise.reject(result) | |
} else { | |
return Promise.resolve() | |
} | |
} | |
} else { | |
return result | |
} | |
} | |
/** | |
* Returns true if validationResult contains at least 1 error. | |
*/ | |
export function hasError (validationResult) { | |
return _.some(validationResult, (value, key) => { | |
if (_.isObject(value)) return hasError(value) | |
return true | |
}) | |
} | |
/** | |
* Collect validators from each field in a model. | |
*/ | |
export function getValidators (model, options = {}) { | |
return _.reduce(model._schema, (validators, field, key) => { | |
if (field.validators) { | |
if (shouldValidateField(field, key, options)) { | |
validators[key] = field.validators | |
} | |
} | |
return validators | |
}, {}) | |
} | |
/** | |
* Parse options and decide if a field should be validated. | |
*/ | |
export function shouldValidateField (field, key, options = {}) { | |
if (options.op && options.op !== 'create' && field.createOnly) { | |
return false | |
} | |
return true | |
} | |
/** | |
* Type validator. | |
*/ | |
validatejs.validators.type = function (value, options, key, attributes) { | |
// Allow empty values by default (needs to be checked by "presence" check) | |
if (value === null || typeof value === 'undefined') { | |
return null | |
} | |
// Allow defining object of any type using their constructor. --> options = {clazz: ClassName} | |
if (typeof options === 'object' && options.clazz) { | |
return value instanceof options.clazz | |
? null | |
: ` is not of type "${options.clazz.name}"` | |
} | |
if (!validatejs.validators.type.checks[options]) { | |
throw new Error(`Could not find validator for type "${options}"`) | |
} | |
return validatejs.validators.type.checks[options](value) | |
? null | |
: ` is not of type "${options}"` | |
} | |
validatejs.validators.type.checks = { | |
'Object': (value) => { | |
return validatejs.isObject(value) && !validatejs.isArray(value) | |
}, | |
'Array': validatejs.isArray, | |
'Integer': validatejs.isInteger, | |
'Number': validatejs.isNumber, | |
'String': validatejs.isString, | |
'Date': validatejs.isDate, | |
'Boolean': (value) => { | |
return typeof value === 'boolean' | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment