/* eslint no-unused-vars: "off" */
/**
* PredicateCore
* @module core
* @namespace core
* @since 1.0.0
* @note rules are 100% tested from PredicateCore.test.js
*/
const merge = require('ramda/src/merge');
const find = require('ramda/src/find');
const curry = require('ramda/src/curry');
const pipe = require('ramda/src/pipe');
const filter = require('ramda/src/filter');
const map = require('ramda/src/map');
const takeLast = require('ramda/src/takeLast');
const insert = require('ramda/src/insert');
const option = require('option');
const { EventEmitter } = require('events');
function head(list) {
return option.fromNullable(list[0]).value();
}
module.exports = function({ dataclasses, invariants, errors, rules, UITypes }) {
const { CompoundPredicate, ComparisonPredicate, Predicate } = dataclasses;
/**
* Get a type by its type_id
* @param {array} types types
* @param {string} type_id type id name
* @return {?dataclasses.Type} a Type
* @private
* @since 1.0.0
*/
const _getTypeById = (types, type_id) =>
option.fromNullable(find(type => type.type_id === type_id, types));
/**
* Get a target by its target_id
* @param {Array<dataclasses.Target>} targets targets
* @param {string} target_id target id name
* @return {Promise<dataclasses.Target, errors.Target_idMustReferToADefinedTarget>} resolved promise will yield a Target, rejected promise will yield `Target_idMustReferToADefinedTarget` if target_id was not found in `targets`
* @private
* @since 1.0.0
*/
const _getTargetById = (targets, target_id) =>
invariants.Target_idMustReferToADefinedTarget(
find(target => target.target_id === target_id, targets)
);
/**
* Get a logical type by its logicalType_id
* @param {Array<dataclasses.LogicalType>} logicalTypes logicalTypes
* @param {string} logicalType_id logicalType id name
* @return {Promise<dataclasses.LogicalType, errors.LogicalType_idMustReferToADefinedLogicalType>} resolved promise will yield a LogicalType, rejected promise will yield `LogicalType_idMustReferToADefinedLogicalType` if logicalType was not found in `logicalTypes`.
* @private
* @since 1.0.0
*/
const _getLogicalTypeById = (logicalTypes, logicalType_id) =>
invariants.LogicalType_idMustReferToADefinedLogicalType(
find(
logicalType => logicalType.logicalType_id === logicalType_id,
logicalTypes
)
);
/**
* Get an operator by its operator_id
* @param {Array<dataclasses.Operator>} operators operators
* @param {string[]} operator_id operator_id
* @return {Promise<dataclasses.Operator, errors.Operator_idMustReferToADefinedOperator>} resolved promise will yield an Operator, rejected promise will yield `Operator_idMustReferToADefinedOperator` if operator was not found in `operators`.
* @private
* @since 1.0.0
*/
const _getOperatorById = (operators, operator_id) =>
invariants.Operator_idMustReferToADefinedOperator(
find(operator => operator.operator_id === operator_id, operators)
);
/**
* _getOperatorsByIds
* @param {Object} operators operators
* @param {string[]} operator_ids operator_ids
* @return {Array<dataclasses.operator>}
* @private
* @since 1.0.0
*/
const _getOperatorsByIds = curry((operators, operator_ids) =>
pipe(filter(({ operator_id }) => operator_ids.includes(operator_id)))(
operators
)
);
const _set$operatorsToType = curry((columns, type) => {
type.$operators = _getOperatorsByIds(columns.operators, type.operator_ids);
return type;
});
const _set$typeToTarget = curry((columns, target) => {
const typeOption = _getTypeById(columns.types, target.type_id);
return invariants
.TargetMustReferToADefinedType(typeOption, target)
.then(type => {
target.$type = type;
return target;
});
});
/**
* Tap for Promise
* @param {Function} f function to call
* @return {Function} function that accept a promise and will call `f`
* @private
*/
const _tapPromise = f => {
return function(promise) {
return promise.then(result => {
f();
return result;
});
};
};
/**
* Run `fAfter()` (without any arguments) after `fBefore`, it will yield the promise yield from fBefore
* @param {Function} fBefore fBefore
* @param {Function} fAfter fAfter
* @return {Promise} promise from fBefore
* @private
*/
const _afterPromise = (fBefore, fAfter) => pipe(fBefore, _tapPromise(fAfter));
// columns => Promise[columns]
const _initializeColumns = columns => {
// at first I used lenses, but the code was way harder to read so it's better that way :)
// wrap operators
columns.operators = map(dataclasses.Operator, columns.operators);
// wrap logicalTypes
columns.logicalTypes = map(dataclasses.LogicalType, columns.logicalTypes);
// wrap argumentTypes (allow argumentTypes to be undefined)
columns.argumentTypes = map(
dataclasses.ArgumentType,
columns.argumentTypes || []
);
// wrap types and set `$operators` attribute on each type
const wrapType = pipe(dataclasses.Type, _set$operatorsToType(columns));
columns.types = map(wrapType, columns.types);
// wrap targets and set `$type` attribut on each target
const wrapTarget = pipe(dataclasses.Target, _set$typeToTarget(columns));
return Promise.all(map(wrapTarget, columns.targets)).then(targets => {
columns.targets = targets;
return columns;
});
};
/**
* Create a new PredicateCore
* @param {Object} args Predicate Core parameters
* @param {?dataclasses.CompoundPredicate} [args.data=core.defaults.options.getDefaultData] data
* @param {Object} [args.columns] columns definition
* @example <caption>Example of columns definition</caption>
* // don't forget to take a look at the Storybook https://ui-predicate.fgribreau.com/ui-predicate-vue/latest#/examples
* {
* // first define the operator list available along with what type of argument they need
* operators: [
* {
* operator_id: 'is',
* label: 'is',
* argumentType_id: 'smallString',
* },
* {
* operator_id: 'contains',
* label: 'Contains',
* argumentType_id: 'smallString',
* },
* {
* operator_id: 'isLowerThan',
* label: '<',
* argumentType_id: 'number',
* },
* {
* operator_id: 'isEqualTo',
* label: '=',
* argumentType_id: 'number',
* },
* {
* operator_id: 'isHigherThan',
* label: '>',
* argumentType_id: 'number',
* },
* {
* operator_id: 'is_date',
* label: 'is',
* argumentType_id: 'datepicker',
* },
* {
* operator_id: 'isBetween_date',
* label: 'is between',
* argumentType_id: 'daterangepicker',
* },
* ],
* // then define the type, think of them as aggregate of operators so you can attach them to targets
* types: [
* {
* type_id: 'int',
* operator_ids: ['isLowerThan', 'isEqualTo', 'isHigherThan'],
* },
* {
* type_id: 'string',
* operator_ids: ['is', 'contains'],
* },
* {
* type_id: 'datetime',
* operator_ids: ['is', 'isBetween'],
* },
* ],
* // finally define targets, don't forget to specify their associated `type_id`
* targets: [
* {
* target_id: 'title',
* label: 'Title',
* type_id: 'string',
* },
* {
* target_id: 'videoCount',
* label: 'Video count',
* type_id: 'int',
* },
* {
* target_id: 'publishedAt',
* label: 'Created at',
* type_id: 'datetime',
* },
* ],
* // define supported logical type
* logicalTypes: [
* {
* logicalType_id: 'any',
* label: 'Any',
* },
* {
* logicalType_id: 'all',
* label: 'All',
* },
* {
* logicalType_id: 'none',
* label: 'None',
* },
* ],
* // (optional) finally define how to display each argumentType_id
* argumentTypes: [
* // here we don't define `component` because it depends on the UI Framework you are using (e.g. Vue, React, Angular, ...)
* // since we are in ui-predicate-core here we don't know the UI Framework library that will be used
* // read your UI Framework adapter (e.g. ui-predicate-vue) on how to set the component.
* // if no argumentType is defined for argumentType_id, then UIPredicateCore will fallback on the default UI component (thanks to getDefaultArgumentComponent)
* // { argumentType_id: 'datepicker', component: ? },
* // { argumentType_id: 'daterangepicker', component: ? },
* // { argumentType_id: 'smallString', component: ? },
* // { argumentType_id: 'number', component: ? },
* ]}
* @param {Object} args.columns.operators operators
* @param {Object} args.columns.types types
* @param {Object} args.columns.targets targets
* @param {Object} args.columns.logicalTypes logicalTypes
* @param {?Object} args.columns.argumentTypes argumentTypes
* @param {Object} [args.ui] overriden core ui-predicate component (see UITypes and examples)
* @param {Object} [args.options=core.defaults.options] options
* @return {Promise<core.PredicateCoreAPI, errors<*>>} resolved promise yield Predicate Core public API, rejected promise yield an error {@link errors}
* @memberof core
*/
function PredicateCore(args) {
const { data, columns, ui, options } = args;
const _ui = ui || {};
return new Promise((resolve, reject) => {
try {
dataclasses._requireProps(
columns,
'operators,logicalTypes,types,targets'
);
} catch (err) {
reject(err);
return;
}
resolve();
})
.then(() => _initializeColumns(columns))
.then(_columns => {
let _root;
let _api;
const _events = new EventEmitter();
const _options = merge(PredicateCore.defaults.options, options);
/**
* Loop through the predicate tree and update flags (e.g. $canBeRemoved)
* @return {undefined} nothing.
* @private
*/
function _apply$flags() {
const canRemoveAnyPredicate = !rules.predicateToRemoveIsTheLastComparisonPredicate(
_root,
CompoundPredicate,
ComparisonPredicate
);
CompoundPredicate.forEach(_root, predicate => {
predicate.$canBeRemoved =
canRemoveAnyPredicate &&
!rules.predicateToRemoveIsRootPredicate(_root, predicate);
});
}
function _emitChangedEvent() {
_events.emit('changed', _api);
}
const _afterWrite = pipe(_apply$flags, _emitChangedEvent);
/**
* Set PredicateCore data
* @param {dataclasses.CompoundPredicate} root CompoundPredicate
* @return {Promise<undefined, errors.RootPredicateMustBeACompoundPredicate>} resolved promise yield nothing, rejected promise yield RootPredicateMustBeACompoundPredicate error
* @since 1.0.0
* @memberof core.api
*/
function setData(root) {
return invariants
.RootPredicateMustBeACompoundPredicate(root, CompoundPredicate)
.then(() => {
_root = root;
});
}
/**
* @param {object} json user defined JSON
* @return {Promise<Predicate, errors<*>>} resolved promise yield the root CompoundPredicate, rejected promise yield an errors
*/
function _fromJSON(json) {
return Predicate.fromJSON(json, {
getTargetById: target_id =>
_getTargetById(_columns.targets, target_id),
getLogicalTypeById: logicalType_id =>
_getLogicalTypeById(_columns.logicalTypes, logicalType_id),
getOperatorById: operator_id =>
_getOperatorById(_columns.operators, operator_id),
});
}
/**
* Add a ComparisonPredicate or CompoundPredicate
* @param {Object} option option object
* @param {string} options.type what type of Predicate to add
* @param {string} [options.how=after] should we insert it before, after or instead of? (currently only after is supported)
* @param {dataclasses.Predicate} options.where current element
* @return {Promise<dataclasses.Predicate>} inserted predicate
* @since 1.0.0
* @memberof core.api
*/
function add({ where, how = 'after', type }) {
// currently only after is supported
return (
Promise.resolve()
.then(() => invariants.AddOnlySupportsAfter(how))
.then(() =>
invariants.PredicateTypeMustBeValid(type, Predicate.Types)
)
// generate the Predicates
.then(() => _options[`getDefault${type}`](_columns, _options))
// then add it
.then(predicate => {
const isComparisonPredicate = ComparisonPredicate.is(where);
if (isComparisonPredicate || CompoundPredicate.is(where)) {
if (isComparisonPredicate) {
// it's a comparisonpredicate
// first find predicates array that contains the element
const path = _find(where);
// we are starting from a ComparisonPredicate that always live inside a CompoundPredicate.predicates array
const [compoundpredicate, [_, index]] = takeLast(2, path);
compoundpredicate.predicates = insert(
index + 1,
predicate,
compoundpredicate.predicates
);
} else {
// it's a compoundpredicate
// we want to add a CompoundPredicate after a compound predicate
// so we need to add it as its first .predicates entry
where.predicates.unshift(predicate);
}
return predicate;
}
return Promise.reject(
new errors.CannotAddSomethingElseThanACompoundPredicateOrAComparisonPredicate()
);
})
);
}
/**
* Remove a ComparisonPredicate or CompoundPredicate
* @param {(dataclasses.ComparisonPredicate|dataclasses.CompoundPredicate)} predicate predicate
* @return {Promise<dataclasses.Predicate>} yield the removed predicate, will reject the promise if remove was called with the root CompoundPredicate or the last ComparisonPredicate of the root CompoundPredicate
* @since 1.0.0
* @memberof core.api
*/
function remove(predicate) {
return Promise.resolve()
.then(() =>
invariants.RemovePredicateMustDifferFromRootPredicate(
_root,
predicate
)
)
.then(() =>
invariants.RemovePredicateCannotBeTheLastComparisonPredicate(
_root,
predicate,
CompoundPredicate,
ComparisonPredicate
)
)
.then(() => {
if (
CompoundPredicate.is(predicate) ||
ComparisonPredicate.is(predicate)
) {
const path = _find(predicate);
// we are starting from a ComparisonPredicate that always live
// inside a CompoundPredicate.predicates array
const [parentCompoundpredicate, [_, index]] = takeLast(2, path);
parentCompoundpredicate.predicates.splice(index, 1);
if (parentCompoundpredicate.predicates.length === 0) {
// if there are not any more predicates
// inside the parentCompoundpredicate, we should also remove it
return remove(parentCompoundpredicate);
}
return predicate;
}
return Promise.reject(
new errors.CannotRemoveSomethingElseThanACompoundPredicateOrAComparisonPredicate()
);
});
}
/**
* Change a CompoundPredicate logical
* @param {dataclasses.CompoundPredicate} predicate predicate
* @param {string} newLogicalType_id newLogicalType_id
* @return {Promise<undefined, errors.PredicateMustBeACompoundPredicate>} yield nothing if everything went right, otherwise yield a reject promise with the PredicateMustBeACompoundPredicate error
* @since 1.0.0
* @memberof core.api
*/
function setPredicateLogicalType_id(predicate, newLogicalType_id) {
return invariants
.PredicateMustBeACompoundPredicate(predicate, CompoundPredicate)
.then(() => {
// first change the logical type
return _getLogicalTypeById(
_columns.logicalTypes,
newLogicalType_id
);
})
.then(logicalType => {
predicate.logic = logicalType;
});
}
/**
* Change a predicate's target
* @param {dataclasses.ComparisonPredicate} predicate predicate
* @param {string} newTarget_id newTarget_id
* @return {Promise<undefined, errors.PredicateMustBeAComparisonPredicate>} yield nothing if everything went right, otherwise yield a reject promise with the PredicateMustBeAComparisonPredicate error
* @since 1.0.0
* @memberof core.api
*/
function setPredicateTarget_id(predicate, newTarget_id) {
return (
invariants
.PredicateMustBeAComparisonPredicate(
predicate,
ComparisonPredicate
)
// first change the target
.then(() => _getTargetById(_columns.targets, newTarget_id))
.then(target => {
predicate.target = target;
// then change the operator to the first operator for this target
return setPredicateOperator_id(
predicate,
head(predicate.target.$type.$operators).operator_id
);
})
);
}
/**
* Change a predicate's operator
* @param {dataclasses.ComparisonPredicate} predicate predicate
* @param {string} newOperator_id newOperator_id
* @return {Promise<undefined, errors.Operator_idMustReferToADefinedOperator>} yield nothing if everything went right, otherwise yield a reject promise with the PredicateMustBeAComparisonPredicate error
* @since 1.0.0
* @memberof core.api
*/
function setPredicateOperator_id(predicate, newOperator_id) {
return (
Promise.resolve()
// find operator
.then(() =>
invariants.Operator_idMustReferToADefinedOperator(
predicate.target.$type.$operators.find(
operator => operator.operator_id === newOperator_id
)
)
)
// change the operator
.then(operator => {
predicate.operator = operator;
// then reset arguments
predicate.argument = null;
})
);
}
/**
* Change a predicate's operator value
* @param {dataclasses.ComparisonPredicate} predicate predicate
* @param {*} newValue newValue
* @return {Promise<undefined>} yield nothing if everything went right, currently everything always go right ;)
* @since 1.0.0
* @memberof core.api
*/
function setArgumentValue(predicate, newValue) {
return Promise.resolve().then(() => {
predicate.argument = newValue;
});
}
/**
* Get a UI Component (e.g. Vue Component) based on the argumentType_id
* @param {string} argumentType_id the argumentType id to find
* @return {*} it will either yield the argumentType id associated component or fallback on {@link core.defaults.getArgumentTypeComponentById} to yield the default component
* @since 1.0.0
* @memberof core.api
*/
function getArgumentTypeComponentById(argumentType_id) {
return option
.fromNullable(
_columns.argumentTypes.find(
argumentType => argumentType.argumentType_id === argumentType_id
)
)
.map(argumentType => argumentType.component)
.valueOrElse(() =>
_options.getDefaultArgumentComponent(_columns, _options, _ui)
);
}
/**
* Get default or overrided ui component
* @param {ui} name the UIType key to get the right component
* @return {any} component
* @since 1.0.0
* @memberof core.api
*/
function getUIComponent(name) {
return ui[name];
}
/**
* Compute the JSON pointer path the element
* @param {Object} element (http://jsonpatch.com/)
* @return {?Array} null if not found
* @readonly
* @since 1.0.0
*/
function _find(element) {
return CompoundPredicate.reduce(
_root,
(acc, predicate, parents) => {
return element === predicate ? parents : acc;
},
null
);
}
/**
* Yield a serializable object without internal flags ($xxx properties)
* @example console.log(JSON.stringify(ctrl, null, 2)); // will call ctrl.toJSON() underneath
* @return {Object} a serializable object
* @since 1.0.0
* @memberof core.api
*/
function toJSON() {
return Predicate.toJSON(_root);
}
/**
* Adds the `listener` function to the end of the listeners array for the event named `eventName`.
* No checks are made to see if the `listener` has already been added. Multiple calls passing the same combination of eventName and listener will result in the listener being added, and called, multiple times.
* @param {string} eventName available event names are : (`changed`, api)
* @param {function} listener listener
* @return {undefined}
* @since 1.0.0
* @memberof core.api
*/
function on(eventName, listener) {
_events.on(eventName, listener);
}
/**
* Adds a *one-time* `listener` function for the event named `eventName`. The next time `eventName` is triggered, this `listener` is removed and then invoked.
* @param {string} eventName see {@link core.api.on} for available event names
* @param {function} listener listener
* @return {undefined}
* @see core.api.on
* @since 1.0.0
* @memberof core.api
*/
function once(eventName, listener) {
_events.once(eventName, listener);
}
/**
* Remove listener(s)
* If off() will remove every listeners
* If off(eventName) will only remove listeners to this specific eventName
* If off(eventName, listener) will remove the `listener` to the `eventName`
* @param {?string} eventName see {@link core.api.on} for available event names
* @param {?function} listener listener
* @return {undefined}
* @since 1.0.0
* @memberof core.api
*/
function off(eventName, listener) {
if (!eventName) {
// because removeAllListeners treat "undefined" as an event name :facepalm:
_events.removeAllListeners();
} else if (!listener) {
_events.removeAllListeners(eventName);
} else {
_events.removeListener(eventName, listener);
}
}
// get data for initialization
return (
(data ? _fromJSON(data) : _options.getDefaultData(_columns, _options))
// setup PredicateCore data
.then(_afterPromise(setData, _afterWrite))
// expose public API
.then(() => {
/**
* ui-predicate core public API
* @typedef {object} PredicateCoreAPI
* @namespace core.api
*/
_api = {
on,
once,
off,
setData: _afterPromise(setData, _afterWrite),
add: _afterPromise(add, _afterWrite),
remove: _afterPromise(remove, _afterWrite),
setPredicateTarget_id: _afterPromise(
setPredicateTarget_id,
_afterWrite
),
setPredicateOperator_id: _afterPromise(
setPredicateOperator_id,
_afterWrite
),
setPredicateLogicalType_id: _afterPromise(
setPredicateLogicalType_id,
_afterWrite
),
setArgumentValue: _afterPromise(setArgumentValue, _afterWrite),
getArgumentTypeComponentById,
/**
* Enumeration of overridable core ui-predicate component
* @enum {String}
*/
UITypes,
/**
* Get core UI component (e.g. target selector)
* @param {core.ui} ui component name
* @return {Object} component
* @memberof core.api
*/
getUIComponent,
toJSON,
/**
* Get root CompoundPredicate
* @return {dataclasses.CompoundPredicate} root CompoundPredicate
* @memberof core.api
*/
get root() {
return _root;
},
// used for testing
get columns() {
return _columns;
},
// used for testing
get options() {
return _options;
},
};
return _api;
})
);
});
}
/**
* Defaults configuration of PredicateCore
* @type {Object}
* @namespace core.defaults
*/
PredicateCore.defaults = {
/**
* Defaults options of PredicateCore
* @type {Object}
* @namespace core.defaults.options
*/
options: {
/**
* When data is not set at construction time PredicateCore default behavior will be to use the first target and its first operator with empty argument
* @param {Object} columns every necessary data class
* @param {Object} options PredicateCore available options
* @return {Promise<dataclasses.CompoundPredicate>} root CompoundPredicate
* @since 1.0.0
* @memberof core.defaults.options
*/
getDefaultData(columns, options) {
return options
.getDefaultComparisonPredicate(columns, options)
.then(comparisonPredicate => {
return options.getDefaultCompoundPredicate(columns, options, [
comparisonPredicate,
]);
});
},
/**
* Default compount predicate to use
*
* This function is called whenever a new CompoundPredicate is added to the UIPredicate
* @param {Object} columns specified columns
* @param {Object} options PredicateCore available options
* @param {Array<dataclasses.Predicate>} predicates array of predicates to include into the CompoundPredicate
* @return {Promise<dataclasses.CompoundPredicate>} a CompoundPredicate
* @since 1.0.0
* @memberof core.defaults.options
*/
getDefaultCompoundPredicate(columns, options, predicates) {
return (!Array.isArray(predicates) || predicates.length === 0
? options
.getDefaultComparisonPredicate(columns, options)
.then(comparisonPredicate => [comparisonPredicate])
: Promise.resolve(predicates)
).then(predicates =>
options
.getDefaultLogicalType(predicates, columns, options)
.then(logicalType => CompoundPredicate(logicalType, predicates))
);
},
/**
* Default comparison predicate to use
*
* This function is called whenever a new ComparisonPredicate is added to the UIPredicate
* @param {Object} columns specified columns
* @param {Object} [options=PredicateCore.defaults.options] PredicateCore available options
* @return {Promise<dataclasses.ComparisonPredicate>} a Comparison
* @since 1.0.0
* @memberof core.defaults.options
*/
getDefaultComparisonPredicate(columns, options) {
const firstTarget = head(columns.targets);
return ComparisonPredicate(
firstTarget,
head(firstTarget.$type.$operators)
);
},
/**
* Default logical type to use when a new comparison predicate is created
*
* This function is called whenever a new ComparisonPredicate is added to the UIPredicate
* @param {Array<dataclasses.Predicate>} predicates specified columns
* @param {Object} columns specified columns
* @param {Object} [options=PredicateCore.defaults.options] PredicateCore available options
* @return {Promise<dataclasses.LogicalType>} a logical type
* @since 1.0.0
* @memberof core.defaults.options
*/
getDefaultLogicalType(predicates, columns, options) {
return Promise.resolve(head(columns.logicalTypes));
},
/**
* Get the default UI component for any argument. Also used if no registered UI component match `argumentType_id`
* @param {Object} columns specified columns
* @param {Object} [options=PredicateCore.defaults.options] PredicateCore available options
* @return {*} yield a UI Component (depending on the UI Framework used)
* @throws if the UI Framework adapter did not override this function. Each UI Framework adapter (e.g. ui-predicate-vue, ui-predicate-react, ...) must implement this and let user override it
* @memberof core.defaults.options
*/
getDefaultArgumentComponent(columns, options) {
throw new errors.UIFrameworkMustImplementgetDefaultArgumentComponent(
'UIFrameworkMustImplementgetDefaultArgumentComponent'
);
},
},
};
return PredicateCore;
};