wikimedia/mediawiki-core

View on GitHub
resources/src/mediawiki.widgets/Table/mw.widgets.TableWidget.js

Summary

Maintainability
F
3 days
Test Coverage
/**
 * A TableWidget groups {@link mw.widgets.RowWidget row widgets} together to form a bidimensional
 * grid of text inputs.
 *
 * @class
 * @extends OO.ui.Widget
 * @mixes OO.ui.mixin.GroupElement
 *
 * @constructor
 * @param {mw.widgets.TableWidgetModel~Config} [config] Configuration options
 */
mw.widgets.TableWidget = function MwWidgetsTableWidget( config ) {
    var headerRowItems = [],
        insertionRowItems = [],
        columnProps, prop, i, len;

    // Configuration initialization
    config = config || {};

    // Parent constructor
    mw.widgets.TableWidget.super.call( this, config );

    // Mixin constructors
    OO.ui.mixin.GroupElement.call( this, config );

    // Set up model
    this.model = new mw.widgets.TableWidgetModel( config );

    // Properties
    this.listeningToInsertionRowChanges = true;

    // Set up group element
    this.setGroupElement(
        $( '<div>' )
            .addClass( 'mw-widgets-tableWidget-rows' )
    );

    // Set up static rows
    columnProps = this.model.getAllColumnProperties();

    if ( this.model.getTableProperties().showHeaders ) {
        this.headerRow = new mw.widgets.RowWidget( {
            deletable: false,
            label: null
        } );

        for ( i = 0, len = columnProps.length; i < len; i++ ) {
            prop = columnProps[ i ];
            headerRowItems.push(
                this.getHeaderRowItem( prop.label, prop.key, prop.index )
            );
        }

        this.headerRow.addItems( headerRowItems );
    }

    if ( this.model.getTableProperties().allowRowInsertion ) {
        this.insertionRow = new mw.widgets.RowWidget( {
            classes: [ 'mw-widgets-rowWidget-insertionRow' ],
            deletable: false,
            label: null
        } );

        for ( i = 0, len = columnProps.length; i < len; i++ ) {
            insertionRowItems.push( new OO.ui.TextInputWidget( {
                data: columnProps[ i ].key ? columnProps[ i ].key : columnProps[ i ].index,
                disabled: this.isDisabled()
            } ) );
        }

        this.insertionRow.addItems( insertionRowItems );
    }

    // Set up initial rows
    if ( Array.isArray( config.items ) ) {
        this.addItems( config.items );
    }

    // Events
    this.model.connect( this, {
        valueChange: 'onValueChange',
        insertRow: 'onInsertRow',
        insertColumn: 'onInsertColumn',
        removeRow: 'onRemoveRow',
        removeColumn: 'onRemoveColumn',
        clear: 'onClear'
    } );

    this.aggregate( {
        inputChange: 'rowInputChange',
        deleteButtonClick: 'rowDeleteButtonClick'
    } );

    this.connect( this, {
        rowInputChange: 'onRowInputChange',
        rowDeleteButtonClick: 'onRowDeleteButtonClick'
    } );

    if ( this.model.getTableProperties().allowRowInsertion ) {
        this.insertionRow.connect( this, {
            inputChange: 'onInsertionRowInputChange'
        } );
    }

    // Initialization
    this.$element.addClass( 'mw-widgets-tableWidget' );

    if ( this.model.getTableProperties().showHeaders ) {
        this.$element.append( this.headerRow.$element );
    }
    this.$element.append( this.$group );
    if ( this.model.getTableProperties().allowRowInsertion ) {
        this.$element.append( this.insertionRow.$element );
    }

    this.$element.toggleClass(
        'mw-widgets-tableWidget-no-labels',
        !this.model.getTableProperties().showRowLabels
    );

    this.model.setupTable();
};

/* Inheritance */

OO.inheritClass( mw.widgets.TableWidget, OO.ui.Widget );
OO.mixinClass( mw.widgets.TableWidget, OO.ui.mixin.GroupElement );

/* Static Properties */
mw.widgets.TableWidget.static.patterns = {
    // eslint-disable-next-line security/detect-unsafe-regex
    validate: /^[0-9]+(\.[0-9]+)?$/,
    // eslint-disable-next-line security/detect-unsafe-regex
    filter: /[0-9]+(\.[0-9]+)?/
};

/* Events */

/**
 * Change when the data within the table has been updated.
 *
 * @event mw.widgets.TableWidget.change
 * @param {number} rowIndex The index of the row that changed
 * @param {string} rowKey The key of the row that changed, or `undefined` if it doesn't exist
 * @param {number} columnIndex The index of the column that changed
 * @param {string} columnKey The key of the column that changed, or `undefined` if it doesn't exist
 * @param {string} value The new value
 */

/**
 * Fires when a row is removed from the table.
 *
 * @event mw.widgets.TableWidget.removeRow
 * @param {number} index The index of the row being deleted
 * @param {string} key The key of the row being deleted
 */

/* Methods */

/**
 * Set the value of a particular cell.
 *
 * @param {number|string} row The row containing the cell to edit. Can be either
 * the row index or string key if one has been set for the row.
 * @param {number|string} col The column containing the cell to edit. Can be either
 * the column index or string key if one has been set for the column.
 * @param {any} value The new value
 */
mw.widgets.TableWidget.prototype.setValue = function ( row, col, value ) {
    this.model.setValue( row, col, value );
};

/**
 * Set the table data.
 *
 * @param {Array} data The new table data
 * @return {boolean} The data has been successfully changed
 */
mw.widgets.TableWidget.prototype.setData = function ( data ) {
    if ( !Array.isArray( data ) ) {
        return false;
    }

    this.model.setData( data );
    return true;
};

/**
 * Inserts a row into the table. If the row isn't added at the end of the table,
 * all the following data will be shifted back one row.
 *
 * @param {Array} [data] The data to insert to the row.
 * @param {number} [index] The index in which to insert the new row.
 * If unset or set to null, the row will be added at the end of the table.
 * @param {string} [key] A key to quickly select this row.
 * If unset or set to null, no key will be set.
 * @param {string} [label] A label to display next to the row.
 * If unset or set to null, the key will be used if there is one.
 */
mw.widgets.TableWidget.prototype.insertRow = function ( data, index, key, label ) {
    this.model.insertRow( data, index, key, label );
};

/**
 * Inserts a column into the table. If the column isn't added at the end of the table,
 * all the following data will be shifted back one column.
 *
 * @param {Array} [data] The data to insert to the column.
 * @param {number} [index] The index in which to insert the new column.
 * If unset or set to null, the column will be added at the end of the table.
 * @param {string} [key] A key to quickly select this column.
 * If unset or set to null, no key will be set.
 * @param {string} [label] A label to display next to the column.
 * If unset or set to null, the key will be used if there is one.
 */
mw.widgets.TableWidget.prototype.insertColumn = function ( data, index, key, label ) {
    this.model.insertColumn( data, index, key, label );
};

/**
 * Removes a row from the table. If the row removed isn't at the end of the table,
 * all the following rows will be shifted back one row.
 *
 * @param {number|string} key The key or numerical index of the row to remove.
 */
mw.widgets.TableWidget.prototype.removeRow = function ( key ) {
    this.model.removeRow( key );
};

/**
 * Removes a column from the table. If the column removed isn't at the end of the table,
 * all the following columns will be shifted back one column.
 *
 * @param {number|string} key The key or numerical index of the column to remove.
 */
mw.widgets.TableWidget.prototype.removeColumn = function ( key ) {
    this.model.removeColumn( key );
};

/**
 * Clears all values from the table, without wiping any row or column properties.
 */
mw.widgets.TableWidget.prototype.clear = function () {
    this.model.clear();
};

/**
 * Clears the table data, as well as all row and column properties.
 */
mw.widgets.TableWidget.prototype.clearWithProperties = function () {
    this.model.clearWithProperties();
};

/**
 * Filter cell input once it is changed.
 *
 * @param {string} value The input value
 * @return {string} The filtered input
 */
mw.widgets.TableWidget.prototype.filterCellInput = function ( value ) {
    var matches = value.match( mw.widgets.TableWidget.static.patterns.filter );
    return ( Array.isArray( matches ) ) ? matches[ 0 ] : '';
};

/**
 * Get an input item for the header row.
 *
 * @private
 * @param {string} label The column label
 * @param {string} key The column key
 * @param {number} index The column index
 * @return {OO.ui.TextInputWidget} An input item for the header row
 */
mw.widgets.TableWidget.prototype.getHeaderRowItem = function ( label, key, index ) {
    return new OO.ui.TextInputWidget( {
        value: label || key || index,
        // TODO: Allow editing of fields
        disabled: true
    } );
};

/**
 * @private
 * @inheritdoc
 */
mw.widgets.TableWidget.prototype.addItems = function ( items, index ) {
    var i, len;

    OO.ui.mixin.GroupElement.prototype.addItems.call( this, items, index );

    for ( i = index, len = items.length; i < len; i++ ) {
        items[ i ].setIndex( i );
    }
};

/**
 * @private
 * @inheritdoc
 */
mw.widgets.TableWidget.prototype.removeItems = function ( items ) {
    var i, len, rows;

    OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items );

    rows = this.getItems();
    for ( i = 0, len = rows.length; i < len; i++ ) {
        rows[ i ].setIndex( i );
    }
};

/**
 * Handle model value changes
 *
 * @private
 * @param {number} row The row index of the changed cell
 * @param {number} col The column index of the changed cell
 * @param {any} value The new value
 * @fires mw.widgets.TableWidget.change
 */
mw.widgets.TableWidget.prototype.onValueChange = function ( row, col, value ) {
    var rowProps = this.model.getRowProperties( row ),
        colProps = this.model.getColumnProperties( col );

    this.getItems()[ row ].setValue( col, value );

    this.emit( 'change', row, rowProps.key, col, colProps.key, value );
};

/**
 * Handle model row insertions
 *
 * @private
 * @param {Array} data The initial data
 * @param {number} index The index in which the new row was inserted
 * @param {string} key The row key
 * @param {string} label The row label
 * @fires mw.widgets.TableWidget.change
 */
mw.widgets.TableWidget.prototype.onInsertRow = function ( data, index, key, label ) {
    var colProps = this.model.getAllColumnProperties(),
        keys = [],
        newRow, i, len;

    for ( i = 0, len = colProps.length; i < len; i++ ) {
        keys.push( ( colProps[ i ].key ) ? colProps[ i ].key : i );
    }

    newRow = new mw.widgets.RowWidget( {
        data: data,
        keys: keys,
        validate: this.model.getValidationPattern(),
        label: label,
        showLabel: this.model.getTableProperties().showRowLabels,
        deletable: this.model.getTableProperties().allowRowDeletion
    } );

    newRow.setDisabled( this.isDisabled() );

    // TODO: Handle index parameter. Right now all new rows are inserted at the end
    this.addItems( [ newRow ] );

    // If this is the first data being added, refresh headers and insertion row
    if ( this.model.getAllRowProperties().length === 1 ) {
        this.refreshTableMarginals();
    }

    for ( i = 0, len = data.length; i < len; i++ ) {
        this.emit( 'change', index, key, i, colProps[ i ].key, data[ i ] );
    }
};

/**
 * Handle model column insertions
 *
 * @private
 * @param {Array} data The initial data
 * @param {number} index The index in which to insert the new column
 * @param {string} key The row key
 * @param {string} label The row label
 *
 * @fires mw.widgets.TableWidget.change
 */
mw.widgets.TableWidget.prototype.onInsertColumn = function ( data, index, key, label ) {
    var tableProps = this.model.getTableProperties(),
        items = this.getItems(),
        rowProps = this.model.getAllRowProperties(),
        i, len;

    for ( i = 0, len = items.length; i < len; i++ ) {
        items[ i ].insertCell( data[ i ], index, key );
        this.emit( 'change', i, rowProps[ i ].key, index, key, data[ i ] );
    }

    if ( tableProps.showHeaders ) {
        this.headerRow.addItems( [
            this.getHeaderRowItem( label, key, index )
        ] );
    }

    if ( tableProps.handleRowInsertion ) {
        this.insertionRow.addItems( [
            new OO.ui.TextInputWidget( {
                validate: this.model.getValidationPattern(),
                disabled: this.isDisabled()
            } )
        ] );
    }
};

/**
 * Handle model row removals
 *
 * @private
 * @param {number} index The removed row index
 * @param {string} key The removed row key
 * @fires mw.widgets.TableWidget.removeRow
 */
mw.widgets.TableWidget.prototype.onRemoveRow = function ( index, key ) {
    this.removeItems( [ this.getItems()[ index ] ] );
    this.emit( 'removeRow', index, key );
};

/**
 * Handle model column removals
 *
 * @private
 * @param {number} index The removed column index
 * @param {string} key The removed column key
 * @fires mw.widgets.TableWidget.removeColumn
 */
mw.widgets.TableWidget.prototype.onRemoveColumn = function ( index, key ) {
    var i, items = this.getItems();

    for ( i = 0; i < items.length; i++ ) {
        items[ i ].removeCell( index );
    }

    this.emit( 'removeColumn', index, key );
};

/**
 * Handle model table clears
 *
 * @private
 * @param {boolean} withProperties Clear row/column properties
 */
mw.widgets.TableWidget.prototype.onClear = function ( withProperties ) {
    var i, len, rows;

    if ( withProperties ) {
        this.removeItems( this.getItems() );
    } else {
        rows = this.getItems();

        for ( i = 0, len = rows.length; i < len; i++ ) {
            rows[ i ].clear();
        }
    }
};

/**
 * React to input changes bubbled up from event aggregation
 *
 * @private
 * @param {mw.widgets.RowWidget} row The row that changed
 * @param {number} colIndex The column index of the cell that changed
 * @param {string} value The new value of the input
 * @fires mw.widgets.TableWidget.change
 */
mw.widgets.TableWidget.prototype.onRowInputChange = function ( row, colIndex, value ) {
    var items = this.getItems(),
        i, len, rowIndex;

    for ( i = 0, len = items.length; i < len; i++ ) {
        if ( row === items[ i ] ) {
            rowIndex = i;
            break;
        }
    }

    this.model.setValue( rowIndex, colIndex, value );
};

/**
 * React to new row input changes
 *
 * @private
 * @param {number} colIndex The column index of the input that fired the change
 * @param {string} value The new row value
 */
mw.widgets.TableWidget.prototype.onInsertionRowInputChange = function ( colIndex, value ) {
    var insertionRowItems = this.insertionRow.getItems(),
        newRowData = [],
        i, len, lastRow;

    if ( this.listeningToInsertionRowChanges ) {
        for ( i = 0, len = insertionRowItems.length; i < len; i++ ) {
            if ( i === colIndex ) {
                newRowData.push( value );
            } else {
                newRowData.push( '' );
            }
        }

        this.insertRow( newRowData );

        // Focus newly inserted row
        lastRow = this.getItems().slice( -1 )[ 0 ];
        lastRow.getItems()[ colIndex ].focus();

        // Reset insertion row
        this.listeningToInsertionRowChanges = false;
        this.insertionRow.clear();
        this.listeningToInsertionRowChanges = true;
    }
};

/**
 * Handle row deletion input
 *
 * @private
 * @param {mw.widgets.RowWidget} row The row that asked for the deletion
 */
mw.widgets.TableWidget.prototype.onRowDeleteButtonClick = function ( row ) {
    var items = this.getItems(),
        i = -1,
        len;

    for ( i = 0, len = items.length; i < len; i++ ) {
        if ( items[ i ] === row ) {
            break;
        }
    }

    this.removeRow( i );
};

/**
 * @inheritdoc
 */
mw.widgets.TableWidget.prototype.setDisabled = function ( disabled ) {
    // Parent method
    mw.widgets.TableWidget.super.prototype.setDisabled.call( this, disabled );

    if ( !this.items ) {
        return;
    }

    this.getItems().forEach( function ( row ) {
        row.setDisabled( disabled );
    } );

    if ( this.model.getTableProperties().allowRowInsertion ) {
        this.insertionRow.getItems().forEach( function ( row ) {
            row.setDisabled( disabled );
        } );
    }
};

/**
 * Refresh table header and insertion row.
 */
mw.widgets.TableWidget.prototype.refreshTableMarginals = function () {
    var tableProps = this.model.getTableProperties(),
        columnProps = this.model.getAllColumnProperties(),
        rowItems,
        i, len, prop;

    if ( tableProps.showHeaders ) {
        this.headerRow.removeItems( this.headerRow.getItems() );
        rowItems = [];

        for ( i = 0, len = columnProps.length; i < len; i++ ) {
            prop = columnProps[ i ];
            rowItems.push(
                this.getHeaderRowItem( prop.label, prop.key, prop.index )
            );
        }

        this.headerRow.addItems( rowItems );
    }

    if ( tableProps.allowRowInsertion ) {
        this.insertionRow.clear();
        this.insertionRow.removeItems( this.insertionRow.getItems() );

        for ( i = 0, len = columnProps.length; i < len; i++ ) {
            this.insertionRow.insertCell( '', columnProps[ i ].index, columnProps[ i ].key );
        }
    }
};