Source: components/sl-grid.js

import Ember from 'ember';
import layout from '../templates/components/sl-grid';

/**
 * Valid values for the column definitions' `align` property
 *
 * @memberof module:components/sl-grid
 * @enum {String}
 */
const ColumnAlign = Object.freeze({
    LEFT: 'left',
    RIGHT: 'right'
});
export { ColumnAlign };

/**
 * Valid values for the column definitions' `size` property
 *
 * @memberof module:components/sl-grid
 * @enum {String}
 */
const ColumnSize = Object.freeze({
    LARGE: 'large',
    MEDIUM: 'medium',
    SMALL: 'small'
});
export { ColumnSize };

/**
 * @module
 * @augments ember/Component
 */
export default Ember.Component.extend({

    // -------------------------------------------------------------------------
    // Dependencies

    // -------------------------------------------------------------------------
    // Attributes

    /** @type {String[]} */
    classNameBindings: [
        'detailPaneOpen:details-open',
        'loading:sl-loading'
    ],

    /** @type {String[]} */
    classNames: [
        'sl-grid'
    ],

    /** @type {Object} */
    layout,

    /** @type {String} */
    tagName: 'div',

    // -------------------------------------------------------------------------
    // Actions

    /** @type {Object} */
    actions: {

        /**
         * Handle changing pages
         *
         * @function actions:changePage
         * @param {Number} page - The page number being changed to
         * @returns {undefined}
         */
        changePage( page ) {
            if ( this.get( 'loading' ) ) {
                return;
            }

            const limit = this.get( 'pageSize' );
            const offset = limit * ( page - 1 );

            this.set( 'loading', true );
            this.sendAction( 'requestData', limit, offset );
        },

        /**
         * Close the detail-pane
         *
         * @function
         * @returns {undefined}
         */
        closeDetailPane() {
            const activeRecord = this.get( 'activeRecord' );

            if ( activeRecord ) {
                Ember.set( activeRecord, 'active', false );
                this.set( 'activeRecord', null );
            }

            this.set( 'detailPaneOpen', false );
            this.updateHeight();
        },

        /**
         * Open the detail-pane with a specific row object
         *
         * @function
         * @param {Object} row - An object representing the row to make active
         * @returns {undefined}
         */
        openDetailPane( row ) {
            const activeRecord = this.get( 'activeRecord' );

            if ( activeRecord ) {
                Ember.set( activeRecord, 'active', false );
            }

            Ember.set( row, 'active', true );
            this.setProperties({
                activeRecord: row,
                detailPaneOpen: true
            });
            this.updateHeight();
        },

        /**
         * Handle a list item's row click
         *
         * If an action is bound to the `rowClick` property, then it will be
         * called when this is triggered. Otherwise, the detail-pane will be
         * opened for the triggering row's model record, unless no detailPath is
         * defined.
         *
         * @function actions:rowClick
         * @param {Object} row - The object that the clicked row represents
         * @returns {undefined}
         */
        rowClick( row ) {
            if ( this.get( 'rowClick' ) ) {
                this.sendAction( 'rowClick', row );
            } else if ( this.get( 'detailComponent' ) ) {
                this.send( 'openDetailPane', row );
            }
        },

        /**
         * Toggle sorting of the selected column, and send the "sortAction"
         * bound action the column and direction to sort
         *
         * @function actions:sortColumn
         * @param {Object} column - The column definition for the triggered
         *        header's column
         * @returns {undefined}
         */
        sortColumn( column ) {
            if ( this.get( 'loading' ) ) {
                return;
            }

            const columnTitle = Ember.get( column, 'title' );
            const sortedColumn = this.get( 'sortedColumn' );
            const sortedColumnTitle = this.get( 'sortedColumnTitle' );
            let sortDirection = this.get( 'sortDirection' );

            if ( sortedColumnTitle === columnTitle ) {
                sortDirection = !sortDirection;
            } else {
                if ( sortedColumn ) {
                    Ember.set( sortedColumn, 'sortAscending' );
                }

                this.set( 'sortedColumnTitle', columnTitle );
                sortDirection = true;
            }

            this.set( 'sortDirection', sortDirection );
            Ember.set( column, 'sortAscending', sortDirection );

            this.sendAction( 'sortColumn', column, sortDirection );
        },

        /**
         * Opens/closes the filter pane
         *
         * @function actions:toggleFilterPane
         * @returns {undefined}
         */
        toggleFilterPane() {
            this.toggleProperty( 'filterPaneOpen' );
            this.updateHeight();
        }
    },

    // -------------------------------------------------------------------------
    // Events

    // -------------------------------------------------------------------------
    // Properties

    /**
     * The text label for the rows' actions buttons
     *
     * @type {String}
     */
    actionsButtonLabel: 'Actions',

    /**
     * The row record that is currently active in the detail pane
     *
     * @type {?Object}
     */
    activeRecord: null,

    /**
     * @typedef ColumnDefinition
     * @type {Object}
     * @property {ColumnAlign} [align] - Which direction to align the
     *           column's content
     * @property {Boolean} [primary] - Whether the column is always shown
     * @property {Number|ColumnSize} [size] - The width of the column; either a
     *           number of pixels, or a ColumnSize value
     * @property {Boolean} [sortable] - Whether the column is able to be sorted
     * @property {String} [template] - Template name to use for the cell value;
     *           uses the `rowController` as its controller
     * @property {String} title - The displayed title of the column
     * @property {String} [valuePath] - Name of a property to lookup on the
     *           rows to populate the cell with
     */

    /**
     * @type {ColumnDefinition[]}
     */
    columns: [],

    /**
     * @type {?Object[]}
     */
    content: null,

    /**
     * Whether the grid's data should be handled by continuous-scrolling
     *
     * When this is false (default), then the grid will have pagination enabled.
     *
     * @type {Boolean}
     */
    continuous: false,

    /**
     * The current page, valid for a non-`continuous` grid
     *
     * @type {Number}
     */
    currentPage: 1,

    /**
     * The name of the component to render for the detail pane
     *
     * @type {?String}
     */
    detailComponent: null,

    /**
     * The path of a template to use for the detail-pane footer
     *
     * @type {?String}
     */
    detailFooterPath: null,

    /**
     * The path of a template to use for the detail-pane header
     *
     * @type {?String}
     */
    detailHeaderPath: null,

    /**
     * Indicates when the detail-pane is open
     *
     * @type {Boolean}
     */
    detailPaneOpen: false,

    /**
     * The text to display on the filter panel toggle button
     *
     * @type {String}
     */
    filterButtonLabel: 'Filter',

    /**
     * Indicates when the filter pane is open
     *
     * @type {Boolean}
     */
    filterPaneOpen: false,

    /**
     * The name of a component to use for the filter panel
     *
     * @type {?String}
     */
    filterComponent: null,

    /**
     * The path for the template to use for the footer of the list pane
     *
     * @type {?String}
     */
    footerPath: null,

    /**
     * The height of the overall grid
     *
     * When the value is set to "auto" (the default), then the sl-grid will size
     * its content to take up the maximum valid vertical space for the
     * current viewport.
     *
     * @type {Number|String}
     */
    height: 'auto',

    /**
     * When true, the split-grid is in a loading state
     *
     * @type {Boolean}
     */
    loading: false,

    /**
     * The "top" value for the table scroll to request a new page at
     *
     * @type {Number}
     */
    nextPageScrollPoint: 0,

    /**
     * The number of records to request for each page
     *
     * @type {Number}
     */
    pageSize: 25,

    /**
     * The aliased grid's parent controller, used to trigger row actions
     *
     * @type {module:components/sl-grid~_parentView._controller}
     */
    rowActionContext: Ember.computed.alias( '_parentView._controller' ),

    /**
     * An array of action definitions to use for individual row actions
     *
     * Each item in this array should have the following properties:
     * - {String} action - The name of the action to trigger when this option
     *   is called
     * - {String} label - The displayed text for the option
     *
     * @type {?Object[]}
     */
    rowActions: null,

    /**
     * Bound action to call when a row is clicked
     *
     * When this value is not set, the detail pane will be opened whenever a row
     * is clicked.
     *
     * @type {?String}
     */
    rowClick: null,

    /**
     * Whether the currently sorted column is ascending or not
     *
     * @type {Boolean}
     */
    sortAscending: true,

    /**
     * The title of the column that is currently being sorted
     *
     * @type {?Object}
     */
    sortedColumnTitle: null,

    // -------------------------------------------------------------------------
    // Observers

    /**
     * Does cleanup for internal state when content length has changed
     *
     * @function
     * @returns {undefined}
     */
    handleNewContent: Ember.observer(
        'content.@each',
        function() {
            this.set( 'loading', false );

            if ( !this.get( 'hasMoreData' ) ) {
                this.disableContinuousPaging();
            }
        }
    ),

    /**
     * Setup the viewport-based auto sizing when `height` is "auto"
     *
     * @function
     * @returns {undefined}
     */
    setupAutoHeight: Ember.on(
        'didInsertElement',
        function() {
            if ( 'auto' === this.get( 'height' ) ) {
                Ember.$( window ).bind( 'resize', () => {
                    this.updateHeight();
                });
            }
        }
    ),

    /**
     * Setup the "continuous paging" functionality, if the data set is
     * not complete
     *
     * @function
     * @returns {undefined}
     */
    setupContinuousPaging: Ember.on(
        'didInsertElement',
        function() {
            if ( this.get( 'continuous' ) && this.get( 'hasMoreData' ) ) {
                this.enableContinuousPaging();
            }
        }
    ),

    /**
     * Setup paths for the various sections within the split-grid
     *
     * @function
     * @returns {undefined}
     */
    setupTemplates: Ember.on(
        'init',
        function() {
            const renderedName = this.get( '_parentView.renderedName' );

            if ( renderedName ) {
                const registry = this.get( 'container._registry' );
                const root = renderedName.replace( '.', '/' ) + '/';
                const detailFooterPath = root + 'detail-footer';
                const detailHeaderPath = root + 'detail-header';
                const filterPath = root + 'filter';
                const footerPath = root + 'footer';

                if (
                    !this.get( 'detailFooterPath' ) &&
                    registry.resolve( 'template:' + detailFooterPath )
                ) {
                    this.set( 'detailFooterPath', detailFooterPath );
                }

                if (
                    !this.get( 'detailHeaderPath' ) &&
                    registry.resolve( 'template:' + detailHeaderPath )
                ) {
                    this.set( 'detailHeaderPath', detailHeaderPath );
                }

                if (
                    !this.get( 'filterPath' ) &&
                    registry.resolve( 'template:' + filterPath )
                ) {
                    this.set( 'filterPath', filterPath );
                }

                if (
                    !this.get( 'footerPath' ) &&
                    registry.resolve( 'template:' + footerPath )
                ) {
                    this.set( 'footerPath', footerPath );
                }
            }
        }
    ),

    /**
     * Whether to show the pagination in the list-pane footer
     *
     * @function
     * @returns {Boolean}
     */
    showPagination: Ember.computed(
        'continuous',
        'totalPages',
        function() {
            const totalPages = this.get( 'totalPages' );

            return !this.get( 'continuous' ) && totalPages && totalPages > 1;
        }
    ),

    /**
     * The currently sorted column definition
     *
     * @function
     * @returns {?Object} The definition for the currently sorted column
     */
    sortedColumn: Ember.computed(
        'columns',
        'sortedColumnTitle',
        function() {
            const sortedColumnTitle = this.get( 'sortedColumnTitle' );

            if ( sortedColumnTitle ) {
                const columns = this.get( 'columns' );

                for ( let i = 0; i < columns.length; i++ ) {
                    if (
                        Ember.get( columns[ i ], 'title' ) === sortedColumnTitle
                    ) {
                        return columns[ i ];
                    }
                }
            }
        }
    ),

    /**
     * The total number of pages of bound content, based on pageSize
     *
     * @function
     * @returns {Number|undefined}
     */
    totalPages: Ember.computed(
        'continuous',
        'pageSize',
        'totalCount',
        function() {
            if (
                !this.get( 'continuous' ) &&
                this.get( 'totalCount' ) &&
                this.get( 'pageSize' )
            ) {
                return Math.ceil(
                    this.get( 'totalCount' ) / this.get( 'pageSize' )
                );
            }
        }
    ),

    /**
     * Update the panes' heights according to `height` property value
     *
     * @function
     * @returns {undefined}
     */
    updateHeight: Ember.on(
        'didInsertElement',
        function() {
            if ( !this.$() ) {
                return;
            }

            const componentHeight = this.get( 'height' );
            const gridHeader = this.$( '.grid-header' );
            const detailHeader = this.$( '.detail-pane header' );
            const detailFooter = this.$( '.detail-pane footer' );
            const listHeader = this.$( '.list-pane .column-headers' );
            const listFooter = this.$( '.list-pane footer' );

            const detailHeaderHeight = detailHeader ?
                parseInt( detailHeader.css( 'height' ) ) : 0;

            const detailFooterHeight = detailFooter ?
                parseInt( detailFooter.css( 'height' ) ) : 0;

            const gridHeaderHeight = gridHeader ?
                parseInt( gridHeader.css( 'height' ) ) : 0;

            const listHeaderHeight = listHeader ?
                parseInt( listHeader.css( 'height' ) ) : 0;

            const listFooterHeight = listFooter ?
                parseInt( listFooter.css( 'height' ) ) : 0;

            let maxHeight = componentHeight;
            if ( 'auto' === componentHeight ) {
                maxHeight = Ember.$( window ).innerHeight() -
                    this.$().position().top;
            }

            let detailContentHeight = maxHeight - gridHeaderHeight -
                detailHeaderHeight - detailFooterHeight;

            let listContentHeight = maxHeight - gridHeaderHeight -
                listHeaderHeight - listFooterHeight;

            if ( this.get( 'filterPaneOpen' ) ) {
                const filterPaneHeight = parseInt(
                    this.$( '.filter-pane' ).css( 'height' )
                );

                detailContentHeight -= filterPaneHeight;
                listContentHeight -= filterPaneHeight;
            }

            this.$( '.detail-pane .content' ).height( detailContentHeight );
            this.$( '.list-pane .content' ).height( listContentHeight );
        }
    ),

    // -------------------------------------------------------------------------
    // Methods

    /**
     * Disables the scroll event handling for continuous paging
     *
     * @function
     * @returns {undefined}
     */
    disableContinuousPaging() {
        this.$( '.list-pane .content' ).unbind( 'scroll' );
    },

    /**
     * Enables the scroll event handling for continuous paging
     *
     * @function
     * @returns {undefined}
     */
    enableContinuousPaging() {
        this.$( '.list-pane .content' ).bind( 'scroll', ( event ) => {
            this.handleListContentScroll( event );
        });
    },

    /**
     * For `continuous` grids; callback to the list content scrolling, which is
     * responsible for determining when triggering requestData is necessary by
     * checking the scroll location of the content
     *
     * @function
     * @param {jQuery.Event} event - The scroll trigger event
     * @returns {undefined}
     */
    handleListContentScroll( event ) {
        const listContent = this.$( event.target );
        const loading = this.get( 'loading' );
        const nextPageScrollPoint = this.get( 'nextPageScrollPoint' );
        const scrollBottom = listContent.scrollTop() + listContent.height();

        if ( scrollBottom >= nextPageScrollPoint && !loading ) {
            this.requestMoreData();
        }
    },

    /**
     * Whether the content has more available data to page in
     *
     * @function
     * @returns {Boolean} - True if more content pages are available
     */
    hasMoreData: Ember.computed(
        'content.length',
        'totalCount',
        function() {
            return this.get( 'content.length' ) < this.get( 'totalCount' );
        }
    ),

    /**
     * Trigger the bound `requestData` action for more content data
     *
     * @function
     * @returns {undefined}
     */
    requestMoreData() {
        if ( this.get( 'hasMoreData' ) ) {
            const nextPageScrollPoint = this.$( '.list-pane .content' )[ 0 ]
                .scrollHeight;

            this.setProperties({
                'loading': true,
                nextPageScrollPoint
            });

            this.sendAction( 'requestData' );
        }
    }

});