///
import * as _ from 'lodash'
import * as moment from 'moment'
export interface StateHistory
{
actionName : string
state : T
}
/**
* Store that hooks into actions
*
* Store holds all data and is the only class that should modify the data,
* anything that pulls data from the DataStore cannot modify it and should treat
* it as immutable
*/
class Store
{
/**
* Holds our state
*/
protected state : T = null
/**
* If state history is enabled, all state changes are saved here
*/
protected stateHistory : StateHistory[] = []
/**
* Hold our listeners, whenever the store's state changes, they will be
* notified, and sent the new state, and old state
*/
protected listeners : ((...any) => any)[] = []
/**
* High performance loads will only dispatch state updates on requestAnimationFrame
*/
protected highPerformance : boolean
/**
* Used to signify if the state is dirty and we should send a notify
*/
protected dirtyState : boolean
/**
*
*/
protected _nextState : T = null
/**
* Whether or not we are in debug mode
*/
public debug : boolean = false
constructor()
{
this.highPerformance = false
this.dirtyState = false
this.listen = this.listen.bind(this)
this.ignore = this.ignore.bind(this)
this.stateChange = this.stateChange.bind(this)
this.newState = this.newState.bind(this)
this.nextState = this.nextState.bind(this)
this.cloneState = this.cloneState.bind(this)
this.notify = this.notify.bind(this)
this.upsertItem = this.upsertItem.bind(this)
this.removeItem = this.removeItem.bind(this)
this.removeItems = this.removeItems.bind(this)
}
/**
* Listen on a given event
*/
public listen(callback : ((...args : any[]) => any))
{
this.listeners.push(callback)
}
/**
* Return our current state
*/
public getState() : T
{
return this.state
}
/**
* Ignore an event we are listening on
*/
public ignore(callback : ((...args : any[]) => any)) : boolean
{
let index = this.listeners.indexOf(callback)
if(index >= 0) {
this.listeners.splice(index, 1)
return true
}
return false
}
/**
* Change the state
*/
public stateChange(actionName : string, nextState : T)
{
let oldState : any = {}
_.assign(oldState, this.state)
if(this.debug) {
this.stateHistory.push({
actionName: actionName,
state: oldState
})
}
this.state = nextState
this._nextState = null
if(!this.dirtyState) {
this.dirtyState = true
if(this.highPerformance) {
requestAnimationFrame(this.notify.bind(this, oldState))
} else {
this.notify(oldState)
}
}
return nextState
}
/**
* Clonse the current state
*/
public cloneState() : T
{
let clonedState : any = {}
_.assign(clonedState, this.state)
return clonedState
}
/**
* @deprecated use nextState
*/
public newState() : T
{
return this.nextState()
}
/**
* Return the next state (this is a WIP state that has not been sent to listeners)
*/
public nextState() : T
{
if(this._nextState)
{
return this._nextState
}
return this.cloneState()
}
/**
* Sends notification of state to given listeners
*/
protected notify(oldState : T)
{
if(this.debug) {
console.debug('Store state changed, notifying ' + this.listeners.length + ' listener(s) of change', 'Old State', oldState, 'New State', this.state)
}
this.listeners.forEach((listener) => {
listener(this.state, oldState)
})
this.dirtyState = false
}
/**
* Insert an item into the given modelArray, update it if it already exists
*/
public upsertItem(modelArray : any[], keyValue : any, newItem : any, overwrite : boolean = false) : boolean
{
var updated : boolean = false
if(typeof modelArray === 'undefined' || !Array.isArray(modelArray)) {
console.warn('Non array passed in as modelArray')
modelArray = []
}
if(typeof newItem !== 'object') {
console.warn('Upserted item must be an object', typeof newItem, 'given')
return false
}
if(typeof newItem['__bID'] !== 'undefined' && newItem['__bID'] !== keyValue) {
console.warn('Upserted item does not match the keyValue passed in', newItem['__bID'], '!=', keyValue)
return false
}
let existing = null
for(var i = 0; i < modelArray.length; i++) {
let item = modelArray[i]
if(item['__bID'] === keyValue) {
existing = i
break
}
}
if(existing === null) {
newItem['__bID'] = keyValue
modelArray.push(newItem)
} else {
let existingItem = modelArray[existing]
modelArray[existing] = overwrite ? newItem : this.merge(existingItem, newItem)
modelArray[existing]['__bID'] = keyValue
}
return true
}
/**
* Get an item from a modelArray
*/
public getItem(modelArray : any[], keyValue : any) : any
{
let existing = null
for(var i = 0; i < modelArray.length; i++) {
let item = modelArray[i]
if(item['__bID'] === keyValue) {
return item
}
}
return null
}
/**
* Remove an item from a modelArray
*/
public removeItem(modelArray : any[], keyValue : any) : any[]|boolean
{
let existing = null
for(var i = 0; i < modelArray.length; i++) {
let item = modelArray[i]
if(item['__bID'] === keyValue) {
existing = i
break
}
}
if(existing === null) {
return false
}
return modelArray.splice(existing, 1)
}
/**
* Pass in an array of keyValues and remove all items that match
*/
public removeItems(modelArray : any[], keyValues : any[])
{
keyValues.forEach((keyValue) => {
this.removeItem(modelArray, keyValue)
})
}
/**
* Sanitize and valide the given object to the given schema
*/
public sanitizeAndValidate(obj : any, schema : any)
{
var model = this.sanitize(obj, schema, false)
var validation = this.validate(model, schema)
if(validation === true) {
return model
}
return validation
}
/**
* Validate the given object to the given schema, will return an array
* of errors, or true if valid
*/
public validate(obj : any, schema : any) : any[]|boolean
{
var errors : string[] = []
for(var field in schema) {
if(typeof(schema[field].validation) !== 'undefined') {
for(var validation in schema[field].validation) {
if(errors.length > 0) {
break
}
var value = obj[field]
let validationParameters = schema[field].validation[validation]
//validate sub objects if marked as should validate
if(schema[field].type === 'object' && validation === 'validate' && validationParameters) {
let subErrors = this.validate(value, schema[field].schema())
if(subErrors !== true) {
errors = errors.concat(subErrors as string[])
break
}
}
var label = schema[field].label ? schema[field].label : field
switch(validation) {
case 'required':
if(typeof(value) === 'undefined' || value === null || value === '') {
errors.push(label + ' is required')
}
break
case 'minLength':
if(value.length < validationParameters) {
errors.push(label + ' must be at least ' + validationParameters + ' characters')
}
break
case 'maxLength':
if(value.length > validationParameters) {
errors.push(label + ' must be at under ' + validationParameters + ' characters')
}
break
default:
if(typeof(validationParameters) === 'function') {
var results = validationParameters(value)
if(results !== true) {
errors = errors.concat(results)
}
}
break
}
}
}
}
return errors.length > 0 ? errors : true
}
/**
* Sanitize the given object to a schema, also an optional parameter if
* you are sending the object as JSON, to format datetimes properly
*/
public sanitize(obj : any, schema : any, json : boolean = false) : any
{
var clean = {}
let tmp = {}
_.merge(tmp, obj)
for(var field in schema) {
clean[field] = this.sanitizeField(field, tmp, schema, json)
}
return clean
}
/**
* Merge objects together
*/
public merge(obj1 : any, obj2 : any)
{
_.merge(obj1, obj2)
return obj1
}
/**
* Creates a filter sort callback to sort by a given key
*/
public sortBy(key : string, dir : string = 'desc')
{
return (a, b) => {
if((b[key] && !a[key]) || (b[key] && a[key] && b[key] > a[key])) {
return dir.toLowerCase() === 'desc' ? 1 : -1
}
if((!b[key] && a[key]) || (b[key] && a[key] && b[key] < a[key])) {
return dir.toLowerCase() === 'desc' ? -1 : 1
}
if(b[key] && a[key] && b[key] == a[key]) {
return 0
}
if(b[key] && !a[key]) {
return 0
}
}
}
/**
* Formats a given number to two decimal places
*/
public money(value : number)
{
return value.toFixed(2)
}
/**
* Generates a UUID
*/
public uuid()
{
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
let r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8)
return v.toString(16)
})
}
public static string(params = {})
{
let tmp = {}
_.merge(tmp, {
type: 'string'
}, params)
return tmp
}
public static int(params = {})
{
let tmp = {}
_.merge(tmp, {
type: 'int'
}, params)
return tmp
}
public static double(params = {})
{
let tmp = {}
_.merge(tmp, {
type: 'double'
}, params)
return tmp
}
public static bool(params = {})
{
let tmp = {}
_.merge(tmp, {
type: 'bool'
}, params)
return tmp
}
public static float(params = {})
{
let tmp = {}
_.merge(tmp, {
type: 'float'
}, params)
return tmp
}
public static array (params = {})
{
let tmp = {}
_.merge(tmp, {
type: 'array',
schema: null
}, params)
return tmp
}
public static object (params = {})
{
let tmp = {}
_.merge(tmp, {
type: 'object',
schema: null
}, params)
return tmp
}
public static datetime (params = {})
{
let tmp = {}
_.merge(tmp, {
type: 'datetime',
schema: null,
format: 'YYYY-MM-DD HH:mm:ss'
}, params)
return tmp
}
public static callback(params = {})
{
let tmp = {}
_.merge(tmp, {
type: 'callback',
schema: null
}, params)
return tmp
}
public static customType(type, params = {})
{
let tmp = {}
_.merge(tmp, {
type: type,
schema: null
}, params)
return tmp
}
/**
* Sanitizes a field on an object to the given schema
*/
protected sanitizeField(field : string, obj : any, schema : any, json : boolean)
{
if(schema[field].type === 'function') {
return schema[field].value //return function
}
if(typeof(obj[field]) === 'undefined') {
//see if schema has a default
if(typeof(schema[field]['initial']) !== 'undefined') {
obj[field] = schema[field]['initial']()
} else {
obj[field] = null
}
}
var type = schema[field]['type']
if(obj[field] === null && type !== 'obj' && type !== 'object') {
return null
}
schema[field].field = field
switch(type) {
case 'int':
case 'integer':
return this.sanitizeInteger(obj[field], schema[field])
case 'float':
case 'double':
return this.sanitizeFloat(obj[field], schema[field])
case 'string':
case 'char':
case 'varchar':
return this.sanitizeString(obj[field], schema[field])
case 'date':
case 'datetime':
case 'timestamp':
return this.sanitizeDateTime(obj[field], schema[field], json)
case 'bool':
case 'boolean':
return this.sanitizeBoolean(obj[field], schema[field])
case 'obj':
case 'object':
return this.sanitizeObject(obj[field], schema[field], json)
case 'array':
case 'collection':
return this.sanitizeArray(obj[field], schema[field], json)
case 'callback':
return this.sanitizeCallback(obj[field], schema[field])
default:
if(typeof schema[field].sanitize !== 'undefined') {
return schema[field].sanitize(obj[field], schema[field])
}
break
}
}
protected sanitizeCallback(value : any, schemaConfig : any)
{
if(typeof value !== 'function') {
throw new Error('Provided callback is not a valid function')
}
return value
}
/**
* Sanitizes a field to an integer
*/
protected sanitizeInteger(value : any, schemaConfig : any)
{
if(typeof value === 'string') {
value = value.replace(/[a-zA-Z]+/gi, '')
if(value.length === 0) {
return value = ''
}
}
value = parseInt(value)
if(typeof(schemaConfig.min) !== 'undefined' && value < schemaConfig.min) {
throw new Error('Provided value cannot be sanitized, value is below minimum integer allowed')
}
if(typeof(schemaConfig.max) !== 'undefined' && value > schemaConfig.max) {
throw new Error('Provided value cannot be sanitized, value is greater than maximum integer allowed')
}
if(isNaN(value)) {
return value = ''
}
return value
}
/**
* Sanitizes a field to a float
*/
protected sanitizeFloat(value : any, schemaConfig : any)
{
value = parseFloat(value)
if(typeof(schemaConfig.min) !== 'undefined' && value < schemaConfig.min) {
throw new Error('Provided value cannot be sanitized, value is below minimum float allowed')
}
if(typeof(schemaConfig.max) !== 'undefined' && value > schemaConfig.max) {
throw new Error('Provided value cannot be sanitized, value is greater than maximum float allowed')
}
return value
}
/**
* Sanitizes a field to a string
*/
protected sanitizeString(value : any, schemaConfig : any)
{
value = String(value)
if(typeof(schemaConfig.minLength) !== 'undefined' && value.length < schemaConfig.minLength) {
throw new Error('Provided value cannot be sanitized, string length is below minimum allowed')
}
if(typeof(schemaConfig.maxLength) !== 'undefined' && value.length > schemaConfig.maxLength) {
//truncate and do a warning
console.warn('Value was truncated during sanitization')
value = value.substr(0, schemaConfig.maxLength)
}
return value
}
/**
* Sanitizes a field to a moment object
*/
protected sanitizeDateTime(value : any, schemaConfig : any, json : boolean) : any
{
if(typeof schemaConfig.utc === 'undefined' || schemaConfig.utc) {
var momentDate = moment.utc(value, schemaConfig.format)
} else {
var momentDate = moment(value, schemaConfig.format)
}
if(momentDate.isValid()) {
if(json) {
return momentDate.utc().format('YYYY-MM-DD hh:mm:ss')
}
return momentDate
}
throw new Error("Provided value ("+ value +") cannot be sanitized for field ("+ schemaConfig.field +"), is not a valid date")
}
/**
* Sanitizes a field to boolean
*/
protected sanitizeBoolean(value : any, schemaConfig : any)
{
if(value === false || value === true) {
return value
}
if(typeof(value) == 'string') {
if(value.toLowerCase().trim() === 'false') {
return false
}
if(value.toLowerCase().trim() === 'true') {
return true
}
}
if(parseInt(value) === 0) {
return false
}
if(parseInt(value) === 1) {
return true
}
throw new Error('Provided value cannot be santized, is not a valid boolean')
}
/**
* Sanitizes an object
*/
protected sanitizeObject(value : any, schemaConfig : any, json : boolean)
{
if(typeof(schemaConfig.schema) === 'undefined') {
throw new Error('Provided value cannot be santized, no reference schema provided for field type of object')
}
if(schemaConfig.schema === null) {
return value
}
if(value === null) {
return null
}
if(!json && typeof schemaConfig.constructor === 'function') {
return new schemaConfig.constructor(this.sanitize(value, schemaConfig.schema()))
}
return this.sanitize(value, schemaConfig.schema(), json)
}
/**
* Sanitizes an array of objects
*/
protected sanitizeArray(value : any, schemaConfig : any, json : boolean)
{
if(typeof(schemaConfig.schema) === 'undefined' || schemaConfig.schema === null || schemaConfig.schema === false) {
return value
}
if(typeof(value.length) === 'undefined') {
return [] //empty array
}
return value.map((v) => {
if(!json && typeof schemaConfig.constructor === 'function') {
return new schemaConfig.constructor(this.sanitize(v, schemaConfig.schema()))
}
return this.sanitize(v, schemaConfig.schema(), json)
})
}
}
export default Store