Skip to content

Instantly share code, notes, and snippets.

@cpsubrian
Last active March 8, 2016 19:57
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save cpsubrian/3b6e1d7da1ba4bf5c7fc to your computer and use it in GitHub Desktop.
Save cpsubrian/3b6e1d7da1ba4bf5c7fc to your computer and use it in GitHub Desktop.
Immutable.Record for models
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
}
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') {}
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})
}
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