Source: ui/FieldGroupConnector.jsx

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

import {useContext, useState, useEffect, useRef} from 'react';
import PropTypes from 'prop-types';
import {get,omit} from 'lodash';
import {dispatchMountComponent,dispatchValueChange} from '../fieldGroup/FieldGroupCntlr.js';
import FieldGroupUtils, {getFieldGroupState} from '../fieldGroup/FieldGroupUtils.js';
import {flux} from '../Firefly.js';
import {GroupKeyCtx} from './FieldGroup';
import {isDefined} from '../util/WebUtil';

const defaultConfirmValue= (v) => v;
const defValidatorFunc= () => ({valid:true,message:''});



const STORE_OMIT_LIST= ['fieldKey', 'groupKey', 'initialState', 'fireReducer',
    'confirmValue', 'confirmValueOnInit', 'forceReinit', 'mounted'];

function buildViewProps(fieldState,props,fieldKey,groupKey,value='') {
    const {message= '', valid= true, visible= true, value:ignoreValue, displayValue= '',
        tooltip= '', validator= defValidatorFunc, ...rest}= fieldState;
    const propsClean= Object.keys(props).reduce( (obj,k)=> {
        if (isDefined(props[k]) && k!=='value') obj[k]=props[k];
        return obj;
    },{} );

    const tmpProps= Object.assign({ message, valid, visible, value, displayValue,
            tooltip, validator, key:`${groupKey}-${fieldKey}`},
        rest, propsClean);

    return omit(tmpProps, STORE_OMIT_LIST);
}

function showValueWarning(groupKey, fieldKey, value, ignoring) {
    const extra= ignoring ? 'value property is being ignored, since initialState.value is defined' : '';
    console.warn(
        `useFieldGroupConnector: fieldKey: ${fieldKey}, groupKey: ${groupKey}: value should not be passed in props\n`,
        `Only initial value should be passed as in the initialState property, ${extra}\n`,
        `i.e. initialState= {value: '${value}'} `);
}

function validateKeys(fieldKey, groupKey) {
    if (!fieldKey) throw Error('useFieldGroupConnector: fieldKey is required for useFieldGroupConnector.');
    if (!groupKey) throw Error('useFieldGroupConnector: groupKey is required for useFieldGroupConnector, groupKey is usually passed though context but may be a prop.');
}

function isInit(infoRef,fieldKey,groupKey) {
    const {prevFieldKey, prevGroupKey}= infoRef.current;
    return (prevFieldKey!==fieldKey || prevGroupKey !== groupKey);
}

function hasNewKeys(infoRef,fieldKey,groupKey) {
    const {prevFieldKey, prevGroupKey}= infoRef.current;
    return isInit(infoRef,fieldKey,groupKey) && prevFieldKey && prevGroupKey;
}

function doGetInitialState(groupKey, initialState,  props, confirmValueOnInit= defaultConfirmValue) {
    const {fieldKey, forceReinit}= props;
    const storeField= get(FieldGroupUtils.getGroupFields(groupKey), [fieldKey]);
    const initS= forceReinit ? (initialState ||  storeField || {}) : (storeField || initialState || {});
    return {...initS, value: confirmValueOnInit(initS.value,props,initialState)};
}


/**
 * @global
 * @public
 * @typedef {Object} ConnectorInterface
 *
 * @summary Return value of useFieldGroupConnector. This object contains the view components properties (viewProps) and the
 * fireValueChange. Simple pass the viewProps to the component and call fireValueChange when the view component has a
 * value change call.
 *
 * @prop {function(payload:Object)} fireValueChange- Call with the value of the view changes. The parameter is an object
 * the should contains as least a value property plus anything else that is appropriate to pass
 * @prop {Object} viewProps - all the properties passed to useFieldGroupConnector with the connection properties
 * removed.  This object should contain all the properties to pass on the the view component
 * @prop {String} fieldKey - the passed fieldKey, you don't usually need to access this value
 * @prop {String} groupKey -  the passed groupKey, you don't usually need to access this value
 */

/**
 * useFieldGroupConnector parameters expressed as propTypes
 */
export const fgConnectPropsTypes= {
    fieldKey: PropTypes.string.isRequired,
    groupKey: PropTypes.string, // normally passed in context
    forceReinit: PropTypes.bool,
    initialState: PropTypes.shape({ // not all fields use everything in initialState, most of it is optional
        value: PropTypes.any, // this is the most common one, it is the initial value for the field.
        message: PropTypes.string,
        validator: PropTypes.func,
        displayValue: PropTypes.string,
        tooltip:  PropTypes.string,
        label:  PropTypes.string,
    }),
    confirmValueOnInit: PropTypes.func,
    confirmValue: PropTypes.func
};

/**
 * Minimal set of properties ot use for useFieldGroupConnector
 */
export const fgMinPropTypes= {
    fieldKey: PropTypes.string.isRequired,
    groupKey: PropTypes.string,  // normally passed in context
};

/**
 * @name ConfirmValueFunc
 * Give a value, props, and state, return the same or a updated value. It must return a value. This is most often
 * used with a radio box type component when the value must be one in the list.
 * @function
 * @param {*} value
 * @param {Object} props
 * @param {Object} state
 * @returns *
 */

/**
 *
 * Hook to connect a field to the FieldGroup Store. Pass the props object, make sure it includes the required props
 * to connect to the store (fieldKey is the only requirement, see below). The hooks returns an object with the new
 * props that you should be able to pass directly to the view.
 *
 * the props object parameter can contain any that should be kept in the store. The parameters below are special.
 * fieldKey is required.
 *
 * @param {Object} props
 * @param {string} props.fieldKey - required, a unique id for this field (unique within group)
 * @param {string} [props.groupKey] - optional - a unique group id, normally this is not use because it is passed in the context
 * @param {string} [props.initialState] - optional - the initial state object, anything in the initialState,
 * should be thought of as state data, it can change over the lifetime of the component and is not controlled by props.
 * Typically only items like value (and possibly displayValue) are in the state but other items can be manage there as well.
 * @param {ConfirmValueFunc} [props.confirmValueOnInit] - optional - If defined it will be call only on init
 * @param {ConfirmValueFunc} [props.confirmValue] - optional - If defined it called every update or init.
 * @param {boolean} [props.forceReinit] - optional - if true, this field will be reinited from the properties and not from the field group,
 *                                it is almost always unnecessary, only use if you know what you are doing and even then make sure.
 * @return {ConnectorInterface}
 *
 */
export const useFieldGroupConnector= (props) => {
    const infoRef = useRef({prevFieldKey:undefined, prevGroupKey:undefined});
    const context= useContext(GroupKeyCtx);
    const {fieldKey,confirmValue,confirmValueOnInit}= props;
    let {initialState}= props;
    const groupKey= props.groupKey || context.groupKey;
    const doingInit= isInit(infoRef,fieldKey,groupKey);

    if (doingInit) {// validation checks
        validateKeys(fieldKey,groupKey);
        if (isDefined(props.value)) {
            showValueWarning(groupKey, fieldKey, props.value, (initialState && isDefined(initialState.value)));
            initialState= {value:props.value, ...initialState};
        }
    }

    const getInitialState= () => doingInit ?
        doGetInitialState(groupKey, initialState,  props, (confirmValueOnInit||confirmValue))
        : undefined;
    const [fieldState, setFieldState] = useState(getInitialState());

    const fireValueChange= (payload) => dispatchValueChange({...payload, fieldKey,groupKey});
    const value= confirmValue ? confirmValue(fieldState.value,props,fieldState) : fieldState.value;

    const effectChangeAry= [fieldKey, groupKey, fieldState];
    if (confirmValue) effectChangeAry.push(value); // only need to watch value in this case

    useEffect(() => {
        if (doingInit) {  // called the first time or when fieldKey or groupKey change
            let value= fieldState.value;
            if (hasNewKeys(infoRef,fieldKey,groupKey)) { // if field and group key changed, whole thing must reinit
                const {prevFieldKey, prevGroupKey}= infoRef.current;
                dispatchMountComponent( prevGroupKey, prevFieldKey, false );
                const initFieldState= getInitialState();
                setFieldState(initFieldState);
                value= initFieldState.value;
            }
            dispatchMountComponent( groupKey, fieldKey, true, value, initialState );
            infoRef.current={prevFieldKey: fieldKey, prevGroupKey: groupKey};
        }
        else if (confirmValue) {// in this case the value might have been updated during the render
            if (fieldState.value!==value) fireValueChange({value}); // put value back in sync
        }
        return flux.addListener(()=> {
            const gState = getFieldGroupState(groupKey);
            if (!gState || !gState.mounted || !get(gState,['fields',fieldKey])) return;
            if (fieldState !== gState.fields[fieldKey]) setFieldState(gState.fields[fieldKey]);
        });
    }, effectChangeAry);
    
    return {
        fireValueChange, viewProps: buildViewProps(fieldState,props,fieldKey,groupKey, value), fieldKey, groupKey
    };
};