var {
pick,
omit,
getByPath,
isEqual,
extend,
curry,
mapObj,
isUndefined,
unique
} = require('./utils')
/**
* Allows for creating and manipulating changesets. As an entry point to create changeset use cast function.
* @namespace changeset
*/
module.exports = mapObj(
{
cast,
getChange,
putChange,
getField,
putError,
getErrors,
merge,
castAssoc,
traverseErrors,
applyChanges,
deleteChange,
applyAction,
change
},
(key, fn) => curry(fn)
)
/**
* Casts association with the changeset parameters.
* @function cast
* @memberof changeset
* @param {Object} data - entity model data
* @param {Object} attrs - changes
* @param {Array} params - changes to pick
* @example cast({id: 1, title: 'title'}, {title: 'new title', body: 'body'}, ['title', 'body'])
* @returns {Object} changeset
*/
function castAssoc({ field, change }, attrs, changeset) {
var assoc = change(changeset.data[field] || {}, attrs || {})
if (!Object.keys(assoc.changes).length) return changeset
var changes = {}
changes[field] = assoc
changes = extend(changeset.changes, changes)
return extend(changeset, {
changes,
valid: changeset.valid && assoc.valid
})
}
/**
* Gets change from the changeset.
* @function getChange
* @memberof changeset
* @param field {String} - name of the change field
* @param changeset {Object} - changeset with expected change
* @returns {Array} Success/Error Array tuple with the change value
* @example
* // when change exists
* getChange('title', changeset) === [true, 'some title']
* // when change does not exist
* getChange('title', changeset) === [false, null]
*/
function getChange(field, changeset) {
var change = getByPath(['changes', field], changeset)
if (isUndefined(change)) return [false, null]
return [true, change]
}
/**
* Puts change into changeset for the given field
* @function putChange
* @memberof changeset
* @param field {String} - name of the change field
* @param change {Any} - value of the change
* @param changeset {Object} - target changeset
* @returns {Object} changeset with the given change
*/
function putChange(field, change, changeset) {
var changes = {}
changes[field] = change
changes = extend(changeset.changes, changes)
return extend(changeset, { changes })
}
/**
* Gets field from the changeset.
* @function getField
* @memberof changeset
* @param field {String} - name of the field
* @param changeset {Object} - changeset with expected field
* @returns {Array} Success/Error Array tuple with the field value
* @example
* // when field exists
* getField('title', changeset) === [true, 'some title']
* // when field does not exist
* getField('title', changeset) === [false, null]
*/
function getField(field, changeset) {
var value = getByPath(['data', field], changeset)
if (isUndefined(value)) return [false, null]
return [true, value]
}
/**
* Gets errors from the changeset for the given field.
* @function getErrors
* @memberof changeset
* @param field {String} - name of the field
* @param changeset {Object} - changeset with expected errors
* @returns {Array} field errors
*/
function getErrors(field, changeset) {
var errors = changeset.errors[field] || []
return [...errors]
}
/**
* Puts error to the changeset for given field
* @function putError
* @memberof changeset
* @param field {String} - name of the field
* @param error {Any} - works with any data, but preferably object with message:String property
* @param changeset {Object} - target changeset
* @returns {Object} changeset with given errors
*/
function putError(field, error, changeset) {
var errorArr = changeset.errors[field] || []
errorArr = [...errorArr, error]
var errors = {}
errors[field] = errorArr
errors = extend(changeset.errors, errors)
return extend(changeset, { errors, valid: false })
}
/**
* Deletes change from the changeset
* @function deleteChange
* @memberof changeset
* @param field {String} - name of the field
* @changeset {Object} - target changeset
* @returns {Object} changeset without given change
*/
function deleteChange(field, changeset) {
var changes = omit([field], changeset.changes)
var errors = omit([field], changeset.errors)
return extend(changeset, { changes, errors })
}
/**
* Applies action to the changeset only if the changes are valid.
* If the changes are valid all changes will be merged with the data model.
* @function applyAction
* @memberof changeset
* @param changeset {Object} - target changeset
* @param action {String} - action name
* @returns {Array} Success/Error Array tuple
* @example
* // with valid changes
* applyChanges(oldChangeset, 'INSERT') === [true, oldChangeset]
* oldChangeset.action === null
* // with invalid changes
* applyChanges(oldChangeset, 'INSERT') === [false, newChangeset]
* newChangeset.action === 'INSERT'
*/
function applyAction(changeset, action) {
if (!changeset.valid) return [false, changeset]
var data = applyChanges(changeset)
return [true, extend(cast(data, {}, []), { action })]
}
/**
* Applies changes to the given changeset.
* @function change
* @memberof changeset
* @param change {Function} - cast function with signature (data:{Object}, attrs:{Object}) -> changeset
* @param changeset {Object} - target changeset
* @param attrs {Object} - changes to apply
*/
function change(change, changeset, attrs) {
var newChangeset = change(changeset.data, attrs)
return merge(changeset, newChangeset)
}
/**
* Applies properties as changes for the data model according to the set of keys.
* @function cast
* @memberof changeset
* @param {Object} data - entity model data
* @param {Object} attrs - changes
* @param {Array} params - changes to pick
* @example cast({id: 1, title: 'title'}, {title: 'new title', body: 'body'}, ['title', 'body'])
* @returns {Object} changeset
*/
function cast(data, attrs, fields) {
var diff = fields.filter(field => !isEqual(data[field], attrs[field]))
var changes = pick(diff, attrs)
return {
data,
changes,
errors: {},
valid: true,
action: null
}
}
/**
* Traverses through changeset errors and associations. With the given transform function will apply it to the error messages.
* @function traverseErrors
* @memberof changeset
* @param changeset {Object} - target changeset
* @param fn {Function} - transform function
* @returns {Object} object with changeset errors corresponding to the changeset graph
* @example
* traverseErrors(changeset, function transform(field, opts){
* return `${field}: ${opts.message}`
* })
*/
function traverseErrors(changeset, fn = null) {
var { errors, changes } = changeset
var keys = Object.keys(changes)
return keys.reduce((cur, key) => {
var error = errors[key]
var change = changes[key]
if (isChangeset(change)) {
cur[key] = traverseErrors(change, fn)
return cur
}
if (isUndefined(error)) return cur
if (fn) {
cur[key] = error.map(opts => fn(key, opts))
return cur
}
cur[key] = error.map(opts => opts.message)
return cur
}, {})
}
/**
* Applies changes to the data model regardless if the changes are valid or not.
* @function applyChanges
* @memberof changeset
* @param changeset {Object} - target changeset
* @returns {Object} data
*/
function applyChanges(changeset) {
var { data, changes } = changeset
var keys = unique([...Object.keys(data), ...Object.keys(changes)])
return keys.reduce((cur, key) => {
var change = changes[key]
if (isChangeset(change)) {
cur[key] = applyChanges(change)
return cur
}
if (isUndefined(change)) {
cur[key] = data[key]
return cur
}
cur[key] = change
return cur
}, {})
}
/**
* Merges two changesets with the same data model.
* If data models are not equal, function will throw error.
* Changes, errors and action are merged into the second changeset.
* @function merge
* @memberof changeset
* @param from {Object} from - source changeset
* @param from {Object} to - target changeset
* @returns {Object} changeset
*/
function merge(from, to) {
if (!isEqual(from.data, to.data))
throw 'Different data when merging changesets'
var keys = Object.keys(to.changes)
return {
data: to.data,
changes: extend(from.changes, to.changes),
errors: extend(omit(keys, from.errors), to.errors),
valid: from.valid && to.valid
}
}
function isChangeset(obj) {
return (
obj &&
obj.hasOwnProperty('data') &&
obj.hasOwnProperty('changes') &&
obj.hasOwnProperty('errors') &&
obj.hasOwnProperty('valid')
)
}