Transform.react.js

import React from 'react';
import Base from './_sub/Base.react';
import PropTypes from 'prop-types';

import Alert from 'react-bootstrap/Alert';
import Button from 'react-bootstrap/Button';
import Modal from 'react-bootstrap/Modal';
import Form from 'react-bootstrap/Form';
import InputGroup from 'react-bootstrap/InputGroup';

import Accordion from 'react-bootstrap/Accordion';

import AggrTransform from './_sub/AggrTransform.react';
import EvalTransform from './_sub/EvalTransform.react';
import MeltTransform from './_sub/MeltTransform.react';
import CombinecatTransform from './_sub/CombinecatTransform.react';
import CategoryLookup from './_sub/CategoryLookup.react';
import DropnaTransform from './_sub/DropnaTransform.react';
import WideToLong from './_sub/WideToLong.react';
import ZerosToNanTransform from './_sub/ZerosToNanTransform.react';
import RenameTransform from './_sub/RenameTransform.react';
import StrSplitTransform from './_sub/StrSplitTransform.react';
import BinTransform from './_sub/BinTransform.react';
import FilterIqrTransform from './_sub/FilterIqrTransform.react';
import AsType from './_sub/AsType.react';
import GroupedSample from './_sub/GroupedSample.react';
import PivotTableTransform from './_sub/PivotTableTransform.react';


import AggrSvg from 'react-svg-loader!./_svg/aggr.svg';
import EvalSvg from 'react-svg-loader!./_svg/eval.svg';
import MeltSvg from 'react-svg-loader!./_svg/melt.svg';
import CombinecatSvg from 'react-svg-loader!./_svg/combinecat.svg';
import CatlookupSvg from 'react-svg-loader!./_svg/catlookup.svg';
import DropnaSvg from 'react-svg-loader!./_svg/dropna.svg';
import WideToLongSvg from 'react-svg-loader!./_svg/wide_to_long.svg';
import ZerostoNanSvg from 'react-svg-loader!./_svg/zerostonan.svg';
import RenameSvg from 'react-svg-loader!./_svg/rename.svg';
import StrSplitSvg from 'react-svg-loader!./_svg/str.split.svg';
import BinSvg from 'react-svg-loader!./_svg/bin.svg';
import FilterIqrSvg from 'react-svg-loader!./_svg/filteriqr.svg';
import AsTypeSvg from 'react-svg-loader!./_svg/astype.svg';
import GroupedSampleSvg from 'react-svg-loader!./_svg/groupedsample.svg';
import PivotTableTransformSvg from 'react-svg-loader!./_svg/pivottable.svg';



/**
 * <div style="width:450px; margin-left: 20px; float: right;  margin-top: -150px;">
 * <img src="https://raw.githubusercontent.com/VK/dash-express-components/main/.media/transform.png"/>
 * <img src="https://raw.githubusercontent.com/VK/dash-express-components/main/.media/transform-modal.png"/>
 * <img src="https://raw.githubusercontent.com/VK/dash-express-components/main/.media/transform-types.png"/>
 * </div>
 * 
 * The `Transform` component helps to create user defined data transformations.
 * Currently basic transformations are available, like:
 * 
 * <ul style="margin-left: 20px;">
 *    <li><b>eval</b></li>
 *    <li><b>groupby([...]).aggr([...])</b></li>
 *    <li><b>melt</b></li>
 *    <li><b>wide_to_long</b></li>
 *    <li><b>replace</b></li>
 *    <li><b>rename</b></li>
 * </ul>
 * @hideconstructor
 * 
 * @example
 * import dash_express_components as dxc
 * import plotly.express as px
 * 
 * meta = dxc.get_meta(px.data.gapminder())
 * 
 * dxc.Transform(
 *    id="transform",
 *    meta=meta
 * )
 * @public
 */
class Transform extends Base {

    static trafo_groups = [
        { label: "New columns", value: "col" },
        { label: "Reshape data", value: "reshape" },
        { label: "Metadata", value: "meta" },
        { label: "Missing & filter data", value: "missing" }
    ]

    static known_trafos = [
        {
            group: "col", type: "eval", class: EvalTransform,
            "label": "Compute new column", svg: <EvalSvg />
        },
        {
            group: "col", type: "strsplit", class: StrSplitTransform,
            "label": "Compute a string split", svg: <StrSplitSvg />
        },
        {
            group: "reshape", type: "aggr", class: AggrTransform,
            "label": "Aggregate grouped dataset", svg: <AggrSvg />
        },
        {
            group: "col", type: "combinecat", class: CombinecatTransform,
            "label": "Combine multiple columns to new category", svg: <CombinecatSvg />
        },
        {
            group: "reshape", type: "melt", class: MeltTransform,
            "label": "Rearrange multiple colums to one", svg: <MeltSvg />
        },
        {
            group: "reshape", type: "wide_to_long", class: WideToLong,
            "label": "Rearrange columns based on naming", svg: <WideToLongSvg />
        },
        {
            group: "reshape", type: "pivot_table", class: PivotTableTransform,
            "label": "Create a pivot table", svg: <PivotTableTransformSvg />
        },        
        {
            group: "col", type: "catlookup", class: CategoryLookup,
            "label": "Apply a lookup on a categorical column", svg: <CatlookupSvg />
        },
        {
            group: "missing", type: "dropna", class: DropnaTransform,
            "label": "Remove rows with nan values", svg: <DropnaSvg />
        },
        {
            group: "missing", type: "zerostonan", class: ZerosToNanTransform,
            "label": "Replace zero values with nan values", svg: <ZerostoNanSvg />
        },
        {
            group: "missing", type: "filteriqr", class: FilterIqrTransform,
            "label": "Apply a grouped IQR filter", svg: <FilterIqrSvg />
        },
        {
            group: "missing", type: "groupby_sample", class: GroupedSample,
            "label": "Apply a grouped sampling", svg: <GroupedSampleSvg />
        },
        {
            group: "meta", type: "rename", class: RenameTransform,
            "label": "Rename multiple columns", svg: <RenameSvg />
        },
        {
            group: "meta", type: "as_type", class: AsType,
            "label": "Recast multiple columns", svg: <AsTypeSvg />
        },
        {
            group: "col", type: "bin", class: BinTransform,
            "label": "Compute a binned variable", svg: <BinSvg />
        },
    ]


    constructor(props) {
        super([], props);



        this.state =
        {
            ...this.state,

            /* state of the modal to add new filters */
            showAddModal: false,
            showChooseModal: false,

            transformIndex: undefined,

            transformType: "",
            sub_config: {}
        };

        this.update_config(this.state.config, true);
    }

    handleClose() {
        this.setState({ showAddModal: false });
    }
    handleShow() {
        this.setState({ showAddModal: true });
    }

    handleChooseClose() {
        this.setState({ showChooseModal: false });
    }
    handleChooseShow() {
        this.setState({ showChooseModal: true });
    }

    UNSAFE_componentWillReceiveProps(newProps) {
        const update_config_needed = (newProps.config !== this.props.config);

        super.UNSAFE_componentWillReceiveProps(newProps);

        if (update_config_needed && "config" in newProps) {
            this.update_config(newProps.config);
        }

    }


    update_config(new_config, constructor = false) {
        super.update_config(new_config, constructor);

        //let new_meta = JSON.parse(JSON.stringify(this.state.meta))
        let new_meta = { ...this.state.meta };
        let meta_stages = [];
        let stage_results = [];
        meta_stages.push(new_meta);

        if (new_config)
            new_config.forEach(el => {

                // if the transform is not known, skip it
                if (Transform.known_trafos.filter(t => t["type"] === el["type"]).length !== 0) {

                    let transform_class = Transform.known_trafos.filter(t => t["type"] === el["type"])[0]["class"];
                    let res = transform_class.eval(
                        {
                            ...el,
                            meta: new_meta
                        }
                    );
                    if (res["new_meta"] != undefined) {
                        new_meta = res["new_meta"];
                        meta_stages.push(new_meta);
                    } else {
                        meta_stages.push({});
                    }
                    stage_results.push(res);

                } else {
                    // TODO add a server callback to get the result of the transform
                    stage_results.push({ error: false, message: "Unknown transform type: " + el["type"] });
                    meta_stages.push({});
                }


            });

        super.update_meta_out(new_meta, constructor);


        if (constructor) {
            this.state = {
                ...this.state,
                meta_stages: meta_stages,
                stage_results: stage_results,
                ...this.get_columns(new_meta)
            }
        } else {
            this.setState({
                meta_stages: meta_stages,
                stage_results: stage_results,
                ...this.get_columns(new_meta)
            });
        }

        return new_meta;
    }

    get_transform_blocks() {
        const { config, stage_results } = this.state;

        if (config) {
            return <div>
                {
                    config.map((el, id) => {


                        // if the transform is not known, we use a generic block
                        if (Transform.known_trafos.filter(t => t["type"] === el["type"]).length === 0) {
                            return <Alert variant='warning' key={id}>
                                <pre className='mb-0'>{JSON.stringify(el, null, 2)}</pre>
                            </Alert>
                        }

                        let transform_class = Transform.known_trafos.filter(t => t["type"] === el["type"])[0]["class"];

                        let config_string = transform_class.config_to_string(el);
                        let variant = (stage_results[id].error) ? 'secondary' : 'primary';
                        let error_string = (stage_results[id].error) ? (<span className="text-danger"><br /><b>Error: </b>{stage_results[id].message}</span>) : '';

                        return (<Alert dismissible variant={variant} key={id} onClose={() => {

                            let new_config = config.filter((e, idx) => idx !== id);
                            this.update_config(new_config)

                        }}>

                            {config_string}
                            {error_string}

                            <button className='btn-close btn-edit'

                                onClick={() => {

                                    let update_state = {
                                        transformIndex: id,
                                        transformType: el.type,
                                        sub_config: el
                                    };

                                    this.setState(update_state, () => {
                                        this.handleShow();
                                    })

                                }}
                            ></button>

                        </Alert>)
                    }
                    )
                }

            </div>
        }
    }







    get_modal_blocks() {

        const {
            allColOptions,
            catColOptions,
            numColOptions,
            allOptions,
            meta_out,


            showAddModal,
            transformType,

            sub_config,
            config,

            transformIndex,
            meta_stages
        } = this.state;

        const {
            id
        } = this.props;

        const stt = Transform.known_trafos.filter((el) => el.type === transformType);
        const tt = (stt.length === 1) ? stt[0] : undefined;


        return (<Modal
            centered
            backdrop="static"
            animation={false}
            show={showAddModal}
            onHide={() => this.handleClose()
            }
        >
            <Modal.Header closeButton>
                <Modal.Title>{(stt.length === 1) ? tt.label : "Add transform"}</Modal.Title>
            </Modal.Header>
            <Modal.Body><div style={{ minHeight: "15em" }} className="mb-3">


                <Button
                    key={id + "change-transform-button"}
                    variant="outline-secondary"
                    className="d-flex align-items-center w-100 mb-2"
                    onClick={() => this.handleChooseShow()}
                    style={{ "height": "110px" }}
                >

                    {(tt && "svg" in tt) && <div className="w-100">{tt.svg}</div>}
                    {!(tt && "svg" in tt) && <div className="w-100 h3">Choose a transformation type</div>}

                </Button>



                {
                    Transform.known_trafos.map(trafo_el => {

                        let input_meta = (transformIndex === undefined) ? meta_out : meta_stages[transformIndex];
                        let stage_options = this.get_columns(input_meta);

                        return (
                            transformType === trafo_el["type"] &&
                            <trafo_el.class
                                key={"config" + trafo_el["type"]}
                                config={sub_config}
                                meta={input_meta}
                                allColOptions={stage_options.allColOptions}
                                catColOptions={stage_options.catColOptions}
                                numColOptions={stage_options.numColOptions}
                                allOptions={stage_options.allOptions}
                                setProps={e => { if ("config" in e) { this.setState({ sub_config: e.config }) } }}
                            />
                        )
                    })
                }


            </div>
            </Modal.Body>
            <Modal.Footer>
                <Button variant="secondary" onClick={() => this.handleClose()}>
                    Close
                </Button>
                <Button variant="primary" onClick={() => {

                    if ("type" in sub_config) {


                        if (transformIndex === undefined) {
                            //add a new transform


                            let transform_class = Transform.known_trafos.filter(el => el["type"] === sub_config["type"])[0]["class"];
                            let res = transform_class.eval(
                                {
                                    ...sub_config,
                                    meta: meta_out
                                }
                            );

                            if (!res.error || window.confirm("Do you want to add the transform, even with errors?")) {
                                let new_config = [
                                    ...config,
                                    sub_config
                                ];

                                this.update_config(new_config);
                                this.handleClose();
                            }

                        } else {
                            //update a transform

                            let transform_class = Transform.known_trafos.filter(el => el["type"] === sub_config["type"])[0]["class"];
                            let res = transform_class.eval(
                                {
                                    ...sub_config,
                                    meta: meta_stages[transformIndex]
                                }
                            );

                            if (!res.error || window.confirm("Do you want to add the transform, even with errors?")) {
                                let new_config = JSON.parse(JSON.stringify(config));
                                new_config[transformIndex] = sub_config;

                                this.update_config(new_config);
                                this.handleClose();
                            }
                        }


                    }


                }}>
                    {(transformIndex === undefined) ? "Add" : "Update"}
                </Button>
            </Modal.Footer>
        </Modal>)

    }



    get_choose_modal() {

        const {
            showChooseModal
        } = this.state;

        const {
            id
        } = this.props;

        return (<Modal
            size="xl"
            centered
            backdrop="static"
            animation={false}
            show={showChooseModal}
            onHide={() => this.handleChooseClose()}
            key={id + "-trafo-type-modal"}
        >
            <Modal.Header closeButton>
                <Modal.Title>Transformation Types</Modal.Title>
            </Modal.Header>
            <Modal.Body><div className="mt-2 dxc-container dxc-row" style={{ padding: 0 }}>

                {Transform.trafo_groups.map(gr => {

                    return <div className='dxc-p-1 dxc-col-6'><h4>{gr.label}</h4> {


                        Transform.known_trafos.filter(pt => pt.group == gr.value).map(pt => {


                            return (
                                <Button
                                    key={"set-plot-" + pt.type}
                                    variant="outline-secondary"
                                    className="d-flex align-items-center w-100 mb-2"

                                    onClick={(e) => {

                                        this.setState({
                                            transformType: pt.type
                                        });
                                        this.handleChooseClose()
                                    }}

                                >

                                    <div style={{ "transform": "scale(.6)", "transformOrigin": "0 0", width: "180px", height: "60px" }}>{(pt && "svg" in pt) ? pt.svg : ""}</div>
                                    <div className="flex-grow-1 m-1 h5">
                                        {(pt && "label" in pt) ? pt.label : ""}
                                    </div>

                                </Button>
                            );
                        })
                    } </div>


                })

                }




            </div>
            </Modal.Body>
            <Modal.Footer>
                <Button variant="secondary" onClick={() => this.handleChooseClose()}>
                    Close
                </Button>

            </Modal.Footer>
        </Modal>)
    }

    render() {

        return (
            <div>
                {this.get_transform_blocks()}

                <Button className='w-100' onClick={() => {
                    this.setState({
                        transformType: "",
                        sub_config: {},
                        transformIndex: undefined
                    }, () => {
                        this.handleShow();
                    });
                }}>
                    Add transformation
                </Button>

                {this.get_modal_blocks()}
                {this.get_choose_modal()}
            </div>
        )

    }
}



Transform.defaultProps = {};

/**
 * @typedef
 * @public
 * @enum {}
 */
Transform.propTypes = {

    /**
     * The ID used to identify this component in Dash callbacks.
     */
    id: PropTypes.string.isRequired,

    /**
    * The config the user sets in this component.
    */
    config: PropTypes.any,

    /**
     * The metadata this section is based on.
     */
    meta: PropTypes.any.isRequired,


    /**
     * The metadata section will create as output.
     */
    meta_out: PropTypes.any,


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


/**
 * @private
 */
export default Transform;