Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
QueryVisualizationTable.react.js 10.49 KiB
"use strict";

import React, { Component, PropTypes } from "react";

import { Table, Column } from 'fixed-data-table';
import Icon from "metabase/components/Icon.react";
import Popover from "metabase/components/Popover.react";

import MetabaseAnalytics from '../lib/analytics';
import DataGrid from "metabase/lib/data_grid";
import { formatCell } from "metabase/lib/formatting";

import _ from "underscore";
import cx from "classnames";

export default class QueryVisualizationTable extends Component {
    constructor(props) {
        super(props);

        this.state = {
            width: 0,
            height: 0,
            columnWidths: [],
            popover: null,
            data: null,
            rawData: null,
            contentWidths: null
        };

        _.bindAll(this, "onClosePopover", "rowGetter", "cellRenderer", "columnResized");

        this.isColumnResizing = false;
    }

    componentWillMount() {
        this.componentWillReceiveProps(this.props);
    }

    componentWillReceiveProps(newProps) {
        if (newProps.data && newProps.data !== this.state.rawData) {
            let gridData = (newProps.pivot) ? DataGrid.pivot(newProps.data) : newProps.data;
            this.setState({
                data: gridData,
                rawData: this.props.data
            });
            if (JSON.stringify(this.state.data && this.state.data.cols) !== JSON.stringify(gridData.cols)) {
                this.setState({
                    columnWidths: gridData.cols.map(col => 0), // content cells don't wrap so this is fine
                    contentWidths: null
                });
            }
        }
    }

    componentDidMount() {
        this.calculateSizing(this.state);
    }

    shouldComponentUpdate(nextProps, nextState) {
        // this is required because we don't pass in the containing element size as a property :-/
        // if size changes don't update yet because state will change in a moment
        this.calculateSizing(nextState);

        // compare props and state to determine if we should re-render
        // NOTE: this is essentially the same as React.addons.PureRenderMixin but
        // we currently need to recalculate the container size here.
        return !_.isEqual(this.props, nextProps) || !_.isEqual(this.state, nextState);
    }

    componentDidUpdate() {
        if (!this.state.contentWidths) {
            let tableElement = React.findDOMNode(this.refs.table);
            let contentWidths = [];
            for (let rowElement of tableElement.querySelectorAll(".fixedDataTableRowLayout_rowWrapper")) {
                for (let [index, cellDataElement] of Object.entries(rowElement.querySelectorAll(".public_fixedDataTableCell_cellContent"))) {
                    contentWidths[index] = Math.max(contentWidths[index] || 0, cellDataElement.offsetWidth);
                }
            }
            this.setState({ contentWidths }, () => this.calculateColumnWidths(this.state.data.cols));
        }
    }

    calculateColumnWidths(cols) {
        let columnWidths = cols.map((col, index) => {
            if (this.state.contentWidths) {
                return Math.min(this.state.contentWidths[index] + 1, 300); // + 1 to make sure it doen't wrap?
            } else {
                return 300;
            }
        });
        this.setState({ columnWidths });
    }

    calculateSizing(prevState, force) {
        var element = React.findDOMNode(this);

        // account for padding of our parent
        var style = window.getComputedStyle(element.parentElement, null);
        var paddingTop = Math.ceil(parseFloat(style.getPropertyValue("padding-top")));
        var paddingLeft = Math.ceil(parseFloat(style.getPropertyValue("padding-left")));
        var paddingRight = Math.ceil(parseFloat(style.getPropertyValue("padding-right")));

        var width = element.parentElement.offsetWidth - paddingLeft - paddingRight;
        var height = element.parentElement.offsetHeight - paddingTop;

        if (width !== prevState.width || height !== prevState.height || force) {
            this.setState({ width, height });
        }
    }

    isSortable() {
        return (this.props.setSortFn !== undefined);
    }

    setSort(fieldId) {
        this.props.setSortFn(fieldId);

        MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Sort', 'table column');
    }

    cellClicked(rowIndex, columnIndex) {
        this.props.cellClickedFn(rowIndex, columnIndex);
    }

    popoverFilterClicked(rowIndex, columnIndex, operator) {
        this.props.cellClickedFn(rowIndex, columnIndex, operator);
        this.setState({ popover: null });
    }

    rowGetter(rowIndex) {
        var row = {
            hasPopover: this.state.popover && this.state.popover.rowIndex === rowIndex || false
        };
        for (var i = 0; i < this.state.data.rows[rowIndex].length; i++) {
            row[i] = this.state.data.rows[rowIndex][i];
        }
        return row;
    }

    showPopover(rowIndex, cellDataKey) {
        this.setState({
            popover: {
                rowIndex: rowIndex,
                cellDataKey: cellDataKey
            }
        });
    }

    onClosePopover() {
        this.setState({ popover: null });
    }

    cellRenderer(cellData, cellDataKey, rowData, rowIndex, columnData, width) {
        cellData = cellData != null ? formatCell(cellData, this.props.data.cols[cellDataKey]) : null;

        var key = 'cl'+rowIndex+'_'+cellDataKey;
        if (this.props.cellIsClickableFn(rowIndex, cellDataKey)) {
            return (
                <a key={key} className="link cellData" href="#" onClick={this.cellClicked.bind(this, rowIndex, cellDataKey)}>{cellData}</a>
            );
        } else {
            var popover = null;
            if (this.state.popover && this.state.popover.rowIndex === rowIndex && this.state.popover.cellDataKey === cellDataKey) {
                popover = (
                    <Popover
                        tetherOptions={{
                            targetAttachment: "middle center",
                            attachment: "middle center"
                        }}
                        onClose={this.onClosePopover}
                    >
                        <div className="bg-white bordered shadowed p1">
                            <ul className="h1 flex align-center">
                                { ["<", "=", "≠", ">"].map(operator =>
                                    <li key={operator} className="p2 text-brand-hover" onClick={this.popoverFilterClicked.bind(this, rowIndex, cellDataKey, operator)}>{operator}</li>
                                )}
                            </ul>
                        </div>
                    </Popover>
                );
            }
            return (
                <div key={key} onClick={this.showPopover.bind(this, rowIndex, cellDataKey)}>
                    <span className="cellData">{cellData}</span>
                    {popover}
                </div>
            );
        }
    }

    columnResized(width, idx) {
        var tableColumnWidths = this.state.columnWidths.slice();
        tableColumnWidths[idx] = width;
        this.setState({
            columnWidths: tableColumnWidths
        });
        this.isColumnResizing = false;
    }

    tableHeaderRenderer(columnIndex) {
        var column = this.state.data.cols[columnIndex],
            colVal = (column && column.display_name && column.display_name.toString()) ||
                     (column && column.name && column.name.toString());

        if (!colVal && this.props.pivot && columnIndex !== 0) {
            colVal = "Unset";
        }

        var headerClasses = cx('MB-DataTable-header align-center', {
            'MB-DataTable-header--sorted': (this.props.sort && (this.props.sort[0][0] === column.id)),
        });

        // set the initial state of the sorting indicator chevron
        var sortChevron = (<Icon name="chevrondown" width="8px" height="8px"></Icon>);

        if(this.props.sort && this.props.sort[0][1] === 'ascending') {
            sortChevron = (<Icon name="chevronup" width="8px" height="8px"></Icon>);
        }

        if (this.isSortable()) {
            // ICK.  this is hacky for dealing with aggregations.  need something better
            var fieldId = (column.id) ? column.id : "agg";

            return (
                <div key={columnIndex} className={headerClasses} onClick={this.setSort.bind(this, fieldId)}>
                    <span>
                        {colVal}
                    </span>
                    <span className="ml1">
                        {sortChevron}
                    </span>
                </div>
            );
        } else {
            return (
                <span className={headerClasses}>
                    {colVal}
                </span>
            );
        }
    }

    render() {
        if(!this.state.data) {
            return false;
        }

        var tableColumns = this.state.data.cols.map((column, idx) => {
            var colVal = (column !== null) ? column.name.toString() : null;
            var colWidth = this.state.columnWidths[idx];

            if (!colWidth) {
                colWidth = 75;
            }

            return (
                <Column
                    key={'col_' + idx}
                    className="MB-DataTable-column"
                    width={colWidth}
                    isResizable={true}
                    headerRenderer={this.tableHeaderRenderer.bind(this, idx)}
                    cellRenderer={this.cellRenderer}
                    dataKey={idx}
                    label={colVal}>
                </Column>
            );
        });

        return (
            <span className={cx('MB-DataTable', { 'MB-DataTable--pivot': this.props.pivot, 'MB-DataTable--ready': this.state.contentWidths })}>
                <Table
                    ref="table"
                    rowHeight={35}
                    rowGetter={this.rowGetter}
                    rowsCount={this.state.data.rows.length}
                    width={this.state.width}
                    height={this.state.height}
                    headerHeight={50}
                    isColumnResizing={this.isColumnResizing}
                    onColumnResizeEndCallback={this.columnResized}
                >
                    {tableColumns}
                </Table>
            </span>
        );
    }
}

QueryVisualizationTable.propTypes = {
    data: PropTypes.object,
    sort: PropTypes.array,
    setSortFn: PropTypes.func,
    isCellClickableFn: PropTypes.func,
    cellClickedFn: PropTypes.func
};

QueryVisualizationTable.defaultProps = {
    maxRows: 2000,
    minColumnWidth: 75
};