src/context/extensions/helpers/processConfig.js

Summary

Maintainability
C
7 hrs
Test Coverage
import { bold, underline } from 'chalk';
import { isPlainObject, intersection, get, update, union } from 'lodash';

import { RAW } from '../../../configuration/addRaw';

import buildList from './buildList';

// Returns an updated meta object
export default function processConfig(name, extension, state) {
    const {
        extensionConfigPaths,
        extensionMetaPaths,
        stateConfigPaths,
    } = validateConfig(name, extension, state);

    return updateStateMeta(
        name,
        state,
        extensionConfigPaths,
        extensionMetaPaths,
        stateConfigPaths
    );
}

export function validateConfig(name, extension, state) {
    const extensionConfigPaths = getKeys(extension.config, true);
    const extensionMetaPaths = getKeys(extension.meta);
    const stateConfigPaths = getKeys(state.config, true);
    const stateMetaPaths = getKeys(state.meta);

    validateMetaStructure(
        name,
        intersection(
            extensionMetaPaths.paths,
            stateMetaPaths.paths
        ),
        stateConfigPaths,
        extension.meta,
        state.meta
    );

    validateConfigurationStructure(
        name,
        intersection(
            extensionConfigPaths.paths,
            stateConfigPaths.paths
        ),
        extensionConfigPaths,
        stateConfigPaths,
        extension.meta,
        state.meta
    );

    return {
        extensionConfigPaths,
        extensionMetaPaths,
        stateConfigPaths,
        stateMetaPaths,
    };
}

/* eslint-disable no-param-reassign */
function getKeys(obj = {}, flag = false, oldPath = '', allKeys = [], allGroups = []) {
    let isValue = true;
    Object.keys(obj).forEach((key) => {
        const value = obj[key];
        const newPath = oldPath + key;

        if (isPlainObject(value) && key !== RAW && key !== '__meta') {
            isValue = true;
            if (newPath !== 'settings') {
                allGroups.push(true);
                allKeys.push(newPath);
            }
            const keys = getKeys(value, flag, `${newPath}.`, allKeys, allGroups);
            // If nothing was changed we where in a value, not a group
            if (keys.value) {
                allGroups[allGroups.length - 1] = false;
            }
        } else if (flag && key !== '__meta') {
            allKeys.push(newPath);
            allGroups.push(false);
        }
    });

    return {
        paths: allKeys,
        groups: allGroups,
        value: isValue,
    };
}
/* eslint-enable */

function getGroup(obj, path) {
    return !!obj.groups[obj.paths.indexOf(path)];
}

function notInExtensions(extensions, extension) {
    if (Array.isArray(extension)) {
        return !extension.some((e) => extensions.indexOf(e) !== -1);
    }

    return extensions.indexOf(extension) === -1;
}

function validateMetaStructure(name, intersections, stateConfigPaths, extensionMeta, stateMeta) {
    intersections.forEach((intersect) => {
        const wasGroup = getGroup(stateConfigPaths, intersect);

        if (!wasGroup || get(extensionMeta, `${intersect}.__meta`)) {
            const stateExtensions = get(stateMeta, intersect).__extensions || [];
            const extensionExtensions = get(extensionMeta, intersect).__extensions || [];

            // If it is a group the override info will be on __meta and if not it will be directly on the object
            const override = (get(extensionMeta, intersect, {}).__meta || {}).override ||
                get(extensionMeta, intersect, {}).override;

            if (
                notInExtensions(stateExtensions, name) &&
                override !== true &&
                notInExtensions(stateExtensions, override) &&
                notInExtensions(stateExtensions, extensionExtensions)
            ) {
                // Fail early, might be more errors after this
                // + This gives a better/more concise error for the project developer
                // + We do not waste computation when we already know there is an error
                // - The extension developer will not know the entire picture, just one of potentially several errors
                const overrideMessage = !override ?
                    'No override value was specified, it should probably be one of the extensions above.\n' :
                    `The override did not match the possible values, it was: ${override}\n`;
                throw new Error(
                    'Meta structure was changed without specifying override.\n' + // eslint-disable-line
                    `Meta for ${bold(intersect)} was changed in ${name} and has been altered before by:\n` +
                    buildList(stateExtensions) +
                    overrideMessage +
                    `Contact the developer of ${underline(name)} for help.`
                );
            }
        }
    });
}

function validateConfigurationStructure(
    name, intersections, extensionConfigPaths, stateConfigPaths, extensionMeta, stateMeta
) {
    intersections.forEach((intersect) => {
        const wasGroup = getGroup(stateConfigPaths, intersect);
        const isGroup = getGroup(extensionConfigPaths, intersect);
        if (wasGroup !== isGroup) {
            const stateExtensions = get(stateMeta, intersect).__extensions || [];
            const extensionExtensions = get(extensionMeta, intersect, {}).__extensions || [];

            // If it is a group the override info will be on __meta and if not it will be directly on the object
            const override = (get(extensionMeta, intersect, {}).__meta || {}).override ||
                get(extensionMeta, intersect, {}).override;

            if (
                notInExtensions(stateExtensions, name) &&
                override !== true &&
                notInExtensions(stateExtensions, override) &&
                notInExtensions(stateExtensions, extensionExtensions)
            ) {
                // Fail early, might be more errors after this
                // + This gives a better/more concise error for the project developer
                // + We do not waste computation when we already know there is an error
                // - The extension developer will not know the entire picture, just one of potentially several errors
                throw new Error(
                    'Configuration structure was changed without specifying override in meta.\n' +
                    `Was ${wasGroup ? 'an object' : 'a value'} and is now ${isGroup ? 'an object' : 'a value'}.\n` +
                    `The setting is question is: ${bold(intersect)}\n` +
                    `Contact the developer of ${underline(name)} for help.`
                );
            }
        }
    });
}

function updateStateMeta(name, state, extensionConfigPaths, extensionMetaPaths, stateConfigPaths) {
    const newState = { ...state };

    // Defining meta for something means that the __extensions should be updated
    // Might be the case that the value has changed and in that case the old value will be replaced in the next loop
    extensionMetaPaths.paths.forEach((path) =>
        update(newState.meta, path, (previous = {}) =>
            ({
                ...previous,
                __extensions: union(previous.__extensions || [], [name]),
            })
        )
    );

    extensionConfigPaths.paths.forEach((path, index) => {
        const changed = getGroup(stateConfigPaths, path) !== extensionConfigPaths.groups[index];
        update(newState.meta, path, (previous = {}) => {
            // If it has changed we will reset it
            const newValue = changed ?
                {} :
                previous;
            const newExtensions = changed ?
                [] :
                previous.__extensions || [];

            return {
                ...newValue,
                __extensions: union(newExtensions, [name]),
            };
        });
    });

    return newState.meta;
}

export function validateConfigAndCleanMetaData(name, extension, state) {
    const {
        extensionConfigPaths,
        stateConfigPaths,
    } = validateConfig(name, extension, state);

    extensionConfigPaths.paths.forEach((path, index) => {
        // Only process settings
        if (/^settings/.test(path)) {
            const changed = getGroup(stateConfigPaths, path) !== extensionConfigPaths.groups[index];
            update(state.meta, path, (previous = {}) => {
                if (changed) {
                    return {
                        __extensions: union(previous.__extensions || [], [name]),
                    };
                } else if (!previous.__extensions) {
                    return undefined;
                }

                return previous;
            });
        }
    });

    return removeUndefined(state.meta);
}

function removeUndefined(meta) {
    Object.keys(meta).forEach((key) => {
        if (meta[key] === undefined) {
            delete meta[key]; // eslint-disable-line
        } else if (isPlainObject(meta[key])) {
            removeUndefined(meta[key]);
        }
    });

    return meta;
}