Source: grid/gridViewModel.js

import { createViewModel } from 'scalejs.metadataFactory';
import { merge, get } from 'scalejs';
import { receive } from 'scalejs.messagebus';
import ko from 'knockout';
import _ from 'lodash';

/**
 * Grid component to display a grid of data
 *
 * @module grid
 *
 * @param {object} node
 *  The configuration object for the grid
 * @param {object} node.data
 *  Initial data to populate the grid with
 * @param {string} node.id
 *  The id of the grid
 * @param {string} node.classes
 *  The classes to apply to the grid
 * @param {string} node.gridHeaderClasses
 *  The classes to apply to the grid header
 * @param {array} node.gridHeader
 *  An array of PJSON components to use as the grid header
 * @param {object} node.dataSourceEndpoint
 *  Configuration object for the grid's data source
 * @param {object} node.dataSourceEndpoint.target
 *  Configuration object for the target of the grid's data source
 * @param {string} node.dataSourceEndpoint.target.uri
 *  The uri endpoint for the grid's data source
 * @param {object} node.dataSourceEndpoint.target.dataMapFunctions
 *  An object of functions to run on the data
 * @param {string} node.dataSourceEndpoint.target.dataMapFunctions.before
 *  Function to run before the data is added to the grid?
 * @param {string} node.dataSourceEndpoint.target.dataMapFunctions.after
 *  Function to run after the data is added to the grid?
 * @param {object|array} node.dataSourceEndpoint.keyMap
 *  A mapper object or array of mapper objects to map keys
 * @param {object} node.pagination
 *  An object to specify pagination for the grid
 * @param {number} node.pagination.start=0
 *  The number of which page to start the grid at
 * @param {number} node.pagination.limit=15
 *  The max number of grid items to show on each page
 * @param {array} node.columns
 *  An array of objects to build the columns
 * @param {object} node.selection
 *  A PJSON action to use when a row is selected
 * @param {object} node.options
 *  The options pertaining to the grid
 * @param {boolean} node.options.infinite
 *  Boolean to specify whether to show infinite items on the grid
 * @param {boolean} node.options.fixedHeader
 *  Boolean to specify if the grid header should be fixed or not
 * @param {object} node.options.footer
 *  Configuration object for the grid footer
 * @param {boolean} node.options.footer.hideOnDone
 *  Boolean to hide the footer once the grid is loaded or not
 * @param {string} node.options.footer.loadingText
 *  A string to show while the grid is loading
 * @param {string} node.options.footer.doneText
 *  A string to show when the grid has finished loading.
 * @param {object} node.options.hasChildren
 *  Configuration object for a grid row's child
 * @param {string} node.options.hasChildren.showIcon
 *  The class to apply to the show child button
 * @param {string} node.options.hasChildren.hideIcon
 *  The class to apply to the hide child button
 * @param {string} node.options.hasChildren.template
 *  The template to apply to the child row
 * @param {boolean} node.options.hasChildren.onRowSelect
 *  Boolean to determine whether to show/hide the child on selecting the row or via a button
 * @param {boolean} node.options.hasChildren.accordion
 *  Boolean to determine if only one child should be shown at a time
 * @param {boolean} node.options.clientSearch
 *  Boolean on whether to search/sort client side
 * @param {boolean|expression} node.options.gridDisplay
 *  Boolean or expression to render the grid programmatically
 * @param {string} node.options.scrollElement
 *  Element to scroll grid on, defaults to scrolling on window
 *
 * @example
 * {
 *      "type": "grid",
 *      "id": "my_grid_id",
 *      "classes": "grid-container",
 *      "gridHeaderClasses": "grid-header",
 *      "options": {
 *          "infinite": true,
 *          "fixedHeader": true,
 *          "footer": {
 *              "hideOnDone": true,
 *              "loadingText": "Loading...",
 *              "doneText": "Loaded all rows."
 *          },
 *          "hasChildren": {
 *              "showIcon": "icon-open",
 *              "hideIcon": "icon-close",
 *              "template": "grid_child_template",
 *              "onRowSelect": true,
 *              "accordion": true,
 *          },
 *      }
 *      "dataSourceEndpoint": {
 *         "target": {
 *              "uri": "endpoint"
 *         },
 *         "keyMap": {
 *             "resultsKey": "data"
 *         }
 *      },
 *      "pagination": {
 *          "start": 0,
 *          "limit": 30
 *      },
 *      "columns": [
 *          {
 *              "data": "colData",
 *              "title": "Column Data Title"
 *          }
 *      ]
 * }
 */
export default function (node) {
    const data = node.data,
        options = node.options,
        columns = node.columns,
        context = this,
        pagination = node.pagination,
        endpoint = node.dataSourceEndpoint,
        rows = ko.observableArray(),
        skip = ko.observable(pagination.start || 0),
        limit = ko.observable(pagination.limit || 15),
        search = ko.observable(''),
        filters = ko.observable({}),
        caseInsensitive = ko.observable(true),
        clientSearch = options.clientSearch,
        selectedItem = ko.observable({}),
        gridContext = {
            search,
            filters,
            rows,
            skip,
            clientSearch,
            caseInsensitive,
            getValue: this.getValue,
            parentContext: context
        },
        loaderNoText = '',
        loaderLoading = 'Loading more...',
        loaderDone = 'Loaded all rows',
        loader = {
            text: ko.observable(loaderNoText),
            done: false,
            inProgress: ko.observable(false)
        },
        subs = [];

    let query,
        queryCallback,
        gridHeaderItems = node.gridHeader || [];

    function setupQuery() {
        query = createViewModel({
            type: 'action',
            actionType: 'ajax',
            options: endpoint
        });
    }

    function setupGetResponse() {
        queryCallback = {
            callback: function (err, results) {
                if (!err) {
                    const key = get(endpoint, 'keyMap.resultsKey'),
                        resultData = key && results ? results[key] : results;
                    rows.push(...resultData);
                    skip(results.skip);
                    loader.inProgress(false);
                    loader.done = results.skip >= results.total;
                    if (loader.done) {
                        loader.text(loaderDone);
                    } else {
                        loader.text(loaderNoText);
                    }
                } else {
                    console.error(`Error in grid query callback: ${err.message || ''}`);
                }
            }
        };
    }

    function sendQuery(isFilter) {
        if (isFilter || (!loader.done && !loader.inProgress())) {
            if (isFilter) {
                skip(0);
                rows.removeAll();
            }
            query.options.target.data = {
                skip: skip(),
                limit: limit()
            };
            // data is in target.data, so it will be sent as is
            if (!clientSearch) {
                query.options.target.data.search = search();
                query.options.target.data.filters = filters();
            }
            loader.inProgress(true);
            loader.text(loaderLoading);
            query.action(queryCallback);

            // TODO: call a resetfilter function to update skip/row observs in here
        }
    }

    function setupGridHeader() {
        gridHeaderItems = gridHeaderItems.map(item => createViewModel.call(gridContext, item));
        // TODO: update to createViewModels after Erica updates mf
        search.extend({ rateLimit: 1000 });
        if (!clientSearch) {
            search.subscribe(() => sendQuery(true));
            filters.subscribe(() => sendQuery(true));
        }
    }

    function setupRefresh() {
        subs.push(receive(`${node.id}.refresh`, () => {
            sendQuery(true);
        }));
    }

    function setupSelection() {
        if (node.selection) {
            selectedItem.subscribe((item) => {
                const action = _.cloneDeep(node.selection),
                    selectionCtx = _.cloneDeep(context);
                selectionCtx.data = item;
                createViewModel.call(selectionCtx, action).action();
            });
        }
    }

    function setupData() {
        if (endpoint) {
            setupQuery();
            setupGetResponse();
            sendQuery();
            setupRefresh();
        } else if (data) {
            rows(data);
        }
    }

    function addRow(row) {
        rows.push(...row);
    }

    // Set up a receiver to push rows to the grid.
    subs.push(receive(`${node.id}.add`, (row) => {
        addRow(row);
    }));


    setupData();
    setupSelection();
    setupGridHeader();

    return merge(node, {
        rows,
        columns,
        sendQuery,
        loader,
        options,
        gridHeaderItems,
        search,
        filters,
        caseInsensitive,
        selectedItem,
        dispose: function () {
            subs.forEach((sub) => {
                sub.dispose();
            });
        }
    });
}