Source: HTMLTableWrapperListener.js

/*
 * Copyright 2020 Martin F. Schlegel Jr. | MIT AND BSD-3-Clause
 */

// Callbacks
/**
 * Called by {@link HTMLTableWrapperListener} to obtain a {@link ColumnControl} for a given column. If a value is returned, it will be used as the 
 * control for that column. If {@link Nothing} is returned, the default control ({@link HTMLTableWrapperControl}) will be created and used 
 * for that column.
 *
 * @callback HTMLTableWrapperListener~getColumnControl
 * @param {number} columnIndex Column index for which a {@link ColumnControl} is needed.
 * @param {HTMLTableWrapperListener} parent The {@link HTMLTableWrapperListener} requesting a control.
 * @returns {ColumnControl} A {@link ColumnControl} if a client-defined control is needed for the given `columnIndex`, otherwise {@link Nothing}.
 */

// Virtual Interfaces
// ColumnControlFactory
/**
 * @interface ColumnControlFactory
 * @classdesc
 *   Object-based implementation of {@link HTMLTableWrapperListener~getColumnControl}.
 */
/**
 * Implementation of {@link HTMLTableWrapperListener~getColumnControl}. See the callback's documentation for further details.
 *
 * @function ColumnControlFactory#getColumnControl
 * @param {number} columnIndex Column index for which a {@link ColumnControl} is needed.
 * @param {HTMLTableWrapperListener} parent The {@link HTMLTableWrapperListener} requesting a control.
 * @returns {ColumnControl} A {@link ColumnControl} if a client-defined control is needed for the given `columnIndex`, otherwise {@link Nothing}.
 */

// ColumnControl
/**
 * @interface ColumnControl
 * @extends Disposable
 * @classdesc
 *
 * A handle used by {@link HTMLTableWrapperListener} to open and close controls in response to client calls and end-user requests. 
 * Client code can define custom controls by implementing this interface and returning instances via an implementation of
 * {@link ColumnControlFactory}.
 */ 
/**
 * Index of the column this `ColumnControl` handles.
 *
 * @member {number} ColumnControl#columnIndex
 */
/**
 * Opens this `ColumnControl` such that it is visible to the end-user.
 *
 * @function ColumnControl#open
 */
/**
 * Closes this `ColumnControl` such that it is hidden from the end-user.
 *
 * @function ColumnControl#close
 */
/**
 * Called by {@link HTMLTableWrapperListener#processTable} to obtain a {@link FilterDescriptor} based upon the current state of this
 * `ColumnControl`. If this control is in a state in which no filtering should be performed, it is permissible to return {@link Nothing}.
 *
 * @function ColumnControl#getFilterDescriptor
 * @returns {FilterDescriptor} 
 *   A {@link FilterDescriptor} based upon the state of this `ColumnControl` or {@link Nothing} if no filtering should be performed.
 */
/**
 * Called by {@link HTMLTableWrapperListener#processTable} to obtain a {@link SortDescriptor} based upon the current state of this
 * `ColumnControl`. If this control is in a state in which no sorting should be performed, it is permissible to return {@link Nothing}.
 *
 * @function ColumnControl#getSortDescriptor
 * @returns {SortDescriptor}
 *   A {@link SortDescriptor} based upon the state of this `ColumnControl` or {@link Nothing} if no sorting should be performed.
 */

 
 

// Constructor
/**
 * @constructor
 * @implements Disposable
 * @param {(HTMLTableElement|HTMLTableWrapper)} table `HTMLTableElement` or {@link HTMLTableWrapper} backing this listener.
 * @param {ColumnControlFactory|HTMLTableWrapperListener~getColumnControl} [columnControlFactory] Optional factory if custom column controls are needed.
 * @param {CellInterpreter|HTMLTableWrapperControl~populateCellValues} [cellInterpreter]
 *   Interpreter to use when falling back to the default {@link HTMLTableWrapperControl} on calls to {@link HTMLTableWrapperListener#getColumnControl}.
 * @classdesc
 *
 * Facilitates communication between the API-level {@link HTMLTableWrapper} and the UI-level {@link ColumnControl}. In addition to the constraints
 * placed on the backing `HTMLTableElement` in {@link HTMLTableWrapper}, this class also requires it to define a table header section
 * (`<thead>`) with the first row's cells defining the column headers for the table. Although the table can define more than one row in its table
 * header section, only the first row will be processed by this class.
 * 
 * Upon {@link HTMLTableWrapperListener#init initialization}, this class will add itself as a listener for click events on
 * all cells in the first row of the backing `HTMLTableElement`'s table header section, as well as add the class name
 * {@link HTMLTableWrapperListener.processedColumnHeader} to each cell.
 * 
 * In response to click events, the appropriate {@link ColumnControl} will be created using the given {@link ColumnControlFactory} (or {@link HTMLTableWrapperListener~getColumnControl} 
 * callback), if defined, or will fall back to {@link HTMLTableWrapperControl} in the case no {@link ColumnControlFactory} (or callback) is defined, or the call to 
 * {@link ColumnControlFactory#getColumnControl} (or direct invocation of the {@link HTMLTableWrapperListener~getColumnControl} callback) returns {@link Nothing}. {@link ColumnControl}s 
 * are cached after they are created, and are reused for subsequent click events. The given `cellInterpreter` is used when falling back to {@link HTMLTableWrapperControl}.
 * 
 * {@link ColumnControl}s can make calls back to {@link HTMLTableWrapperListener#processTable} to trigger table-wide
 * sorting and filtering in response to user-triggered events on the control that would be expected to update the state of the 
 * backing table. Upon a call to {@link HTMLTableWrapperListener#processTable}, {@link ColumnControl#getFilterDescriptor} and 
 * {@link ColumnControl#getSortDescriptor} are called for each cached {@link ColumnControl}. Each result that is not {@link Nothing} is stored, 
 * and ultimately passed to {@link HTMLTableWrapper#filter} and {@link HTMLTableWrapper#sort}. (Implicit to this, calls to {@link HTMLTableWrapperListener#processTable}
 * should not be made in {@link ColumnControl#getFilterDescriptor} and {@link ColumnControl#getSortDescriptor}, as such would cause infinite recursion).
 */
function HTMLTableWrapperListener(table, columnControlFactory, cellInterpreter) {
    'use strict';
    
    if (table instanceof HTMLTableWrapper) {
        this.dataTable = dataTable;
    } else {
        this.dataTable = new HTMLTableWrapper(table);
    }
    
    if (columnControlFactory) {
        this.columnControlFactory = columnControlFactory;
    }
    
    if (cellInterpreter) {
        this.cellInterpreter = cellInterpreter;
    }
    
    /**
     * {@link ColumnControl} cache.
     *
     * @private
     * @type {ColumnControl[]}
     */
    this.columnControls = [];
}

// Static fields.
/**
 * Class name added to `HTMLTableCellElement`s upon which `HTMLTableWrapperListener` instances are registered for
 * click events. Default value is `'data-table-column-header'`.
 *
 */
HTMLTableWrapperListener.processedColumnHeader = 'data-table-column-header';

// Instance Properties
/**
 * Backing {@link HTMLTableWrapper}.
 *
 * @member {HTMLTableWrapper} HTMLTableWrapperListener#dataTable
 * @private
 */
 

/**
 * Cache of `HTMLTableCellElement`s for which this class is registered for click events.
 *
 * @private
 * @type {HTMLTableCellElement[]}
 */
HTMLTableWrapperListener.prototype.tableHeaderCache = null;

/**
 * Current column-order in which to apply sort descriptors. Initially `null`, initialized on first call to {@link HTMLTableWrapperListener#processTable}.
 *
 * @private
 * @type {number[]}
 */
HTMLTableWrapperListener.prototype.sortDescriptorOrder = null;


/**
 * {@link ColumnControlFactory} to use when creating controls.
 *
 * @type ColumnControlFactory
 * @private
 */
HTMLTableWrapperListener.prototype.columnControlFactory = null;


HTMLTableWrapperListener.prototype.cellInterpreter = null;



// Instance Methods
/**
 * Initializes this `HTMLTableWrapperListener`. This `HTMLTableWrapperListener` will be added as a click listener for each `HTMLTableCellElement` 
 * in the first row of the backing table's header section. The class name {@link HTMLTableWrapperListener.processedColumnHeader} will also be added to
 * each cell.
 */
HTMLTableWrapperListener.prototype.init = function () {
    'use strict';
    
    var table, tableHeaders, tableHeaderCache, i, tableHeader;
    
    table = this.dataTable.getTableElement();
    tableHeaders = table.tHead.rows[0].cells;
    tableHeaderCache = this.tableHeaderCache = [];
    
    
    for (i = 0; i < tableHeaders.length; ++i) {
        tableHeader = tableHeaders[i];
        IE8Compatibility.addEventListener(tableHeader, 'click', this, false);
        IE8Compatibility.addClass(tableHeader, HTMLTableWrapperListener.processedColumnHeader);
        tableHeaderCache.push(tableHeader);
    }
    
};

/**
 * Disposes this `HTMLTableWrapperListener`. This `HTMLTableWrapperListener` will remove itself as a click listener for any
 * `HTMLTableCellElement`s upon which it has registered itself, as well as remove the {@link HTMLTableWrapperListener.processedColumnHeader}
 * class name. Additionally, if any cached {@link ColumnControl}s define a {@link ColumnControl#dispose dispose} function, it will also be called.
 */
HTMLTableWrapperListener.prototype.dispose = function () {
    'use strict';
    
    var tableHeaderCache, i, columnControls, columnControl, tableHeader;
    
    tableHeaderCache = this.tableHeaderCache;
    for (i = 0; i < tableHeaderCache.length; ++i) {
        tableHeader = tableHeaderCache[i];
        IE8Compatibility.removeEventListener(tableHeader, 'click', this, false);
        IE8Compatibility.removeClass(tableHeader, HTMLTableWrapperListener.processedColumnHeader)
    }
    
    columnControls = this.columnControls;
    for (i = 0; i < columnControls.length; ++i) {
        columnControl = columnControls[i];
        if (typeof columnControl.dispose === 'function') {
            columnControl.dispose();
        }
    }
    
};

/**
 * Implementation of DOM `EventListener`.
 *
 * @param {Event} event Event being dispatched.
 */
HTMLTableWrapperListener.prototype.handleEvent = function (event) {
    'use strict';
    
    var columnIndex, target;
    
    // Only process click events (warn otherwise).
    if (event.type !== 'click') {
        if (console && console.warn) {
            console.warn('Unsupported event: ' + event.type);
            console.warn(event);
        }
        return;
    }
    
    // Get column index.
    target = IE8Compatibility.getEventTarget(event);
    columnIndex = this.tableHeaderCache.indexOf(target);
    if (columnIndex === -1) {
        if (console && console.warn) {
            console.warn('Unrecognized event target.');
            console.warn(target);
        }
        return;
    }
    
    // Open control
    this.openColumnControl(columnIndex);
};

/**
 * Opens the {@link ColumnControl} for the given `columnIndex`. Delegates to {@link HTMLTableWrapperListener#getColumnControl}
 * to obtain the {@link ColumnControl} to be opened. After obtained, {@link ColumnControl#open} will be called on it, and 
 * {@link ColumnControl#close} on all others in the cache.
 *
 * @param {number} columnIndex Index of the column upon which a {@link ColumnControl} is to be opened.
 * @throws {RangeError} If `columnIndex` is greater than or equal to the number of columns in the backing table.
 */
HTMLTableWrapperListener.prototype.openColumnControl = function (columnIndex) {
    'use strict';
    
    var tableHeader, targetColumnControl, columnControls, columnControl, i;
    
    targetColumnControl = this.getColumnControl(columnIndex);
    
    columnControls = this.columnControls;
    for (i = 0; i < columnControls.length; ++i) {
        columnControl = columnControls[i];
        if (columnControl === targetColumnControl) {
            columnControl.open();
        } else {
            columnControl.close();
        }
    }
};

/**
 * Obtains a {@link ColumnControl} for the given `columnIndex`. If one is cached (has been obtained before), it will be returned. Otherwise it will be created using the 
 * {@link ColumnControlFactory} passed to the constructor of this `HTMLTableWrapperListener`. If defined, and its {@link ColumnControlFactory#getColumnControl} (or its 
 * direct invocation if a {@link HTMLTableWrapperListener~getColumnControl}) function returns a value that is not {@link Nothing}, that value will be cached and returned. 
 * Failing that, a new {@link HTMLTableWrapperControl} will be cached and returned.
 *
 * @param {number} columnIndex Index of the column for which a {@link ColumnControl} is to be obtained.
 * @throws {RangeError} If `columnIndex` is greater than or equal to the number of columns in the backing table.
 * @returns {ColumnControl} The newly created, or previously cached {@link ColumnControl} for the given `columnIndex`.
 */
HTMLTableWrapperListener.prototype.getColumnControl = function (columnIndex) {
    'use strict';
    
    var columnControls, columnControl, currentColumnControl, i, columnControlFactory, columnCount;
    
    if (columnIndex > (columnCount = this.tableHeaderCache.length)) {
        throw new RangeError('The given columnIndex must be less than the number of columns in the backing table (' + columnCount + '): ' + columnIndex);
    }
    
    columnControls = this.columnControls;
    for (i = 0; i < columnControls.length; ++i) {
        currentColumnControl = columnControls[i];
        if (currentColumnControl.columnIndex === columnIndex) {
            return currentColumnControl;
        }
    }

    
    columnControlFactory = this.columnControlFactory;
    if (columnControlFactory) {
        columnControl = typeof columnControlFactory.getColumnControl === 'function' ? 
                columnControlFactory.getColumnControl(columnIndex, this) : columnControlFactory(columnIndex, this);
    }
    
    if (!columnControl) {
        columnControl = new HTMLTableWrapperControl(columnIndex, this, this.cellInterpreter);
    }
    
    if (typeof columnControl.init === 'function') {
        columnControl.init();
    }
    
    columnControls.push(columnControl);
    return columnControl;
};


/**
 * {@link HTMLTableWrapper#filter Filters} and {@link HTMLTableWrapper#sort sorts} the backing table based upon
 * the state of cached {@link ColumnControl}s.
 *
 * The {@link ColumnControl#getFilterDescriptor} and {@link ColumnControl#getSortDescriptor} functions are called for each
 * cached {@link ColumnControl} in the order they are created. Each result that is not {@link Nothing} is stored in a distinct
 * `Array` (one for {@link FilterDescriptor}s, another for {@link SortDescriptor}s), and subsequently passed to the backing
 * {@link HTMLTableWrapper}'s {@link HTMLTableWrapper#filter} and {@link HTMLTableWrapper#sort} functions. Filtering is performed before sorting.
 * 
 * {@link SortDescriptor}s are ordered according to the order in which they are/were returned from this and previous calls. E.g., consider
 * the following sequence of calls to `processTable`:
 *   1. A valid {@link SortDescriptor} is returned for column 1 => table will be ordered by column 1
 *   2. A valid {@link SortDescriptor} is returned for columns 1 and 2 => table will be ordered by columns 1, 2
 *   3. A valid {@link SortDescriptor} is returned for columns 1, 2, and 3 => table will be ordered by columns 1, 2, 3
 *   4. A valid {@link SortDescriptor} is only returned for columns 2 and 3 => table will be ordered by columns 2, 3
 *   5. A valid {@link SortDescriptor} is returned for columns 1, 2, and 3 again => table will be ordered by columns 2, 3, 1
 *
 */
HTMLTableWrapperListener.prototype.processTable = function () {
    'use strict';
    
    var sortDescriptors, filterDescriptors, columnControls, i, columnControl, dataTable, filterDescriptor,
        sortDescriptor, sortDescriptorOrder, columnIndex, j, targetIndex;
    
    
    // Get descriptors.
    filterDescriptors = [];
    sortDescriptors = [];
    columnControls = this.columnControls;
    for (i = 0; i < columnControls.length; ++i) {
        columnControl = columnControls[i];
        
        filterDescriptor = columnControl.getFilterDescriptor();
        if (filterDescriptor) {
            filterDescriptors.push(filterDescriptor);
        }
        
        sortDescriptor = columnControl.getSortDescriptor();
        if (sortDescriptor) {
            sortDescriptors.push(sortDescriptor);
        }
    }
    
    
    // Determine order for sort descriptors.
    sortDescriptorOrder = this.sortDescriptorOrder;
    if (!sortDescriptorOrder) {
        sortDescriptorOrder = this.sortDescriptorOrder = [];
    }
    
    // Add column indicies to order that were returned, but not present.
    for (i = 0; i < sortDescriptors.length; ++i) {
        columnIndex = sortDescriptors[i].columnIndex;
        if (sortDescriptorOrder.indexOf(columnIndex) === -1) {
            sortDescriptorOrder.push(columnIndex);
        }
    }
    
    // Remove column indicies from order that are present, but not returned
    for (i = 0; i < sortDescriptorOrder.length; ++i) {
        columnIndex = sortDescriptorOrder[i];
        
        targetIndex = -1;
        for (j = 0; j < sortDescriptors.length; ++j) {
            if (sortDescriptors[j].columnIndex === columnIndex) {
                targetIndex = j;
                break;
            }
        }
        
        if (targetIndex === -1) {
            sortDescriptorOrder.splice(i, 1);
            --i;
        }
    }
    
    // Sort sort descriptors according to order.
    sortDescriptors.sort(function (a, b) {
        return sortDescriptorOrder.indexOf(a.columnIndex) - sortDescriptorOrder.indexOf(b.columnIndex);
    });
    
    
    
    // Process backing table.
    dataTable = this.dataTable;
    
    dataTable.filter(filterDescriptors);
    dataTable.sort(sortDescriptors);
};


/**
 * Returns this `HTMLTableWrapperListener`'s backing {@link HTMLTableWrapper}.
 *
 * @returns {HTMLTableWrapper} This `HTMLTableWrapperListener`'s backing {@link HTMLTableWrapper}.
 */
HTMLTableWrapperListener.prototype.getDataTable = function () {
    'use strict';
    
    return this.dataTable;
};


/**
 * Returns the `HTMLTableCellElement` acting as the column header for the given `columnIndex`.
 *
 * @param {number} columnIndex Index of the column header to be retrieved.
 * @returns {HTMLTableCellElement} The `HTMLTableCellElement` acting as the column header for the given `columnIndex`.
 * @throws {RangeError} If `columnIndex` is greater than or equal to the number of columns in the backing table.
 */
HTMLTableWrapperListener.prototype.getTableHeaderElement = function (columnIndex) {
    'use strict';
    
    return this.dataTable.getTableElement().tHead.rows[0].cells[columnIndex];
};