plugins/restrictions.js

const base = require("./base");
const ripe = base.ripe;

/**
 * @class
 * @augments Plugin
 * @classdesc Plugin responsible for applying restrictions rules.
 *
 * @param {Object} rules A Map with restrictions rules to be applied.
 * If defined, overrides the rules defined on the model's config.
 * @param {Object} options An object with options to configure the plugin.
 */
ripe.Ripe.plugins.RestrictionsPlugin = function(restrictions, options = {}) {
    ripe.Ripe.plugins.Plugin.call(this);
    this.token = options.token || ":";
    this.restrictions = restrictions;
    this.restrictionsMap = this._buildRestrictionsMap(restrictions);
    this.manual = Boolean(options.manual || restrictions);
    this.auto = !this.manual;
    this.token = options.token || ":";
};

ripe.Ripe.plugins.RestrictionsPlugin.prototype = ripe.build(ripe.Ripe.plugins.Plugin.prototype);
ripe.Ripe.plugins.RestrictionsPlugin.prototype.constructor = ripe.Ripe.plugins.RestrictionsPlugin;

/**
 * The Restrictions Plugin binds the 'post_config' and 'part' events,
 * in order to:
 * - retrieve the model's configuration.
 * - change the necessary parts making them comply with the restrictions rules.
 *
 * @param {Ripe} The Ripe instance in use.
 */
ripe.Ripe.plugins.RestrictionsPlugin.prototype.register = function(owner) {
    ripe.Ripe.plugins.Plugin.prototype.register.call(this, owner);

    // runs the initial configuration, should take into account if
    // a valid configuration is currently loaded
    this._config();

    // registers for the config option so that it's possible to change
    // its value according to the newly generated configuration
    this._postConfigBind = this.manual
        ? null
        : this.owner.bind("post_config", () => this._config());

    // binds to the pre parts event so that the parts can be
    // changed so that they comply with the product's restrictions
    this._partBind = this.owner.bind("part", this._applyRestrictions.bind(this));
};

/**
 * The unregister to be called (by the owner)
 * the plugins unbinds events and executes
 * any necessary cleanup operation.
 *
 * @param {Ripe} The Ripe instance in use.
 */
ripe.Ripe.plugins.RestrictionsPlugin.prototype.unregister = function(owner) {
    this.partsOptions = null;
    this.options = null;
    this.owner && this.owner.unbind("part", this._partBind);
    this.owner && this.owner.unbind("post_config", this._postConfigBind);

    ripe.Ripe.plugins.Plugin.prototype.unregister.call(this, owner);
};

/**
 * @ignore
 */
ripe.Ripe.plugins.RestrictionsPlugin.prototype._config = function() {
    this.restrictions =
        this.auto && this.owner.loadedConfig
            ? this.owner.loadedConfig.restrictions
            : this.restrictions;
    this.restrictionsMap = this._buildRestrictionsMap(this.restrictions);

    this.partsOptions = this.owner.loadedConfig ? this.owner.loadedConfig.parts : {};
    const defaults = this.owner.loadedConfig ? this.owner.loadedConfig.defaults : {};
    const optionals = [];
    for (const name in defaults) {
        const part = defaults[name];
        part.optional && optionals.push(name);
    }
    this.optionals = optionals;
};

/**
 * @ignore
 */
ripe.Ripe.plugins.RestrictionsPlugin.prototype._applyRestrictions = function(name, value) {
    // if there are no restrictions defined (map length is zeo)
    // then returns the control flow immediately (nothing to be done)
    if (Object.keys(this.restrictionsMap).length === 0) return;

    // creates an array with the customization, by copying the
    // current parts environment into a separate array, this is
    // a clone of the original customization to be used latter on
    const customization = [];
    const partsOptions = ripe.clone(this.partsOptions);
    for (const partName in this.owner.parts) {
        if (name !== undefined && name === partName) {
            continue;
        }
        const part = this.owner.parts[partName];
        customization.push({
            name: partName,
            material: part.material,
            color: part.color
        });
    }

    // if a new part is set it is added at the end so that it
    // has higher priority when solving the restrictions
    const partSet =
        name !== undefined
            ? {
                  name: name,
                  material: value.material,
                  color: value.color
              }
            : null;
    name !== undefined && customization.push(partSet);

    // obtains the new parts and mutates the original
    // parts map to apply the necessary changes
    const newParts = this._solveRestrictions(partsOptions, this.restrictionsMap, customization);
    const changes = this._applyChanges(newParts);

    // triggers the restrictions event with the set of changes in the
    // domain and the possible part set that triggered those changes
    this.trigger("restrictions", changes, partSet);
};

/**
 * @ignore
 */
ripe.Ripe.plugins.RestrictionsPlugin.prototype._solveRestrictions = function(
    availableParts,
    restrictions,
    customization,
    solution
) {
    // if all the parts are set then a solution has been found
    // and it is returned
    solution = solution || [];
    if (customization.length === 0 && this._isComplete(solution)) {
        return solution;
    }

    // retrieves a part from the customization and checks if it is
    // being restricted by any of the validated parts, if there is
    // no restriction then adds the part to the solution array and
    // proceeds to the next part
    const newPart = customization.pop();
    if (this._isRestricted(newPart, restrictions, solution) === false) {
        solution.push(newPart);
        return this._solveRestrictions(availableParts, restrictions, customization, solution);
    }

    // if the part is restricted then tries to retrieve an alternative option,
    // if an alternative is found then adds it to the customization and proceeds
    // with it, otherwise an invalid state was reached and an empty solution
    // is returned, meaning that there is no option for the current customization
    // that would comply with the restrictions
    const newPartOption = this._alternativeFor(newPart, availableParts, true);
    if (newPartOption === null) {
        return [];
    }
    customization.push(newPartOption);
    return this._solveRestrictions(availableParts, restrictions, customization, solution);
};

/**
 * @ignore
 */
ripe.Ripe.plugins.RestrictionsPlugin.prototype._applyChanges = function(
    newParts,
    oldParts = null
) {
    if (oldParts === null) oldParts = this.owner.parts;

    const changes = [];

    for (let index = 0; index < newParts.length; index++) {
        const newPart = newParts[index];
        const oldPart = oldParts[newPart.name] || {};

        const oldMaterial = oldPart.material || null;
        const oldColor = oldPart.color || null;

        const newMaterial = newPart.material || null;
        const newColor = newPart.color || null;

        // verifies if a valid change has been made for the current
        // part in iteration if not continues the loop
        if (oldMaterial === newMaterial && oldColor === newColor) {
            continue;
        }

        // adds a new change description object to the sequence of
        // changes coming from the restrictions
        changes.push({
            from: {
                part: newPart.name,
                material: oldMaterial,
                color: oldColor
            },
            to: {
                part: newPart.name,
                material: newMaterial,
                color: newColor
            }
        });

        const remove = Boolean(newMaterial && newColor) === false;
        if (remove) {
            delete oldParts[newPart.name];
        } else {
            oldPart.material = newMaterial;
            oldPart.color = newColor;
        }
    }

    // returns the complete set of changes that were
    // computed by comparing the old and the new parts
    return changes;
};

/**
 * @ignore
 */
ripe.Ripe.plugins.RestrictionsPlugin.prototype._getRestrictionKey = function(
    part,
    material,
    color,
    token
) {
    token = token || this.token;
    part = part || "";
    material = material || "";
    color = color || "";
    return part + token + material + token + color;
};

/**
 * Maps the restrictions array into a dictionary where restrictions
 * are associated by key with each other for easier use.
 * For example, '[[{ material: "nappa"}, { material: "suede"}]]'
 * turns into '{ "nappa": ["suede"], "suede": ["nappa"] }'.
 *
 * @param {Array} restrictions The array of restrictions defined by an
 * object of incompatible materials/colors.
 * @returns {Object} A map that associates the restricted keys with the
 * array of associated restrictions.
 *
 * @ignore
 */
ripe.Ripe.plugins.RestrictionsPlugin.prototype._buildRestrictionsMap = function(restrictions) {
    const restrictionsMap = {};

    // in case the restrictions value to be applied is not valid returns
    // the (currently) unset restrictions map immediately
    if (!restrictions) return restrictionsMap;

    // iterates over the complete set of restrictions in the restrictions
    // list to process them and populate the restrictions map with a single
    // key to "restricted keys" association
    for (let index = 0; index < restrictions.length; index++) {
        // in case the restriction is considered to be a single one
        // then this is a special (all cases excluded one) and must
        // be treated as such (true value set in the map value)
        const restriction = restrictions[index];
        if (restriction.length === 1) {
            const _restriction = restriction[0];
            const key = this._getRestrictionKey(
                _restriction.part,
                _restriction.material,
                _restriction.color
            );
            restrictionsMap[key] = true;
            continue;
        }

        // iterates over all the items in the restriction to correctly
        // populate the restrictions map with the restrictive values
        for (let _index = 0; _index < restriction.length; _index++) {
            const item = restriction[_index];

            const material = item.material;
            const color = item.color;
            const materialColorKey = this._getRestrictionKey(null, material, color);

            for (let __index = 0; __index < restriction.length; __index++) {
                const _item = restriction[__index];
                const _material = _item.material;
                const _color = _item.color;
                const _key = this._getRestrictionKey(null, _material, _color);

                if (__index === _index) {
                    continue;
                }

                const sequence = restrictionsMap[materialColorKey] || [];
                sequence.push(_key);
                restrictionsMap[materialColorKey] = sequence;
            }
        }
    }

    return restrictionsMap;
};

/**
 * @ignore
 */
ripe.Ripe.plugins.RestrictionsPlugin.prototype._isRestricted = function(
    newPart,
    restrictions,
    parts
) {
    const name = newPart.name;
    const material = newPart.material;
    const color = newPart.color;
    const partKey = this._getRestrictionKey(name);
    const materialKey = this._getRestrictionKey(null, material, null);
    const colorKey = this._getRestrictionKey(null, null, color);
    const materialColorKey = this._getRestrictionKey(null, material, color);
    const materialRestrictions = restrictions[materialKey];
    const colorRestrictions = restrictions[colorKey];
    let keyRestrictions = restrictions[materialColorKey] || [];
    let restricted = restrictions[partKey] !== undefined;
    restricted |= materialRestrictions === true;
    restricted |= colorRestrictions === true;
    restricted |= keyRestrictions === true;
    if (restricted) {
        return true;
    }

    keyRestrictions =
        materialRestrictions instanceof Array
            ? keyRestrictions.concat(materialRestrictions)
            : keyRestrictions;
    keyRestrictions =
        colorRestrictions instanceof Array
            ? keyRestrictions.concat(colorRestrictions)
            : keyRestrictions;

    for (let index = 0; index < keyRestrictions.length; index++) {
        const restriction = keyRestrictions[index];
        for (let _index = 0; _index < parts.length; _index++) {
            const part = parts[_index];
            if (part.name === name) {
                continue;
            }

            const restrictionKeys = [
                this._getRestrictionKey(null, part.material),
                this._getRestrictionKey(null, null, part.color),
                this._getRestrictionKey(null, part.material, part.color)
            ];
            if (restrictionKeys.indexOf(restriction) !== -1) {
                return true;
            }
        }
    }

    return false;
};

/**
 * @ignore
 */
ripe.Ripe.plugins.RestrictionsPlugin.prototype._isComplete = function(parts) {
    // iterates through the parts array and creates
    // an array with the names of the parts for
    // easier searching
    let part = null;
    const partsS = [];
    for (let index = 0; index < parts.length; index++) {
        part = parts[index];
        partsS.push(part.name);
    }

    // iterates through the part options and checks
    // if all non optional parts are set
    for (let index = 0; index < this.partsOptions.length; index++) {
        part = this.partsOptions[index];
        if (this.optionals.indexOf(part.name) !== -1) {
            continue;
        }
        if (partsS.indexOf(part.name) === -1) {
            return false;
        }
    }

    return true;
};

/**
 * @ignore
 */
ripe.Ripe.plugins.RestrictionsPlugin.prototype._alternativeFor = function(
    newPart,
    availableParts,
    pop
) {
    pop = pop || false;
    let part = null;
    let color = null;
    let colors = null;
    let materialsIndex = null;

    // finds the index of the part to use it as the starting
    // point of the search for an alternative
    for (let index = 0; index < availableParts.length; index++) {
        const _part = availableParts[index];
        if (_part.name !== newPart.name) {
            continue;
        }
        part = _part;
        const materials = part.materials;
        for (let _index = 0; _index < materials.length; _index++) {
            const material = materials[_index];
            if (material.name !== newPart.material) {
                continue;
            }

            materialsIndex = _index;
            colors = material.colors;
            break;
        }
        break;
    }

    // tries to retrieve an alternative option, giving
    // priority to the colors of its material
    let indexM = null;
    while (indexM !== materialsIndex) {
        indexM = indexM === null ? materialsIndex : indexM;

        const material = part.materials[indexM];
        colors = material.colors;
        for (let index = 0; index < colors.length; index++) {
            color = colors[index];
            if (indexM === materialsIndex && color === newPart.color) {
                continue;
            }

            // if pop is set to true then removes the alternative
            // from the available parts list so that it is not
            // used again to avoid infinite loops
            if (pop) {
                colors.splice(index, 1);
            }
            const alternative = {
                name: newPart.name,
                material: material.name,
                color: color
            };
            return alternative;
        }
        indexM = (indexM + 1) % part.materials.length;
    }

    // if no alternative is found and this part is
    // optional then the part is removed
    if (this.optionals.indexOf(newPart.name) !== -1) {
        return {
            name: newPart.name,
            material: null,
            color: null
        };
    }

    return null;
};