Source: charts/data-table.js

import {ascending} from 'd3-array';

import {logger} from '../core/logger';
import {BaseMixin} from '../base/base-mixin';
import {d3compat} from '../core/config';

const LABEL_CSS_CLASS = 'dc-table-label';
const ROW_CSS_CLASS = 'dc-table-row';
const COLUMN_CSS_CLASS = 'dc-table-column';
const SECTION_CSS_CLASS = 'dc-table-section dc-table-group';
const HEAD_CSS_CLASS = 'dc-table-head';

/**
 * The data table is a simple widget designed to list crossfilter focused data set (rows being
 * filtered) in a good old tabular fashion.
 *
 * An interesting feature of the data table is that you can pass a crossfilter group to the
 * `dimension`, if you want to show aggregated data instead of raw data rows. This requires no
 * special code as long as you specify the {@link DataTable#order order} as `d3.descending`,
 * since the data table will use `dimension.top()` to fetch the data in that case, and the method is
 * equally supported on the crossfilter group as the crossfilter dimension.
 *
 * If you want to display aggregated data in ascending order, you will need to wrap the group
 * in a [fake dimension](https://github.com/dc-js/dc.js/wiki/FAQ#fake-dimensions) to support the
 * `.bottom()` method. See the example linked below for more details.
 *
 * Note: Formerly the data table (and data grid chart) used the {@link DataTable#group group} attribute as a
 * keying function for {@link https://github.com/d3/d3-collection/blob/master/README.md#nest nesting} the data
 * together in sections.  This was confusing so it has been renamed to `section`, although `group` still works.
 * Examples:
 * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index}
 * - {@link http://dc-js.github.io/dc.js/examples/table-on-aggregated-data.html dataTable on a crossfilter group}
 * ({@link https://github.com/dc-js/dc.js/blob/master/web-src/examples/table-on-aggregated-data.html source})
 *
 * @mixes BaseMixin
 */
export class DataTable extends BaseMixin {
    /**
     * Create a Data Table.
     *
     * @param {String|node|d3.selection} parent - Any valid
     * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying
     * a dom block element such as a div; or a dom element or d3 selection.
     * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.
     * Interaction with a chart will only trigger events and redraws within the chart's group.
     */
    constructor (parent, chartGroup) {
        super();

        this._size = 25;
        this._columns = [];
        this._sortBy = d => d;
        this._order = ascending;
        this._beginSlice = 0;
        this._endSlice = undefined;
        this._showSections = true;
        this._section = () => ''; // all in one section

        this._mandatoryAttributes(['dimension']);

        this.anchor(parent, chartGroup);
    }

    _doRender () {
        this.selectAll('tbody').remove();

        this._renderRows(this._renderSections());

        return this;
    }

    _doColumnValueFormat (v, d) {
        return (typeof v === 'function') ? v(d) :  // v as function
            (typeof v === 'string') ? d[v] :       // v is field name string
            v.format(d);                           // v is Object, use fn (element 2)
    }

    _doColumnHeaderFormat (d) {
        // if 'function', convert to string representation
        // show a string capitalized
        // if an object then display its label string as-is.
        return (typeof d === 'function') ? this._doColumnHeaderFnToString(d) :
            (typeof d === 'string') ? this._doColumnHeaderCapitalize(d) :
            String(d.label);
    }

    _doColumnHeaderCapitalize (s) {
        // capitalize
        return s.charAt(0).toUpperCase() + s.slice(1);
    }

    _doColumnHeaderFnToString (f) {
        // columnString(f) {
        let s = String(f);
        const i1 = s.indexOf('return ');
        if (i1 >= 0) {
            const i2 = s.lastIndexOf(';');
            if (i2 >= 0) {
                s = s.substring(i1 + 7, i2);
                const i3 = s.indexOf('numberFormat');
                if (i3 >= 0) {
                    s = s.replace('numberFormat', '');
                }
            }
        }
        return s;
    }

    _renderSections () {
        // The 'original' example uses all 'functions'.
        // If all 'functions' are used, then don't remove/add a header, and leave
        // the html alone. This preserves the functionality of earlier releases.
        // A 2nd option is a string representing a field in the data.
        // A third option is to supply an Object such as an array of 'information', and
        // supply your own _doColumnHeaderFormat and _doColumnValueFormat functions to
        // create what you need.
        let bAllFunctions = true;
        this._columns.forEach(f => {
            bAllFunctions = bAllFunctions & (typeof f === 'function');
        });

        if (!bAllFunctions) {
            // ensure one thead
            let thead = this.selectAll('thead').data([0]);
            thead.exit().remove();
            thead = thead.enter()
                .append('thead')
                .merge(thead);

            // with one tr
            let headrow = thead.selectAll('tr').data([0]);
            headrow.exit().remove();
            headrow = headrow.enter()
                .append('tr')
                .merge(headrow);

            // with a th for each column
            const headcols = headrow.selectAll('th')
                .data(this._columns);
            headcols.exit().remove();
            headcols.enter().append('th')
                .merge(headcols)
                .attr('class', HEAD_CSS_CLASS)
                .html(d => (this._doColumnHeaderFormat(d)));
        }

        const sections = this.root().selectAll('tbody')
            .data(this._nestEntries(), d => this.keyAccessor()(d));

        const rowSection = sections
            .enter()
            .append('tbody');

        if (this._showSections === true) {
            rowSection
                .append('tr')
                .attr('class', SECTION_CSS_CLASS)
                .append('td')
                .attr('class', LABEL_CSS_CLASS)
                .attr('colspan', this._columns.length)
                .html(d => this.keyAccessor()(d));
        }

        sections.exit().remove();

        return rowSection;
    }

    _nestEntries () {
        let entries;
        if (this._order === ascending) {
            entries = this.dimension().bottom(this._size);
        } else {
            entries = this.dimension().top(this._size);
        }

        entries = entries.sort((a, b) => this._order(this._sortBy(a), this._sortBy(b))).slice(this._beginSlice, this._endSlice)

        return d3compat.nester({
            key: this.section(),
            sortKeys: this._order,
            entries
        });
    }

    _renderRows (sections) {
        const rows = sections.order()
            .selectAll(`tr.${ROW_CSS_CLASS}`)
            .data(d => d.values);

        const rowEnter = rows.enter()
            .append('tr')
            .attr('class', ROW_CSS_CLASS);

        this._columns.forEach((v, i) => {
            rowEnter.append('td')
                .attr('class', `${COLUMN_CSS_CLASS} _${i}`)
                .html(d => this._doColumnValueFormat(v, d));
        });

        rows.exit().remove();

        return rows;
    }

    _doRedraw () {
        return this._doRender();
    }

    /**
     * Get or set the section function for the data table. The section function takes a data row and
     * returns the key to specify to {@link https://github.com/d3/d3-collection/blob/master/README.md#nest d3.nest}
     * to split rows into sections. By default there will be only one section with no name.
     *
     * Set {@link DataTable#showSections showSections} to false to hide the section headers
     *
     * @example
     * // section rows by the value of their field
     * chart
     *     .section(function(d) { return d.field; })
     * @param {Function} section Function taking a row of data and returning the nest key.
     * @returns {Function|DataTable}
     */
    section (section) {
        if (!arguments.length) {
            return this._section;
        }
        this._section = section;
        return this;
    }

    /**
     * Backward-compatible synonym for {@link DataTable#section section}.
     *
     * @param {Function} section Function taking a row of data and returning the nest key.
     * @returns {Function|DataTable}
     */
    group (section) {
        logger.warnOnce('consider using dataTable.section instead of dataTable.group for clarity');
        if (!arguments.length) {
            return this.section();
        }
        return this.section(section);
    }

    /**
     * Get or set the table size which determines the number of rows displayed by the widget.
     * @param {Number} [size=25]
     * @returns {Number|DataTable}
     */
    size (size) {
        if (!arguments.length) {
            return this._size;
        }
        this._size = size;
        return this;
    }

    /**
     * Get or set the index of the beginning slice which determines which entries get displayed
     * by the widget. Useful when implementing pagination.
     *
     * Note: the sortBy function will determine how the rows are ordered for pagination purposes.

     * See the {@link http://dc-js.github.io/dc.js/examples/table-pagination.html table pagination example}
     * to see how to implement the pagination user interface using `beginSlice` and `endSlice`.
     * @param {Number} [beginSlice=0]
     * @returns {Number|DataTable}
     */
    beginSlice (beginSlice) {
        if (!arguments.length) {
            return this._beginSlice;
        }
        this._beginSlice = beginSlice;
        return this;
    }

    /**
     * Get or set the index of the end slice which determines which entries get displayed by the
     * widget. Useful when implementing pagination. See {@link DataTable#beginSlice `beginSlice`} for more information.
     * @param {Number|undefined} [endSlice=undefined]
     * @returns {Number|DataTable}
     */
    endSlice (endSlice) {
        if (!arguments.length) {
            return this._endSlice;
        }
        this._endSlice = endSlice;
        return this;
    }

    /**
     * Get or set column functions. The data table widget supports several methods of specifying the
     * columns to display.
     *
     * The original method uses an array of functions to generate dynamic columns. Column functions
     * are simple javascript functions with only one input argument `d` which represents a row in
     * the data set. The return value of these functions will be used to generate the content for
     * each cell. However, this method requires the HTML for the table to have a fixed set of column
     * headers.
     *
     * <pre><code>chart.columns([
     *     function(d) { return d.date; },
     *     function(d) { return d.open; },
     *     function(d) { return d.close; },
     *     function(d) { return numberFormat(d.close - d.open); },
     *     function(d) { return d.volume; }
     * ]);
     * </code></pre>
     *
     * In the second method, you can list the columns to read from the data without specifying it as
     * a function, except where necessary (ie, computed columns).  Note the data element name is
     * capitalized when displayed in the table header. You can also mix in functions as necessary,
     * using the third `{label, format}` form, as shown below.
     *
     * <pre><code>chart.columns([
     *     "date",    // d["date"], ie, a field accessor; capitalized automatically
     *     "open",    // ...
     *     "close",   // ...
     *     {
     *         label: "Change",
     *         format: function (d) {
     *             return numberFormat(d.close - d.open);
     *         }
     *     },
     *     "volume"   // d["volume"], ie, a field accessor; capitalized automatically
     * ]);
     * </code></pre>
     *
     * In the third example, we specify all fields using the `{label, format}` method:
     * <pre><code>chart.columns([
     *     {
     *         label: "Date",
     *         format: function (d) { return d.date; }
     *     },
     *     {
     *         label: "Open",
     *         format: function (d) { return numberFormat(d.open); }
     *     },
     *     {
     *         label: "Close",
     *         format: function (d) { return numberFormat(d.close); }
     *     },
     *     {
     *         label: "Change",
     *         format: function (d) { return numberFormat(d.close - d.open); }
     *     },
     *     {
     *         label: "Volume",
     *         format: function (d) { return d.volume; }
     *     }
     * ]);
     * </code></pre>
     *
     * You may wish to override the dataTable functions `_doColumnHeaderCapitalize` and
     * `_doColumnHeaderFnToString`, which are used internally to translate the column information or
     * function into a displayed header. The first one is used on the "string" column specifier; the
     * second is used to transform a stringified function into something displayable. For the Stock
     * example, the function for Change becomes the table header **d.close - d.open**.
     *
     * Finally, you can even specify a completely different form of column definition. To do this,
     * override `_chart._doColumnHeaderFormat` and `_chart._doColumnValueFormat` Be aware that
     * fields without numberFormat specification will be displayed just as they are stored in the
     * data, unformatted.
     * @param {Array<Function>} [columns=[]]
     * @returns {Array<Function>}|DataTable}
     */
    columns (columns) {
        if (!arguments.length) {
            return this._columns;
        }
        this._columns = columns;
        return this;
    }

    /**
     * Get or set sort-by function. This function works as a value accessor at row level and returns a
     * particular field to be sorted by.
     * @example
     * chart.sortBy(function(d) {
     *     return d.date;
     * });
     * @param {Function} [sortBy=identity function]
     * @returns {Function|DataTable}
     */
    sortBy (sortBy) {
        if (!arguments.length) {
            return this._sortBy;
        }
        this._sortBy = sortBy;
        return this;
    }

    /**
     * Get or set sort order. If the order is `d3.ascending`, the data table will use
     * `dimension().bottom()` to fetch the data; otherwise it will use `dimension().top()`
     * @see {@link https://github.com/d3/d3-array/blob/master/README.md#ascending d3.ascending}
     * @see {@link https://github.com/d3/d3-array/blob/master/README.md#descending d3.descending}
     * @example
     * chart.order(d3.descending);
     * @param {Function} [order=d3.ascending]
     * @returns {Function|DataTable}
     */
    order (order) {
        if (!arguments.length) {
            return this._order;
        }
        this._order = order;
        return this;
    }

    /**
     * Get or set if section header rows will be shown.
     * @example
     * chart
     *     .section([value], [name])
     *     .showSections(true|false);
     * @param {Boolean} [showSections=true]
     * @returns {Boolean|DataTable}
     */
    showSections (showSections) {
        if (!arguments.length) {
            return this._showSections;
        }
        this._showSections = showSections;
        return this;
    }

    /**
     * Backward-compatible synonym for {@link DataTable#showSections showSections}.
     * @param {Boolean} [showSections=true]
     * @returns {Boolean|DataTable}
     */
    showGroups (showSections) {
        logger.warnOnce('consider using dataTable.showSections instead of dataTable.showGroups for clarity');
        if (!arguments.length) {
            return this.showSections();
        }
        return this.showSections(showSections);
    }
}

export const dataTable = (parent, chartGroup) => new DataTable(parent, chartGroup);