Source: fieldGroup/FieldGroupCntlr.js

/*
 * License information at https://github.com/Caltech-IPAC/firefly/blob/master/License.txt
 */

import {flux} from '../Firefly.js';
import {take} from 'redux-saga/effects';
import {omit,get,isEqual} from 'lodash';
import {clone} from '../util/WebUtil.js';
import {revalidateFields} from './FieldGroupUtils.js';
import {REINIT_APP} from '../core/AppDataCntlr.js';

/**
 * Reducer for 'fieldGroup' key
 */

/**
 * @typedef {Object.<String,FieldGroup>} FieldGroupStore
 *
 * @global
 * @public
 */

/**
 * @typedef {Object} FieldGroupField
 *
 * @prop {String} fieldKey the field id, must be unique in the group
 * @prop {String} groupKey the group id, must be unique
 * @prop {String|Promise|*} value the value, can be anything including promise, typically a string
 * @prop {boolean} valid the group id, must be unique
 * @prop {Function} validator
 * @prop {boolean} mounted field is mounted
 * @prop {boolean} nullAllowed, default to true
 *
 * @global
 * @public
 */

/**
 * @typedef {Object} FieldGroup
 *
 * @prop {String} groupKey
 * @prop {FieldGroupField[]} fields
 * @prop {Function} reducerFunc
 * @props {String[]} actionTypes any action types (beyond the FieldGroup action types) that the reducer should allow though
 * @prop {boolean} keepState
 * @prop {boolean} mounted field is mounted
 * @prop {String} wrapperGroupKey
 * @prop {boolean} fieldGroupValid
 *
 * @global
 * @public
 */





export const INIT_FIELD_GROUP= 'FieldGroupCntlr/initFieldGroup';
export const MOUNT_COMPONENT= 'FieldGroupCntlr/mountComponent';
export const MOUNT_FIELD_GROUP= 'FieldGroupCntlr/mountFieldGroup';
export const VALUE_CHANGE= 'FieldGroupCntlr/valueChange';
export const MULTI_VALUE_CHANGE= 'FieldGroupCntlr/multiValueChange';
export const RESTORE_DEFAULTS= 'FieldGroupCntlr/restoreDefaults';
export const CHILD_GROUP_CHANGE= 'FieldGroupCntlr/childGroupChange';
export const RELATED_ACTION= 'FieldGroupCntlr/relatedAction';


export const FIELD_GROUP_KEY= 'fieldGroup';

export default {
    reducers () {return {[FIELD_GROUP_KEY]: reducer};},

    actionCreators() {
        return {
            [VALUE_CHANGE] : valueChangeActionCreator,
            [MULTI_VALUE_CHANGE]: multiValueChangeActionCreator
        };
    },
    VALUE_CHANGE, CHILD_GROUP_CHANGE,
    MOUNT_FIELD_GROUP
};


//======================================== Dispatch Functions =============================
//======================================== Dispatch Functions =============================
//======================================== Dispatch Functions =============================

/**
 * This will init a field group. In general, you should not need to use this.  Mount does the same thing and is automatic
 * through the FieldGroupConnector. Have a good reason if you are using this action
 * 
 * @param groupKey
 * @param keepState
 * @param initValues
 * @param reducerFunc
 * @param actionTypes any action types (beyond the FieldGroup action types) that the reducer should allow though
 */
export function dispatchInitFieldGroup(groupKey,keepState=false, 
                                       initValues=null, 
                                       reducerFunc= null,
                                       actionTypes=[]) {

    flux.process({type: INIT_FIELD_GROUP, payload: {groupKey,reducerFunc, actionTypes, 
                                                    initValues, keepState}});
}



/**
 * @param groupKey
 * @param mounted
 * @param keepState
 * @param initValues
 * @param reducerFunc
 * @param actionTypes any action types (beyond the FieldGroup action types) that the reducer should allow though
 * @param wrapperGroupKey
 * @param forceUnmount
 */
export function dispatchMountFieldGroup(groupKey, mounted, keepState= false, 
                                        initValues=null, reducerFunc= null,
                                        actionTypes=[], wrapperGroupKey, forceUnmount = false) {
    flux.process({type: MOUNT_FIELD_GROUP, payload: {groupKey, mounted, initValues, reducerFunc, 
                                                     actionTypes, keepState, wrapperGroupKey, forceUnmount} });
}


/**
 * 
 * @param groupKey
 * @param fieldKey
 * @param mounted
 * @param value
 * @param initFieldState
 */
export function dispatchMountComponent(groupKey,fieldKey,mounted,value,initFieldState) {
        flux.process({
            type: MOUNT_COMPONENT, payload: { groupKey, fieldKey, mounted, value, initFieldState }
        });
}

/**
 * the required parameter are below, anything else passed is put in the field group as well
 *
 * @param {Object} payload
 * @param {String} payload.fieldKey the field Key
 * @param {String} payload.groupKey group key
 * @param {String} payload.value value can be anything including a promise or function
 * @param {boolean} payload.valid - true if valid, default to true
 */
export function dispatchValueChange(payload) {
    flux.process({type: VALUE_CHANGE, payload});
}


/**
 * Update mutiliple fields
 * @param {String} groupKey
 * @param {FieldGroupField[]} fieldAry
 */
export function dispatchMultiValueChange(groupKey, fieldAry) {
    flux.process({type: MULTI_VALUE_CHANGE, payload:{groupKey, fieldAry}});
}

/**
 * Restore defaults
 * @param {String} groupKey
 */
export function dispatchRestoreDefaults(groupKey) {
    flux.process({type: RESTORE_DEFAULTS, payload:{groupKey}});
}



//======================================== Action Creators =============================
//======================================== Action Creators =============================
//======================================== Action Creators =============================


/**
 *
 * @param {Action} rawAction
 * @return {Function}
 */
function valueChangeActionCreator(rawAction) {
    return (dispatcher) => {
        const {value}= rawAction.payload;
        dispatcher(rawAction);
        if (value && value.then) {
            const {fieldKey,groupKey}= rawAction.payload;
            value.then((payload) => {
                    dispatcher({
                        type: VALUE_CHANGE,
                        payload: Object.assign({}, payload, {fieldKey, groupKey})
                    });
            }).catch((e) => console.log(e));
        }
    };
}



/**
 *
 * @param {Action} rawAction
 * @return {Function}
 */
function multiValueChangeActionCreator(rawAction) {
    return (dispatcher) => {
        const {groupKey, fieldAry}= rawAction.payload;
        dispatcher(rawAction);
        fieldAry
            .filter((f) => f.value && f.value.then)
            .forEach((f) => {
                f.value.then((payload) => {
                        dispatcher({
                            type: VALUE_CHANGE,
                            payload: Object.assign({}, payload, {fieldKey: f.fieldKey, groupKey})
                        });
                    })
                    .catch((e) => console.log(e));
            });
    };
}

//======================================== Saga =============================
//======================================== Saga =============================
//======================================== Saga =============================


export function* watchForRelatedActions(params, dispatcher, getState ) {
    let action=  yield take();
    let state;
    while(true) {
        state= getState()[FIELD_GROUP_KEY];
        Object.keys(state).forEach((groupKey)=> {
            const fg= state[groupKey];
            if (fg.mounted && fg.actionTypes.includes(action.type)) {
                dispatcher({type:RELATED_ACTION, payload:{fieldGroup:fg, originalAction:action}});
            }
        });

        action=  yield take();
    }
}





//======================================== Reducer =============================
//======================================== Reducer =============================
//======================================== Reducer =============================


/**
 * main reducer
 * @param {Object} state
 * @param {Action} action
 * @return {Object} return state
 */
function reducer(state={}, action={}) {

    if (!action.payload || !action.type) return state;

    let retState= state;
    switch (action.type) {
        case INIT_FIELD_GROUP  :
            retState= initFieldGroup(state, action);
            break;
        case MOUNT_COMPONENT  :
            retState= updateMount(state, action);
            break;
        case MOUNT_FIELD_GROUP  :
            retState= updateFieldGroupMount(state, action);
            break;
        case VALUE_CHANGE  :
            retState= valueChange(state, action);
            break;
        case MULTI_VALUE_CHANGE:
            retState= multiValueChange(state, action);
            break;
        case RESTORE_DEFAULTS:
            retState= restoreDefaults(state, action);
            break;
        case RELATED_ACTION:
            retState= relatedAction(state,action);
            break;
        case REINIT_APP:
            retState= {};
            break;
    }
    return retState;
}


/**
 *
 * @param {Object} state
 * @param {Action} action
 * @return {*}
 */
function relatedAction(state,action) {
    const {originalAction,fieldGroup}= action.payload;
    const newFg= Object.assign({},fieldGroup,{fields:fireFieldsReducer(fieldGroup,originalAction)});
    return Object.assign({},state,{[newFg.groupKey]:newFg});
}



function addInitValues(fields,initValues) {
    fields= Object.assign({},fields);
    return Object.keys(initValues).reduce( (obj,key)=> {
        if (!fields[key]) fields[key]= {};
        fields[key].value= initValues[key];
        fields[key].fieldKey= key;
        fields[key].mounted= true;
        return fields;
    },fields);
}


/**
 *
 * @param {Object} state
 * @param {Action} action
 */
function initFieldGroup(state,action) {
    const {groupKey, reducerFunc, actionTypes=[], keepState, initValues,wrapperGroupKey}= action.payload;

    const mounted= get(state, [groupKey,'mounted'],false);

    let fields= reducerFunc ? reducerFunc(null, action) : {};

    if (initValues) {
        fields= addInitValues(fields,initValues);
    }


    const fg= constructFieldGroup(groupKey,fields,reducerFunc,actionTypes, keepState, wrapperGroupKey);
    fg.mounted= mounted;
    fg.initFields= Object.keys(fg.fields).map( (key) => fg.fields[key]);
    return clone(state,{[groupKey]:fg});
}

function updateFieldGroupMount(state,action) {
    const {groupKey, mounted, initValues, reducerFunc, wrapperGroupKey, forceUnmount}= action.payload;
    if (!groupKey) return state;

    let retState= state;

    if (mounted) {
        if (isFieldGroupDefined(state,groupKey)) {
            const fg= findAndCloneFieldGroup(state, groupKey, {mounted:true});
            if (wrapperGroupKey) fg.wrapperGroupKey= wrapperGroupKey;
            if (reducerFunc) fg.reducerFunc= reducerFunc;

            if (initValues) {
                fg.fields= addInitValues(fg.fields,initValues);
            }

            retState= clone(state,{[groupKey]:fg});
        }
        else {
            retState= initFieldGroup(state,action);
            retState[groupKey].mounted= true;
        }
        retState[groupKey].fields= fireFieldsReducer(retState[groupKey], action);
    }
    else {
        if (isFieldGroupDefined(state,groupKey)) {
            const fg= findAndCloneFieldGroup(state, groupKey, {mounted:false});
            if (!fg.keepState || forceUnmount) fg.fields= null;
            retState= clone(state,{[groupKey]:fg});
        }
    }
    return retState;
};

/**
 * @param {object} fg the field group
 * @param {Action} action - the action to fire
 * @return {Object} the fields
 * fire the reducer for field group if it has been defined
 */
function fireFieldsReducer(fg, action) {
    // return  fg.reducerFunc ? fg.reducerFunc(revalidateFields(fg.fields), action) : fg.fields;
    if (fg.reducerFunc ) {
        const newFields = fg.reducerFunc(revalidateFields(fg.fields), action);
        return smartReplace(fg.fields,newFields);
    }
    else {
        return fg.fields;
    }
}

/**
 * Returns old fields unless some attribute has changed.
 * @param oldFields
 * @param newFields
 * @returns {*}
 */
function smartReplace(oldFields, newFields) {
    if (!oldFields || !newFields || oldFields === newFields) return newFields;
    const newFieldsOptimized = {};
    let hasChanged = false;
    Object.entries(newFields).forEach(([k,nf]) => {
        const of = oldFields[k];
        if (!isEqual(of, nf)) {
            newFieldsOptimized[k] = nf;
            hasChanged = true;
        } else {
            newFieldsOptimized[k] = of;
        }
    });
    if (!hasChanged && (Object.keys(oldFields).length === Object.keys(newFields).length)) {
        return oldFields;
    } else {
        return newFieldsOptimized;
    }
}

function valueChange(state,action) {
    const {fieldKey, groupKey,message='', valid=true, fireReducer=true}= action.payload;

    if (!getFieldGroup(state,groupKey)) {
        state = initFieldGroup(state,action);
        state[groupKey].mounted= true;
    }

    const fg= findAndCloneFieldGroup(state, groupKey);

    const addToInit= !fg.fields[fieldKey];
    fg.fields[fieldKey]=  Object.assign({},
                                  fg.fields[fieldKey],
                                  action.payload,
                                  {message, valid});

    if (fireReducer) fg.fields= fireFieldsReducer(fg, action);
    if (addToInit) fg.initFields= [...fg.initFields,fg.fields[fieldKey]];

    let mods= {[groupKey]:fg};
   //============== Experimental parent group get notified
    if (fireReducer) {
        const modAddition= updateWrapperGroup(state,fg,groupKey,action);
        mods= clone(mods,modAddition);
    }
    //==============

    return clone(state,mods);
}

function updateWrapperGroup(state, fg, groupKey, action) {
    if (!get(state, [fg.wrapperGroupKey,'mounted'],false)) return {};

    const wrapperFg= findAndCloneFieldGroup(state, fg.wrapperGroupKey);
    const childGroups= makeChildGroups(fg.wrapperGroupKey, state);
    childGroups[groupKey]= fg.fields;
    const wrapperAction= {
        type: CHILD_GROUP_CHANGE,
        payload: { changedGroupKey: groupKey, sourceAction:action, childGroups }
    };
    wrapperFg.fields= fireFieldsReducer(wrapperFg, wrapperAction);
    return {[wrapperFg.groupKey]: wrapperFg};
}

function multiValueChange(state,action) {
    const {fieldAry,groupKey}= action.payload;

    fieldAry.forEach( (f) => state= valueChange(state,{type:VALUE_CHANGE, payload:clone(f,{groupKey,fireReducer:false})}) );

    const fg= findAndCloneFieldGroup(state, groupKey);
    fg.fields= fireFieldsReducer(fg, action);

    let mods= {[groupKey]:fg};
    //============== Experimental parent group get notified
    const modAddition= updateWrapperGroup(state,fg,groupKey,action);
    mods= clone(mods,modAddition);
    //==============

    return clone(state,mods);
}

function restoreDefaults(state,action) {
    const {groupKey}= action.payload;
    const fg= getFieldGroup(state,groupKey);
    if (!fg) return state;
    fg.initFields.forEach( (f) => {
        const {fieldKey,message,valid,value,displayValue}= f;
        state= valueChange(state,{type:VALUE_CHANGE, payload:{groupKey,fieldKey,message,valid,value,displayValue}});
    } );
    return state;
}



function makeChildGroups(wrapperGroupKey, state) {
    return Object.keys(state).reduce( (obj,key) => {
        if (state[key].wrapperGroupKey===wrapperGroupKey) {
            obj[key]= state[key].fields;
        }
        return obj;
    }, {});

}



function updateMount(state, action) {
    const {fieldKey,mounted,initFieldState={},groupKey}= action.payload;
    let fg= getFieldGroup(state,groupKey);
    if (!fg || (!mounted && !fg.fields)) return state;

    fg= findAndCloneFieldGroup(state,groupKey);

    if (mounted) {
        const addToInit= !fg.fields[fieldKey];
        const omitPayload= omit(action.payload, ['initFieldState','groupKey']);
        fg.fields[fieldKey]= Object.assign({valid:true,nullAllowed:true},initFieldState, fg.fields[fieldKey],
                                           omitPayload);
        if (addToInit) fg.initFields= [...fg.initFields,fg.fields[fieldKey]];
    }
    else {
        fg.fields[fieldKey]= Object.assign({},fg.fields[fieldKey],{mounted});
    }

    return clone(state,{[groupKey]:fg});
}


//============ private utilities =================================
//============ private utilities =================================
//============ private utilities =================================

function createState(oldState,groupKey,fieldGroup) {
    return Object.assign({},oldState, {[groupKey]:fieldGroup});
}


// function cloneField(field, newKeys={}) { return Object.assign({},field,newKeys); }

/**
 *
 *
 * @param {object} state
 * @param {string} groupKey
 * @param {object} newValues any value replacements
 */
function findAndCloneFieldGroup(state,groupKey, newValues={}) {
    const fg= getFieldGroup(state,groupKey);
    if (!fg) return undefined;
    const retFg= Object.assign({},fg,newValues);
    retFg.fields= Object.assign({},fg.fields);
    return retFg;
}

function isFieldGroupDefined(state,groupKey) {
    return groupKey && state[groupKey] && state[groupKey].fields;
}

/**
 *
 *
 * @param {object} state
 * @param {string} groupKey
 * @return {object} retState
 */
function getFieldGroup(state,groupKey) {
    if (!groupKey) return null;
    return state[groupKey];
}

/**
 *
 * @param groupKey
 * @param fields
 * @param reducerFunc
 * @param actionTypes
 * @param keepState
 * @param wrapperGroupKey
 * @return {FieldGroup}
 *
 */
function constructFieldGroup(groupKey,fields, reducerFunc, actionTypes, keepState, wrapperGroupKey) {
    fields= fields || {};

    Object.keys(fields).forEach( (key) => {
        if (typeof fields[key].valid === 'undefined') {
            fields[key].valid= true;
        }
    });

    return { groupKey,
             fields,
             reducerFunc,
             keepState,
             actionTypes,
             wrapperGroupKey,
             mounted : false,
             fieldGroupValid : false
    };
}


//============ end private utilities ===========
//============ end private utilities ===========


//============ EXPORTS ===========
//============ EXPORTS ===========


//============ EXPORTS ===========
//============ EXPORTS ===========