Configurator.react.js

import { Component } from 'react';

import Accordion from 'react-bootstrap/Accordion';
import Modal from 'react-bootstrap/Modal';
import Button from 'react-bootstrap/Button';
import ButtonGroup from 'react-bootstrap/ButtonGroup';
import classNames from 'classnames';


import Filter from './Filter.react';
import Transform from './Transform.react';
import MetaCheck from './MetaCheck.react';
import Plotter from './Plotter.react';
import Parameterize from './Parameterize.react';
import Localstore from './Localstore.react';
import { none } from 'ramda';

import PropTypes from 'prop-types';



/**
 * Warp all subcomponents used in the configurator into an accordion
 * 
 * @private
 */
class CustomAccordionItem extends Component {
    constructor(props) {
        super(props);

        this.state = {
            isOpen: ("defaultOpen" in props)
        }


    }

    render() {
        const { isOpen } = this.state;

        return (
            <div className='accordion-item'>
                <h2 className='accordion-header'>
                    <button
                        type="button"
                        className={classNames('accordion-button', !isOpen && 'collapsed')}
                        onClick={e => { this.setState({ isOpen: !isOpen }); }}
                    >
                        {this.props.title}
                    </button>
                </h2>
                <div className={classNames('accordion-collapse', !isOpen && 'collapse')}>
                    <div className='accordion-body'>
                        {this.props.children}
                    </div>
                </div>
            </div>
        )
    }
}


/**
 * <div style="width:450px; margin-left: 20px; float: right;  margin-top: -150px;">
 * <img src="https://raw.githubusercontent.com/VK/dash-express-components/main/.media/configurator.png"/>
 * </div>
 * 
 *
 * The configurator component helps to define plot definitions based on the
 * metadata of a dataframe.
 * Different configuration parts like `Filter`, `Transform` or `Plotter`
 * are combined in a single accordion component.
 *
 * The metadata is used to compute the available parameters after data 
 * transformations and newly available colums are adjusted automatically.
 * 
 * @hideconstructor
 * 
 * @example
 * import dash_express_components as dxc
 * import plotly.express as px
 * 
 * meta = dxc.get_meta(px.data.gapminder())
 * 
 *  dxc.Configurator(
 *           id="plotConfig",
 *           meta=meta,
 *  )
 * @public
 */
class Configurator extends Component {
    constructor(props) {
        super(props);

        this.state = {
            id: props.id,
            config: props.config,
            meta: props.meta,
            filter_meta_out: { ...props.meta },
            transform_meta_out: { ...props.meta },

            /* state of the modal to ask if edit should be opened */
            showEditModal: false,
            eventConfig: {},
            eventThumbnail: "",
            eventGraphId: "",
            modal_close_timeout: undefined

        };

        this.state.config = this.fix_config(this.state.config);

        this.state.config_filter = this.state.config.filter;
        this.state.config_transform = this.state.config.transform;
        this.state.config_plot = this.state.config.plot;

        window.addEventListener("message", (event) => {

            if ("data" in event && "configuratorId" in event.data && event.data.configuratorId == this.props.id) {

                this.setState({
                    showEditModal: true,
                    eventConfig: this.fix_config(event.data.defs),
                    eventThumbnail: event.data.thumbnail,
                    eventGraphId: event.data.graphId
                });

                let that = this;
                let modal_close_timeout = setTimeout(function () { that.handleClose(); }, 5000);

                this.setState({ modal_close_timeout: modal_close_timeout });

            }

        }, false);


        this.filter_ref = React.createRef();
        this.transform_ref = React.createRef();
    }


    fix_config(new_config) {
        if (new_config == undefined) {
            new_config = {};
        }
        if (!("filter" in new_config)) {
            new_config["filter"] = [];
        }
        if (!("transform" in new_config)) {
            new_config["transform"] = [];
        }
        if (!("plot" in new_config)) {
            new_config["plot"] = {};
        }
        if (!("parameterization" in new_config)) {
            new_config["parameterization"] = {
                parameters: [],
                computeAll: false,
                computeMatrix: []
            };
        }
        return new_config;
    }

    update_sub_config(config_dict) {

        let that = this;

        setTimeout(function () {

            let new_config = { ...that.state.config, ...config_dict };

            ["filter", "transform", "plot"].forEach(el => {
                if (el in config_dict) {
                    that.setState({ ['config_' + el]: config_dict[el] },
                        () => { that.update_config(new_config); }
                    )
                }
            })
        }, 50);
    }


    update_config(new_config) {


        new_config = this.fix_config(new_config);

        this.setState({ config_filter: new_config.filter },
            () => {
                this.setState({ config_transform: new_config.transform },
                    () => {
                        this.setState({ config_plot: new_config.plot },
                            () => {
                                this.setState({ config: new_config });
                            })
                    }
                )
            });

        try {
            this.props.setProps({
                currentConfig: new_config
            });
        } catch (e) { };

    }


    update_meta(new_meta) {

        let that = this;

        this.setState({ meta: new_meta },
            () => {

                let filter_meta_out = (that.filter_ref && that.filter_ref.current) ? that.filter_ref.current.update_config(that.state.config_filter) : new_meta;

                this.setState({ filter_meta_out: filter_meta_out },
                    () => {

                        let transform_meta_out = (that.transform_ref && that.transform_ref.current) ? that.transform_ref.current.update_config(that.state.config_transform) : filter_meta_out;
                        this.setState({ transform_meta_out: transform_meta_out });

                    }
                )
            });

    }

    update_props(graphId = null) {
        let config = JSON.parse(JSON.stringify(this.state.config));

        config["graphId"] = graphId;

        if (config.filter == null || config.filter === undefined || config.filter.length === 0) {
            delete config["filter"];
        }

        if (config.transform == null || config.transform === undefined || config.transform.length === 0) {
            delete config["transform"];
        }

        if (config.parameterization == null || config.parameterization === undefined || config.parameterization.parameters === undefined
            || config.parameterization.parameters.length === 0) {
            delete config["parameterization"];
        }

        if (config.graphId === null || config.graphId === undefined) {
            delete config["graphId"];
        }

        if (config.plot != null && config.plot.prevent_update != null) {
            delete config["plot"]["prevent_update"];
        }

        this.props.setProps({
            config: JSON.parse(JSON.stringify(config))
        });
    }


    /**
     * external parameters like the dataframe metadata might change.
     * Then we have to update the content
     * @private
     */
    UNSAFE_componentWillReceiveProps(newProps) {

        if (newProps.config !== this.props.config) {
            let config = this.fix_config(JSON.parse(JSON.stringify(newProps.config)));
            //let config = JSON.parse(JSON.stringify(this.fix_config(newProps.config)));

            this.update_config(config);
        }

        if (newProps.meta !== this.props.meta) {

            let new_meta = JSON.parse(JSON.stringify(newProps.meta));
            this.update_meta(new_meta);

        }



    }


    handleClose() {

        if (this.state.modal_close_timeout != undefined) {
            clearTimeout(this.state.modal_close_timeout);
        }
        this.setState({ showEditModal: false, modal_close_timeout: undefined });

    }


    render() {
        const { id } = this.props;
        const { meta, filter_meta_out, transform_meta_out, showEditModal, eventGraphId } = this.state;
        let { config,
            config_filter,
            config_transform,
            config_plot
        } = this.state;

        return (

            <Accordion id={id} key={id} defaultActiveKey="plotter">

                {this.props.showFilter && <CustomAccordionItem title="Filter">
                    < Filter
                        id={`${id}-filter`}
                        key={`${id}-filter`}
                        ref={this.filter_ref}
                        meta={meta}
                        config={config_filter}
                        setProps={
                            out => {
                                if ("config" in out) {
                                    //let new_config = { ...config };
                                    //new_config["filter"] = out.config;
                                    this.update_sub_config({ filter: out.config });
                                }

                                if ("meta_out" in out) {
                                    this.setState({ filter_meta_out: out.meta_out });
                                }
                            }}
                    />
                </CustomAccordionItem>}


                {this.props.showTransform && <CustomAccordionItem title="Transform">
                    <Transform
                        id={`${id}-transform`}
                        key={`${id}-transform`}
                        ref={this.transform_ref}
                        meta={filter_meta_out}
                        config={config_transform}
                        setProps={
                            out => {
                                if ("config" in out) {
                                    //let new_config = { ...config };
                                    //new_config["transform"] = out.config;
                                    this.update_sub_config({ transform: out.config });
                                }

                                if ("meta_out" in out) {
                                    this.setState({ transform_meta_out: out.meta_out });
                                }
                            }}
                    />
                </CustomAccordionItem>}

                {this.props.showMetadata && <CustomAccordionItem title="Data Columns">
                    <MetaCheck
                        id={`${id}-metacheck`}
                        key={`${id}-metacheck`}
                        meta={transform_meta_out}
                        setProps={out => { }}
                    />
                </CustomAccordionItem>}

                {this.props.showPlotter && <CustomAccordionItem title="Plotter" defaultOpen>
                    <Plotter
                        id={`${id}-plotter`}
                        key={`${id}-plotter`}
                        meta={transform_meta_out}
                        config={config_plot}
                        setProps={
                            out => {
                                if ("config" in out) {
                                    //let new_config = { ...config };
                                    //new_config["plot"] = out.config;
                                    this.update_sub_config({ plot: out.config });
                                }
                            }}
                    />
                </CustomAccordionItem>}


                {this.props.showUpdate && <div className='accordion-item' key={`${id}-save-btn`}>
                    <h2 className='accordion-header'>
                        <ButtonGroup className='w-100 p-3'>
                            <Button
                                onClick={e => { this.update_props(); }}
                                size="lg"
                                variant="outline-primary"
                            >New Plot</Button>
                            {/* enable update plot button only if available */}
                            {eventGraphId !== "" && false && <Button
                                onClick={e => { this.update_props(eventGraphId); }}
                                size="lg"
                                variant="outline-primary"
                            >Update Plot</Button>}
                        </ButtonGroup>
                    </h2>
                </div>}

                {this.props.showParameterization && <CustomAccordionItem title="Parameterize" defaultOpen>
                    <Parameterize
                        id={`${id}-parametrize`}
                        key={`${id}-parametrize`}
                        meta={transform_meta_out}
                        config={{ ...config }}
                        setProps={out => {
                            if ("config" in out) {
                                let new_config = { ...out.config };
                                new_config = JSON.parse(JSON.stringify(new_config));
                                config = new_config;
                                this.update_config(new_config);
                            }
                        }}
                    />
                </CustomAccordionItem>}

                {this.props.showStore && <CustomAccordionItem title="Store">
                    <Localstore
                        id={`${id}-store`}
                        key={`${id}-store`}
                        meta={transform_meta_out}
                        config={config}
                        setProps={out => {
                            if ("config" in out) {
                                let new_config = { ...out.config }
                                this.update_config(new_config);
                            }
                        }}
                    />
                </CustomAccordionItem>}


                <Modal
                    backdrop="static"
                    animation={false}
                    show={showEditModal}
                    onHide={() => this.handleClose()}>
                    <Modal.Header closeButton>
                        <Modal.Title>Load Plot Config</Modal.Title>
                    </Modal.Header>
                    <Modal.Body><div style={{ minHeight: "15em" }} className="mb-3">

                        Do you want the load the plot configuration for this plot:
                        <img src={this.state.eventThumbnail} alt="Plot Image" className='w-100' />

                        <b>Note!</b> It will replace the current plot configuration.
                    </div>
                    </Modal.Body>
                    <Modal.Footer>
                        <Button variant="secondary" onClick={() => this.handleClose()}>
                            Close
                        </Button>
                        <Button variant="primary" onClick={(e) => {

                            this.update_config(
                                this.state.eventConfig
                            );
                            //perhaps we want to do something with the eventGraphId
                            //to update the plot just for the right id?

                            this.handleClose();
                        }}>
                            Load
                        </Button>
                    </Modal.Footer>
                </Modal>


            </Accordion >






        );
    }
}

Configurator.defaultProps = {
    showFilter: true,
    showTransform: true,
    showPlotter: true,
    showMetadata: false,
    showParameterization: false,
    showStore: false,
    showUpdate: true
};

/**
 * @typedef
 * @public
 * @enum {}
 */
Configurator.propTypes = {
    /**
     * The ID used to identify this component in Dash callbacks.
     * @type {string}
     */
    id: PropTypes.string.isRequired,


    /**
     * The metadata the plotter selection is based on.
     * @type {Object}
     */
    meta: PropTypes.any.isRequired,

    /**
     * The resulting configuration of the plot.
     * @type {Object}
     */
    config: PropTypes.any,


    /**
     * The current configuration of the plot.
     * @type {Object}
     */
    currentConfig: PropTypes.any,


    /**
     * Prop to define the visibility of the Filter panel
     * @type {boolean}
     */
    showFilter: PropTypes.bool,

    /**
     * Prop to define the visibility of the Transform panel
     * @type {boolean}
     */
    showTransform: PropTypes.bool,

    /**
     * Prop to define the visibility of the Plot panel
     * @type {boolean}
     */
    showPlotter: PropTypes.bool,

    /**
     * Prop to define the visibility of the Metadata panel
     * @type {boolean}
     */
    showMetadata: PropTypes.bool,

    /**
     * Prop to define the visibility of the Parameterization panel
     * @type {boolean}
     */
    showParameterization: PropTypes.bool,

    /**
     * Prop to define the visibility of the Store panel
     * @type {boolean}
     */
    showStore: PropTypes.bool,

    /**
     * Prop to define the visibility of the update plot button
     * @type {boolean}
     */
    showUpdate: PropTypes.bool,

    /**
     * Dash-assigned callback that should be called to report property changes
     * to Dash, to make them available for callbacks.
     * @private
     */
    setProps: PropTypes.func



}


/**
 * @private
 */
export default Configurator;