/* * 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 ===========