Source: src/main/javascript/oracle/oj/ojrowexpander/FlattenedTreeDataSource.js

Oracle® JavaScript Extension Toolkit (JET)
1.1.2

E65298-01

/**
 * Copyright (c) 2014, Oracle and/or its affiliates.
 * All rights reserved.
 */
 
 /**
 * Base class for FlattenedTreeDataGridDataSource and FlattenedTreeTableDataSource
 * @param {Object} treeDataSource the instance of TreeDataSource to flattened
 * @param {Object=} options the options set on the FlattenedDataSource
 * @constructor
 * @export
 * @extends oj.DataSource
 */
oj.FlattenedTreeDataSource = function(treeDataSource, options)
{
    this.m_wrapped = treeDataSource;
    this.m_options = options || {};
    oj.FlattenedTreeDataSource.superclass.constructor.call(this);
};

// Make FlattenedTreeDataSource subclass of oj.DataSource
oj.Object.createSubclass(oj.FlattenedTreeDataSource, oj.DataSource, "oj.FlattenedTreeDataSource");

/**
 * Initializes the data source.
 * @export
 */
oj.FlattenedTreeDataSource.prototype.Init = function()
{
    var expanded;

    // super
    oj.FlattenedTreeDataSource.superclass.Init.call(this);

    // we have to react if the underlying TreeDataSource has changed
    this.m_wrapped.on('change', this._handleModelEvent.bind(this));

    // retrieves the fetch size against the underlying data source
    this.m_fetchSize = parseInt(this.m_options['fetchSize'], 10);
    if (isNaN(this.m_fetchSize))
    {
        this.m_fetchSize = 25;
    }
    // retrieves the maximum number of rows to fetch from the underlying data source
    // once the maximum count has been reached, this data source should stop fetching
    // until either a collapse occurs or a delete model change event.
    this.m_maxCount = parseInt(this.m_options['maxCount'], 10);
    if (isNaN(this.m_maxCount))
    {
        this.m_maxCount = 500;
    }

    // retrieves the initial expanded row keys.  If the expanded is specified to 'all',
    // then mark that all rows should be expanded initially.
    expanded = this.m_options['expanded'];
    if (Array.isArray(expanded))
    {
        this.m_expandedKeys = expanded;
    }
    else
    {
        if (expanded === 'all')
        {
            // if expand all, we'll need to keep track of collapsed keys instead
            this.m_collapsedKeys = [];
        }
        // keep track of expanded row keys
        this.m_expandedKeys = [];
    }

    // cache to keep track of indexes
    // PRIVATE.  Subclass should never need to access this directly.
    // and implementation might change to use different form of caching strategy.
    this.m_cache = [];
};

/**
 * Handle the event
 * @param {string} eventType  event type
 * @param {Object} event  event
 * @return {boolean} Returns false if event is cancelled
 * @export
 */
oj.FlattenedTreeDataSource.prototype.handleEvent = function(eventType, event)
{
    return oj.FlattenedTreeDataSource.superclass.handleEvent.call(this, eventType, event);
};

/**
 * Destroy the data source.
 * @export
 */
oj.FlattenedTreeDataSource.prototype.Destroy = function()
{
    // free internal cache
    delete this.m_cache;
    delete this.m_expandedKeys;
    delete this.m_collapsedKeys;

    // unload listener
    this.m_wrapped.off('change');

    // delegate to underlying data source
    if (this.m_wrapped.Destroy)
    {
        this.m_wrapped.Destroy();
    }
};

/**
 * Retrieves the fetch size
 * @return {number} the fetch size
 * @protected
 */
oj.FlattenedTreeDataSource.prototype.getFetchSize = function()
{
    return this.m_fetchSize;
};

/**
 * Retrieves the max count
 * @return {number} the max count
 * @protected
 */
oj.FlattenedTreeDataSource.prototype.getMaxCount = function()
{
    return this.m_maxCount;
};

/**
 * Retrieves the expanded row keys
 * @return {Array.<Object>|string} an array of expanded row keys or 'all' if 
 *         all rows are expanded.
 * @export
 */
oj.FlattenedTreeDataSource.prototype.getExpandedKeys = function()
{
    return this.m_expandedKeys;
};

/**
 * Retreives the value of the specified option.
 * @param {string} option the option to retrieve the value.
 * @return {Object} the value of the specified option.  Returns null if the
 *         value is null or if the option is not recognized.
 * @export
 */
oj.FlattenedTreeDataSource.prototype.getOption = function(option)
{
    if (this.m_options != null)
    {
        return this.m_options[option];
    }

    // unrecoginzed option or no options set
    return null;
};

/**
 * Retrieves the underlying TreeDataSource.
 * @return {Object} the underlying oj.TreeDataSource.
 * @export
 */
oj.FlattenedTreeDataSource.prototype.getWrappedDataSource = function()
{
    return this.m_wrapped;
};

/**
 * Determine the actual fetch size to use.
 * @param {number} count the child count of the parent node to fetch on.
 * @private
 */
oj.FlattenedTreeDataSource.prototype._getFetchSizeToUse = function(count)
{
    var fetchSize, maxCount;

    fetchSize = this.getFetchSize();
    maxCount = this.getMaxCount();

    if (fetchSize === -1)
    {
        if (count === -1)
        {
            return maxCount;
        }
        return count;
    }
    else
    {
        if (count === -1)
        {
            return Math.min(fetchSize, count);
        }
        return fetchSize;
    } 
};

/**
 * Fetch a range of rows from the underlying data source.  This is a convenient method that
 * the subclasses should use to fetch from the underlying TreeDataSource.  This method will take
 * care of the index mapping between a flattened range to tree indexes.
 * @param {Object} range the range of rows to fetch.  This is the range in a flattened view.
 * @param {number} range.start the start of the range in a flattened view
 * @param {number} range.count the number of rows to fetch
 * @protected
 */ 
oj.FlattenedTreeDataSource.prototype.fetchRows = function(range, callbacks)
{
    // check if we should fetch rows from descendants result set or walk the tree
    // to retrieve children
    if (this._isExpandAll())
    {
        this._fetchRowsFromDescendants(range, callbacks);
    }
    else
    {
        this._fetchRowsFromChildren(range, callbacks);
    }
};

/**
 * Fetch a range of rows from the underlying TreeDataSource.  
 * @param {Object} range the range of rows to fetch.  This is the range in a flattened view.
 * @param {number} range.start the start of the range in a flattened view
 * @param {number} range.count the number of rows to fetch
 * @protected
 */ 
oj.FlattenedTreeDataSource.prototype._fetchRowsFromChildren = function(range, callbacks)
{
    var maxFetchSize, lastEntry, parent, count, index, depth, processed, nodeSet, fetchSize;

    // this condition should always be true since in high watermark scrolling we are
    // always asking for rows after the current last row
    if (range['start'] > this._getLastIndex())
    {
        maxFetchSize = this._getMaxFetchSize();
        // initial fetch
        if (this._getLastIndex() < 0)
        {
            // adjust fetch count if neccessary
            range['count'] = Math.min(maxFetchSize, range['count']);
            this.m_wrapped.fetchChildren(null, range, {"success": function(nodeSet){this._handleFetchSuccess(nodeSet, null, 0, range, 0, callbacks);}.bind(this), "error": function(status){this._handleFetchError(status, callbacks);}.bind(this)});

            return;
        }
        else if (maxFetchSize > 0)
        {
            lastEntry = this._getLastEntry();
            parent = lastEntry['parent'];
            count = this.m_wrapped.getChildCount(parent);
            index = lastEntry['index'];
            depth = lastEntry['depth'];

            // see if we are fetching within the parent
            if (count === -1 || index < count-1)
            {
                fetchSize = this._getFetchSizeToUse(count);
                range['start'] = index+1;
                if (count === -1)
                {
                    range['count'] = fetchSize;
                }
                else
                {
                    range['count'] = Math.min(maxFetchSize, Math.min(fetchSize, count - range['start']));
                }
                this.m_wrapped.fetchChildren(parent, range, {"success": function(nodeSet){this._handleFetchSuccess(nodeSet, parent, depth, range, count, callbacks);}.bind(this), "error": function(status){this._handleFetchError(status, callbacks);}.bind(this)});
            }
            else if (index === count-1)
            {
                // if this is the last child within the parent, then we are done
                nodeSet = new oj.EmptyNodeSet(null, range['start']);
                // invoke original success callback
                if (callbacks != null && callbacks['success'] != null)
                {
                    callbacks['success'].call(null, nodeSet);
                }
            }
            else
            {
                // fetch size is greater than the number of children remaining to fetch
                // so we'll need to go up the path (recursively if necessary) and see if
                // if we need to fetch from ancestors.
                processed = this._fetchFromAncestors(parent, depth, callbacks, maxFetchSize);
                if (!processed)
                {
                    // nothing is used from node set, just return a empty node set
                    nodeSet = new oj.EmptyNodeSet(null, range['start']);
                    // invoke original success callback
                    if (callbacks != null && callbacks['success'] != null)
                    {
                        callbacks['success'].call(null, nodeSet);
                    }
                }
            }
            return;
        }
    }

    // the only case we'll ended up here is if the max count has been reached or
    // for some reason the caller is asking for count = 0
    this.handleMaxCountReached(range, callbacks);
};

/**
 * Determine the maximum possible fetch size.
 * @return {number} the maximum fetch size
 * @private
 */
oj.FlattenedTreeDataSource.prototype._getMaxFetchSize = function()
{
    return this.getMaxCount() - (this._getLastIndex()+1);
};

/**
 * Process error callback for fetchChildren operation before handing it back to original caller.
 * @param {Object} status the error status
 * @param {Object} callbacks the original callbacks passed to the fetch operation
 * @private
 */
oj.FlattenedTreeDataSource.prototype._handleFetchError = function(status, callbacks)
{
    if (callbacks != null && callbacks['error'] != null)
    {
        callbacks['error'].call(null, status);
    }
};

/**
 * Process success callback for fetchChildren operation before handing it back to original caller.
 * @param {Object} nodeSet the set of fetched nodes
 * @param {Object} parent the parent key of the fetch operation
 * @param {number} depth the depth of the nodes
 * @param {Object} range the request range for the fetch operation
 * @param {number} count the child count of the parent, -1 if count is unknown
 * @param {Object} callbacks the original callbacks passed to the fetch operation
 * @private
 */
oj.FlattenedTreeDataSource.prototype._handleFetchSuccess = function(nodeSet, parent, depth, range, count, callbacks)
{
    var toExpand, processed, queue, prevNodeSetInfo;

    // handle result nodeSet
    toExpand = [];
    // wrap it to inject additional metadata
    nodeSet = new oj.NodeSetWrapper(nodeSet, this.insertMetadata.bind(this), range);
    this._processNodeSet(nodeSet, parent, depth, toExpand);

    // if child count is unknown and the result fetched from parent is less than what we asked for 
    // and it's not a root node, go up one level and try to fetch results from its grandparent
    if (count === -1 && nodeSet.getCount() === 0 && parent != null && depth > 0)
    {
        processed = this._fetchFromAncestors(parent, depth, callbacks);
        if (!processed)
        {
            // if nothing is fetched from ancestors, then just return the original empty set
            if (callbacks != null && callbacks['success'] != null)
            {
                callbacks['success'].call(null, nodeSet);
            }
        }
    }
    else
    {
        if (toExpand.length === 0)
        {
            // invoke original success callback
            if (callbacks != null && callbacks['success'] != null)
            {
                callbacks['success'].call(null, nodeSet);
            }
        }
        else
        {
            // there are rows to expand, so we'll need to combine the nodeset after
            // we got the expanded nodeset
            queue = [];
            queue.push(toExpand);

            // we'll reuse the syncExpandRows method, which is used to combine nested expanding
            // nodeset, the only difference is we'll include the callbacks here, see handleExpandSuccess method
            prevNodeSetInfo = {};
            prevNodeSetInfo['callbacks'] = callbacks;
            prevNodeSetInfo['nodeSet'] = nodeSet;
            prevNodeSetInfo['keys'] = [];

            this._syncExpandRows(queue, prevNodeSetInfo);
        }
    }
};

/**
 * Go up ancestors and fetch build up fetch requests (if possible) until the fetch size is met.
 * @param {Object} parent the parent key of the fetch operation
 * @param {number} depth the depth of the nodes
 * @param {Object} callbacks the original callbacks passed to the fetch operation
 * @param {number=} maxFetchSize maximum fetch size, optional
 * @return {boolean} true if results are fetched, false if nothing is fetched
 * @private
 */
oj.FlattenedTreeDataSource.prototype._fetchFromAncestors = function(parent, depth, callbacks, maxFetchSize)
{
    var options, remainToFetch, current, i, currEntry, currDepth, count, index, countUnknown, range, fetchSize;

    if (maxFetchSize === undefined)
    {
        maxFetchSize = this._getMaxFetchSize();
    }

    // fetch size is greater than the number of children remaining to fetch
    // so we'll need to go up the path (recursively if necessary) and see if
    // if we need to fetch from ancestors.
    if (this._isBatchFetching())
    {
        options = {'queueOnly': true};
    }

    fetchSize = this._getFetchSizeToUse(-1);
    remainToFetch = fetchSize;
    current = this._getLastIndex();
    for (i=current-1; i>=0; i--)
    {
        currEntry = this._getEntry(i);
        currDepth = currEntry['depth'];
        if (currDepth < depth)
        {
            parent = currEntry['parent'];
            count = this.m_wrapped.getChildCount(parent);
            index = currEntry['index'];

            countUnknown = (count === -1);
            if (countUnknown || index < count-1)
            {
                range = {};
                range['start'] = index+1;
                if (countUnknown)
                {
                    range['count'] = Math.min(maxFetchSize, Math.max(0, remainToFetch));
                    // if count is unknown, we cannot do batch fetch
                    options = undefined;
                    // stop going up parents
                }
                else
                {
                    range['count'] = Math.min(maxFetchSize, Math.min(remainToFetch, count - range['start']));
                }

                // if there's nothing to fetch, quit
                if (range['count'] == 0)
                {
                    break;
                }
                
                // it's always attached at the end
                this.m_wrapped.fetchChildren(parent, range, {"success": function(nodeSet){this._handleFetchSuccess(nodeSet, parent, currDepth, range, count, callbacks);}.bind(this), "error": function(status){this._handleFetchError(status, callbacks);}.bind(this)}, options);

                // go up one level
                depth = currDepth;
                // update remaining fetch count
                remainToFetch = Math.max(0, remainToFetch - range['count']);

                // done if count is unknown or we've reach the root or we've reach total number of rows we want to fetch
                if (countUnknown || currDepth === 0 || remainToFetch === 0)                
                {
                    break;
                }
            }
        }
    }            

    // if batching is used, fire a final fetch children call to flush the queue
    if (options != undefined)
    {
        this.m_wrapped.fetchChildren(parent, {'start': range['count'], 'count': 0}, {"success": function(nodeSet){this._handleFetchSuccess(nodeSet, parent, currDepth, range, count, callbacks);}.bind(this), "error": function(status){this._handleFetchError(status, callbacks);}.bind(this)});
    }

    // return false if no results are fetched
    return (remainToFetch != fetchSize);
};

/**
 * Walk the node set and do whatever processing is neccessary.
 * @param {Object} nodeSet the node set to process
 * @param {Object} parent the parent key of the nodes
 * @param {number} depth the depth of the nodes
 * @param {Array.<Object>=} toExpand the set of keys to be expand.  It is populated by this method.
 * @private
 */
oj.FlattenedTreeDataSource.prototype._processNodeSet = function(nodeSet, parent, depth, toExpand)
{
    var nodeStart, nodeCount, i, metadata, key;

    nodeStart = nodeSet.getStart();
    nodeCount = nodeSet.getCount();

    // walk the node set and populate the internal cache
    for (i=0; i<nodeCount; i++)
    {
        metadata = nodeSet.getMetadata(nodeStart+i);
        key = metadata['key'];

        this._addEntry(key, depth, nodeStart+i, parent);

        if (this._isExpanded(key))
        {
            // keep track of rows that needs to expand later
            toExpand.push(key);
        }
    }
};

/**
 * A hook for FlattenedTreeDataSource to inject additional metadata into the NodeSet
 * @param {Object} key the row key identifying the row
 * @param {Object} metadata the existing metadata to inject into
 * @protected
 */
oj.FlattenedTreeDataSource.prototype.insertMetadata = function(key, metadata)
{
    if (this._isExpanded(key) && !metadata['leaf'])
    {
        // also update metadata with state info
        metadata['state'] = 'expanded';
    }
    else
    {
        // include state metadata for row expander to render correct icon
        if (metadata['leaf'])
        {
            metadata['state'] = 'leaf';
        }
        else
        {
            metadata['state'] = 'collapsed';
        }
    }
};

/**
 * Fetch a range of rows from the underlying TreeDataSource using the fetchDescendants method.
 * @param {Object} range the range of rows to fetch.  This is the range in a flattened view.
 * @param {number} range.start the start of the range in a flattened view
 * @param {number} range.count the number of rows to fetch
 * @protected
 */ 
oj.FlattenedTreeDataSource.prototype._fetchRowsFromDescendants = function(range, callbacks)
{
    // give implementation a hint of maximum to fetch, implementation can choose to ignore it
    var options = {'maxCount': this.getMaxCount()};

    // give implementation a hint of where to start, implementation can choose to ignore it
    if (this._getLastIndex() >= 0)
    {
        options['start'] = this._getEntry(this._getLastIndex())['key'];
    }

    // invoke method on TreeDataSource
    this.m_wrapped.fetchDescendants(null, {"success": function(nodeSet){this._handleFetchDescendantsSuccess(nodeSet, range, callbacks);}.bind(this), "error": function(status){this._handleFetchError(status, callbacks);}.bind(this)}, options);
};

/**
 * Process success callback for fetchDescendants operation before handing it back to original caller.
 * @param {Object} nodeSet the set of fetched nodes
 * @param {Object} range the request range for the fetch operation
 * @param {Object} callbacks the original callbacks passed to the fetch operation
 * @private
 */
oj.FlattenedTreeDataSource.prototype._handleFetchDescendantsSuccess = function(nodeSet, range, callbacks)
{
    var maxFetchSize, count, lastEntry, options, actualStart;

    // this condition should always be true since in high watermark scrolling we are
    // always asking for rows after the current last row
    if (range['start'] > this._getLastIndex())
    {
        maxFetchSize = this._getMaxFetchSize();
        count = Math.min(maxFetchSize, range['count']);

        // wrap it to inject additional metadata
        nodeSet = new oj.NodeSetWrapper(nodeSet, this.insertMetadata.bind(this), null, this.m_collapsedKeys);

        if (this._getLastIndex() >= 0)
        {
            // in fetchDescendants case, the result node set would probably contains more than what
            // we would return.  The issue is we can't really use range to filter the set since the 
            // range in the current view does not map one-to-one to the expand all node set as some 
            // node might have been collapsed before the fetch.
            // the solution is to use the last cached entry to find where new data starts in the
            // result node set, and use range count to limit what to return
            lastEntry = this._getLastEntry();
            options = {'index': 0, 'found': false, 'count': 0};
            this._processDescendantsNodeSet(nodeSet, null, 0, lastEntry, count, options);
            actualStart = options['index'] + 1;
        }
        else
        {
            // initial fetch case, just specify the count to limit result
            options = {'count': 0};
            this._processDescendantsNodeSet(nodeSet, null, 0, null, count, options);
            actualStart = 0;
        }

        if (callbacks != null && callbacks['success'] != null)
        {
            if (options != null)
            {
                if (options['count'] === 0)
                {
                    // nothing is used from node set, just return a empty node set
                    nodeSet = new oj.EmptyNodeSet(null, range['start']);
                }
                else 
                {
                    // wraps node set with a filter that only returns nodes that
                    // have not been fetched already
                    nodeSet = new oj.FlattenedNodeSet(nodeSet, actualStart);
                }
            }
            else
            {
                nodeSet = new oj.FlattenedNodeSet(nodeSet);
            }
            callbacks['success'].call(null, nodeSet);
        }

        return;
    }

    // the only case we'll ended up here is if the max count has been reached or
    // for some reason the caller is asking for count = 0
    this.handleMaxCountReached(range, callbacks);
};

/**
 * Walk the node set and do whatever processing is neccessary.
 * @param {Object} nodeSet the node set to process
 * @param {Object} parent the parent key of the nodes
 * @param {number} depth the depth of the nodes
 * @param {Object} lastEntry the last fetched entry
 * @param {number} maxCount the maximum number of rows to process
 * @param {Object=} options this object carries information collected in this method
 * @private
 */
oj.FlattenedTreeDataSource.prototype._processDescendantsNodeSet = function(nodeSet, parent, depth, lastEntry, maxCount, options)
{
    var nodeStart, nodeCount, i, metadata, key, childNodeSet;

    nodeStart = nodeSet.getStart();
    nodeCount = nodeSet.getCount();

    // walk the node set and populate the internal cache
    for (i=0; i<nodeCount; i++)
    {
        // see if we have enough results
        if (options['count'] == maxCount)
        {
            return;
        }

        metadata = nodeSet.getMetadata(nodeStart+i);
        key = metadata['key'];

        // see if we need to check depth
        if (options['checkDepth'])
        {
            if (lastEntry['depth'] === depth)
            {
                options['found'] = true;
                options['checkDepth'] = false;
            }
        }

        if (lastEntry == null || options['found'])
        {
            this._addEntry(key, depth, nodeStart+i, parent);

            options['count'] = options['count'] + 1;

            // include state metadata for row expander
            // in the fetchDescendants case the state is always 'expanded'
            if (metadata['leaf'])
            {
                metadata['state'] = 'leaf';
            }
            else
            {
                metadata['state'] = 'expanded';
            }
        }

        // mark we found the entry in node set that matches the last key
        // the rest of node set we can start pushing to cache
        if (lastEntry != null && !options['found'])
        {
            // we'll need to also check whether the last entry is expanded (or not leaf)
            // if it is collapsed, then we can't add any nodes from the node set until
            // we found child in the node set that has the same depth
            if (key === lastEntry['key'])
            {
                if (metadata['leaf'] || this._isExpanded(key))
                {
                    options['found'] = true;
                }
                else
                {
                    // collapsed.  Mark to check the depth of the next node before
                    // setting found to true.
                    options['checkDepth'] = true;
                }
            }
            else
            {
                options['index'] = options['index'] + 1;
            }
        }

        // process child node set, if any
        if (nodeSet.getChildNodeSet && this._isExpanded(key))
        {
            childNodeSet = nodeSet.getChildNodeSet(i);
            if (childNodeSet != null)
            {
                this._processDescendantsNodeSet(childNodeSet, key, depth+1, lastEntry, maxCount, options);
            }
        }
    }
};

/**
 * Expand the specified row.
 * @param {Object} rowKey the key of the row to expand
 * @export
 */
oj.FlattenedTreeDataSource.prototype.expand = function(rowKey)
{
    this._expand(rowKey);
};

/**
 * Expand the specified row with options
 * @param {Object} rowKey the key of the row to expand
 * @param {Object=} options additional options to pass to fetchChildren method
 * @private
 */
oj.FlattenedTreeDataSource.prototype._expand = function(rowKey, options)
{
    var count, fetchSize, maxCount, refIndex, queue, prevNodeSetInfo;

    count = this.m_wrapped.getChildCount(rowKey);
    fetchSize = this._getFetchSizeToUse(count);
    maxCount = this.getMaxCount();

    // if cache is full, check if the rowKey is the last row, if it's
    // the last row do nothing
    if (this._getLastIndex()+1 === maxCount)
    {
       refIndex = this.getIndex(rowKey);
       if (refIndex == maxCount-1)
       {
           // we'll still have to return an empty nodeset to trigger done to occur in handleExpandSuccess
           this.handleExpandSuccess(rowKey, new oj.EmptyNodeSet(rowKey, 0), 0, options);
           return;
       }
    }

    // nothing to do
    if (fetchSize == 0)
    {
        // we'll still have to return an empty nodeset to trigger done to occur in handleExpandSuccess
        this.handleExpandSuccess(rowKey, new oj.EmptyNodeSet(rowKey, 0), 0, options);
        return;
    }

    this.m_wrapped.fetchChildren(rowKey, {"start": 0, "count": fetchSize}, {"success": function(nodeSet){this.handleExpandSuccess(rowKey, nodeSet, count, options);}.bind(this), "error": function(status){this.handleExpandError(rowKey, status);}.bind(this)});
};

/**
 * Collapse the specified row.
 * @param {Object} rowKey the key of the row to collapse
 * @export
 */
oj.FlattenedTreeDataSource.prototype.collapse = function(rowKey)
{
    var rowIndex, parent, count, depth, lastIndex, i, j, keys;

    rowIndex = this.getIndex(rowKey) + 1;
    parent = this._getEntry(rowIndex-1);

    // keeping track of how many rows to remove
    count = 0;

    depth = parent['depth'];
    lastIndex = this._getLastIndex();
    for (j=rowIndex; j<lastIndex+1; j++)
    {
        var rowData = this._getEntry(j);
        var rowDepth = rowData['depth'];
        if (rowDepth > depth)
        {
            count = count + 1;
        }
        else if (rowDepth == depth)
        {
            break;
        }
    }

    // nothing to do
    if (count == 0)
    {
        // still should fire an event to get the icon state updated properly
        this.handleEvent("collapse", {'rowKey':rowKey});
        return;
    }

    // remove from expanded keys or add to collapsed keys
    if (this._isExpandAll())
    {
        this.m_collapsedKeys.push(rowKey);
    }
    else
    {
        this._removeExpanded(rowKey);
    }

    // remove rows from view
    keys = [];
    for (i=0; i<count; i++)
    {
        keys.push({"key": this._getEntry(rowIndex+i)['key'], "index":rowIndex+i});
    }

    // remove from cache.  Note this has to be done before firing row remove event
    // since it could cause a fetch which relies on the internal cache being up to date.
    this._removeEntry(rowIndex, count);    

    // (firing of event to view)
    this.removeRows(keys);

    // fire datasource event
    this.handleEvent("collapse", {'rowKey':rowKey});
};

/**
 * Checks whether the row key is expanded.
 * @param {Object} rowKey the key of the row to inquire the state
 * @return {boolean} true if the row is/should be expanded.  False otherwise.
 * @private
 */
oj.FlattenedTreeDataSource.prototype._isExpanded = function(rowKey)
{
    if (this._isExpandAll())
    {
        if (this.m_collapsedKeys && this.m_collapsedKeys.length > 0)
        {
            // call helper method to check collapsed keys
            return (this._getCollapsedKeyIndex(rowKey) === -1);
        }
        else
        {
            // everything expanded
            return true;
        }
    }
    else
    {
        if (this.m_expandedKeys && this.m_expandedKeys.length > 0)
        {
            // call helper method to check expanded keys
            return (this._getExpandedKeyIndex(rowKey) > -1);
        }
        else
        {
            // nothing expanded
            return false;
        }
    }
};

/**
 * Helper method to retrieve the index of the row key in the set of collapsed row keys
 * @param {Object} rowKey the key of the row
 * @return {number} the index of the key in the collapsed key array
 * @private
 */ 
oj.FlattenedTreeDataSource.prototype._getCollapsedKeyIndex = function(rowKey)
{
    return this._getKeyIndex(this.m_collapsedKeys, rowKey);
};

/**
 * Helper method to retrieve the index of the row key in the set of expanded row keys
 * @param {Object} rowKey the key of the row
 * @return {number} the index of the key in the expanded key array
 * @private
 */ 
oj.FlattenedTreeDataSource.prototype._getExpandedKeyIndex = function(rowKey)
{
    return this._getKeyIndex(this.m_expandedKeys, rowKey);
};

/**
 * Helper method to retrieve the index of the row key in a specified array
 * @param {Object} rowKey the key of the row
 * @return {number} the index of the key in the array
 * @private
 */ 
oj.FlattenedTreeDataSource.prototype._getKeyIndex = function(arr, rowKey)
{
    var i, index;

    index = -1;
    for (i=0; i<arr.length; i++)
    {
        if (arr[i] === rowKey)
        {
            index = i;
        }
    }

    return index;
}

/**
 * Remove the row key from the expanded cache
 * @param {Object} rowKey the key to remove
 * @private
 */
oj.FlattenedTreeDataSource.prototype._removeExpanded = function(rowKey)
{
    var index = this._getExpandedKeyIndex(rowKey);

    // index found, remove from array
    if (index > -1)
    {
        this.m_expandedKeys.splice(index, 1);    
    }
};

/**
 * Remove the row key from the collapsed cache
 * @param {Object} rowKey the key to remove
 * @private
 */
oj.FlattenedTreeDataSource.prototype._removeCollapsed = function(rowKey)
{
    var index = this._getCollapsedKeyIndex(rowKey);

    // index found, remove from array
    if (index > -1)
    {
        this.m_collapsedKeys.splice(index, 1);    
    }
};

/**
 * Callback method to handle fetch error on expand operation.
 * @param {Object} rowKey the key of the expanded row
 * @param {Object} status the error status
 * @protected
 */
oj.FlattenedTreeDataSource.prototype.handleExpandError = function(rowKey, status)
{
    // fire event, todo: should include error msg?
    this.handleEvent("expand", {'rowKey':rowKey});
};

/**
 * Callback method to handle fetch success on expand operation.
 * @param {Object} rowKey the key of the expanded row
 * @param {Object} nodeSet the node set that describes the children of the expanded row
 * @param {number} childCount the total number of children the expanded row has
 * @param {Object=} options optional parameters to the method
 * @param {Object=} options.queue a queue of expanded rows remaining to process (depth first traversal)
 * @param {Object=} options.prevNodeSetInfo.nodeSet the node set from a previous expand call
 * @param {number=} options.prevNodeSetInfo.firstIndex the ref index for the FIRST expand call, this is needed when firing the insert event, where the insertion point is the first index
 * @param {Object=} options.prevNodeSetInfo.firstKey the ref row key for the FIRST expand call, this is needed when firing the insert event, where the insertion point is the first row key
 * @protected
 */
oj.FlattenedTreeDataSource.prototype.handleExpandSuccess = function(rowKey, nodeSet, childCount, options)
{
    var refIndex, rangeStart, rowStart, rowCount, parent, depth, metadata, key, toExpand, i, j, queue, prevNodeSetInfo, done, maxCount, callbacks;

    // wrap it to inject additional metadata
    nodeSet = new oj.NodeSetWrapper(nodeSet, this.insertMetadata.bind(this));

    refIndex = this.getIndex(rowKey) + 1;
    rangeStart = refIndex;

    rowStart = nodeSet.getStart();
    rowCount = nodeSet.getCount();

    parent = this._getEntry(refIndex-1);
    depth = parent['depth']+1;

    toExpand = [];

    // go through the node set and insert an entry with info about the row into internal cache
    for (i=rowStart; i<rowCount; i++)
    {
        metadata = nodeSet.getMetadata(i);
        key = metadata['key'];
        if (this._isExpanded(key))
        {
            // expand it if the user specified it to be expand (or the
            // parent was previously collapsed before and now expanded again, 
            // the expanded child would need to be expanded also)
            toExpand.push(key);
        }

        // add to cache
        this._insertRow(refIndex, metadata, parent['key'], i, depth);

        refIndex++;
    }

    // keep track of expanded row or collapsed row for expand all case
    if (this._isExpandAll())
    {
        this._removeCollapsed(rowKey);
    }
    else
    {
        // check whether it's already in expanded keys, which is the case
        // if it is expanded by initial expansion
        if (this.m_expandedKeys.indexOf(rowKey) === -1)
        {
            this.m_expandedKeys.push(rowKey);
        }
    }

    // extract optional params
    if (options != undefined)
    {
        queue = options['queue'];
        prevNodeSetInfo = options['prevNodeSetInfo'];
    }

    // see if a previous nodeset has been set and merge with current one 
    // so that we have one nodeset that includes expanded children, a single row insert event
    // is fired and the nodeset will be in the proper order
    if (prevNodeSetInfo != undefined)
    {
        nodeSet = new oj.MergedNodeSet(prevNodeSetInfo['nodeSet'], nodeSet, rowKey);
    }

    // check if there's nothing else to expand and process queue is empty
    done = (toExpand.length == 0 && (queue === undefined || queue.length == 0));
    if (done)
    {
        // fire event to insert the expanded rows
        if (prevNodeSetInfo != undefined)
        {
            // check if this is part of a fetchRows call
            callbacks = prevNodeSetInfo['callbacks'];
            if (callbacks != null)
            {
                // invoke fetch success callback
                callbacks['success'].call(null, nodeSet);
                
                // we are done at this point, we don't fire insert events
                return;
            }
            else
            {
                // use the reference insertion point from prevNodeSetInfo instead
                this.insertRows(prevNodeSetInfo['firstIndex'], prevNodeSetInfo['firstKey'], nodeSet);
            }
        }
        else
        {
            this.insertRows(rangeStart, rowKey, nodeSet);
        }

        // if child count is > fetched or child count is unknown and requested fetch count is the same as result set size, 
        // then delete all rows that comes after the reference row so that we can trigger a fetch when user scroll to the end
        // ALSO delete all rows that comes after reference row if the reference row is the last row (according to max row count)
        maxCount = this.getMaxCount();
        if ((childCount === -1 && rowCount === this.getFetchSize()) || childCount > rowCount || refIndex == maxCount)
        {
            this._deleteAllRowsBelow(refIndex);
        }
        else if (this._getLastIndex() >= maxCount)
        {
            // also clean up rows that goes beyond max row count after expand
            this._deleteAllRowsBelow(maxCount);
        }

        if (prevNodeSetInfo != undefined)
        {
            // fire expand event for each row key cached in prevNodeSetInfo
            for (j=0; j<prevNodeSetInfo['keys'].length; j++)
            {
                this.handleEvent("expand", {'rowKey':prevNodeSetInfo['keys'][j]});
            }
        }

        // fire event
        this.handleEvent("expand", {'rowKey':rowKey});
    }
    else
    {
        // there are still child rows to expand
        // create queue if not yet created
        if (queue === undefined)
        {
            queue = [];
        }

        // push expanded rows to the queue
        if (toExpand.length > 0)
        {
            queue.push(toExpand);
        }

        // create prevNodeSetInfo if not yet created
        if (prevNodeSetInfo === undefined)
        {
            prevNodeSetInfo = {};
            // populate the initial insertion index and key, this is needed when we are actually firing
            // the insert event
            prevNodeSetInfo['firstIndex'] = rangeStart;
            prevNodeSetInfo['firstKey'] = rowKey;
            // cache of row keys for firing expand event when everything is done
            prevNodeSetInfo['keys'] = [];
        }

        // update the previous node set 
        prevNodeSetInfo['nodeSet'] = nodeSet;
        // update keys array for fire expand events later
        prevNodeSetInfo['keys'].push(rowKey);

        // expand any child rows that should be expanded
        this._syncExpandRows(queue, prevNodeSetInfo);
    }
};

/**
 * Expands the specified array of rows synchronously, i.e. one will not start until the previous one is finished.
 * @param {Object} queue the queue of a set of expanded row keys remaining to process 
 * @param {Object} prevNodeSetInfo node set from the previous expand call
 * @private 
 */ 
oj.FlattenedTreeDataSource.prototype._syncExpandRows = function(queue, prevNodeSetInfo)
{
    var last, key, options;

    // peek the last set of expanded rows from queue (since we are doing depth first traversal)
    last = queue[queue.length-1];    
    // then take the first row key from the set
    key = last.shift();
    // if this is the last item in the set, we can remove the set from queue
    if (last.length === 0)
    {
        queue.pop();
    }

    this._expand(key, {'prevNodeSetInfo': prevNodeSetInfo, 'queue': queue});    
};

/**
 * Expands the specified array of rows.  Use batch fetching if supported.
 * @param {Array.<Object>} keys an array of row keys.
 * @private
 */
oj.FlattenedTreeDataSource.prototype._expandRows = function(keys)
{
    var options, i;

    // use batch fetching if supported so we'll have less trip to server.
    if (this._isBatchFetching())
    {
        options = {'queueOnly': true};
    }

    // expand each of the rows
    for (i=0; i<keys.length; i++)
    {
        // last expand should not have any options set to flush to batch queue
        if (i == keys.length-1)
        {
            this._expand(keys[i]);
        }            
        else
        {
            this._expand(keys[i], options);
        }            
    }
};

/**
 * Insert a single row of data into the cache
 * @param {number} index the index (based on flattened view) where this is inserted
 * @param {Object} metadata the metadata of the inserted node
 * @param {Object} parentKey the key of the parent node
 * @param {number} childIndex the index relative to its parent where this is inserted
 * @param {number} depth the depth of the node
 * @private
 */
oj.FlattenedTreeDataSource.prototype._insertRow = function(index, metadata, parentKey, childIndex, depth)
{
    var key, rowData;

    key = metadata['key'];

    if (index <= this._getLastIndex())    
    {
        this._addEntry(key, depth, childIndex, parentKey, index);
    }
    else
    {
        this._addEntry(key, depth, childIndex, parentKey);
    }
};

/**
 * Remove all rows below the row of the specified index including this row.
 * @param {number} index the index from which we start to delete rows 
 * @param {number=} count the number of rows to delete.  If not specified, then delete until the end.
 * @private
 */
oj.FlattenedTreeDataSource.prototype._deleteAllRowsBelow = function(index, count)
{
    var keys, event;

    if (count == undefined)
    {
        count = this._getLastIndex()+1 - index;
    }

    keys = [];
    for (var i=0; i<count; i++)
    {
        keys.push({"row": this._getEntry(index+i)['key'], "index":index+i});
    }

    // update internal cache
    this._removeEntry(index, count);    

    // fire event to remove rows from view
    this.removeRows(keys);
};

/**
 * Handles model event from underlying TreeDataSource.
 * @param {Event} event the model change event
 * @private
 */
oj.FlattenedTreeDataSource.prototype._handleModelEvent = function(event)
{
    var operation, ancestors, parentKey, index;

    operation = event['operation'];
    ancestors = event['parent'];
    if (Array.isArray(ancestors))
    {
        // take the direct key of direct parent
        parentKey = ancestors[ancestors.length-1];
    }
    else
    {
        // single element or null, value is the parent key
        parentKey = ancestors;
    }
    index = event['index'];

    if (operation === 'insert')
    {
        this._handleInsertEvent(parentKey, index, event['data']);
    }
    else if (operation === 'delete')
    {
        this._handleDeleteEvent(parentKey, index);
    }
    else if (operation === 'refresh')
    {
        this._handleRefreshEvent(parentKey);
    }
};

/**
 * Handles insert event from TreeDataSource.
 * @param {Object} parentKey the key of the parent where the node is inserted
 * @param {number} index the index relative to its parent where the noce is inserted
 * @param {Object} nodeSet the node set containing the single insert data
 * @private
 */
oj.FlattenedTreeDataSource.prototype._handleInsertEvent = function(parentKey, index, nodeSet)
{
    var parentIndex, parent, depth, insertIndex, metadata;

    parentIndex = this.getIndex(parentKey);
    parent = this._getEntry(parentIndex);
    depth = parent['depth']+1;
    insertIndex = parentIndex + index + 1;

    // there should only be one row in the set
    metadata = nodeSet.getMetadata(nodeSet.getStart());

    // insert into cache
    this._insertRow(insertIndex, metadata, parentKey, index, depth)    
};

/**
 * Handles delete event from TreeDataSource.
 * @param {Object} parentKey the key of the parent where the node is inserted
 * @param {number} index the index relative to its parent where the noce is inserted
 * @private
 */
oj.FlattenedTreeDataSource.prototype._handleDeleteEvent = function(parentKey, index)
{
    var parentIndex, parent, startIndex, start, count, currentIndex, lastIndex, current;

    parentIndex = this.getIndex(parentKey);
    parent = this._getEntry(parentIndex);

    startIndex = parentIndex + index;
    start = this._getEntry(startIndex);

    // make sure the child data is valid
    oj.Assert.assert(start['parent'] === parent && start['depth'] === parent['depth']+1); 

    // remove the entry and all of its children from cache
    count = 1;
    currentIndex = startIndex + 1;
    lastIndex = this._getLastIndex();
    while (currentIndex <= lastIndex)
    {
        current = this._getEntry(currentIndex);
        // check if we have reached the last child of the deleted node
        if (current['depth'] != start['depth'])
        {
            break;
        }      
        currentIndex++;
    }

    // remove rows
    this._deleteAllRowsBelow(startIndex, count);
};

/**
 * Handles refresh event from TreeDataSource.
 * @param {Object} parentKey the key of the parent where the node is inserted
 * @private
 */
oj.FlattenedTreeDataSource.prototype._handleRefreshEvent = function(parentKey)
{
    if (parentKey == null)
    {
        // the entire tree is refreshed
        // clean up internal cache
        this.refresh();
    }
};

/**
 * Checks whether all rows should be expanded.
 * @return {boolean} true if expand all rows, false otherwise.
 * @private
 */
oj.FlattenedTreeDataSource.prototype._isExpandAll = function()
{
    var capability = this.m_wrapped.getCapability('fetchDescendants');
    return (this.m_collapsedKeys != undefined && capability != null && capability != 'disable');
};

/**
 * Checks whether batch fetching is supported.
 * @return {boolean} true if batch fetching is supported, false otherwise.
 * @private
 */
oj.FlattenedTreeDataSource.prototype._isBatchFetching = function()
{
    var capability = this.m_wrapped.getCapability('batchFetch');
    return (capability === 'enable');
};

/////////////////////////////// helper methods subclass should find useful //////////////////////////////////////////////
/**
 * Refresh the data source.  Clear out any state.
 * @protected
 */
oj.FlattenedTreeDataSource.prototype.refresh = function()
{
    // clear the cache
    this._clearAll();

    // todo: more work here to force fetch (remove then insert)
};

/**
 * Determine the flattened index for the specified key
 * @param {Object} rowKey the key to find the index
 * @return {number} the index representing the specified key.  Returns -1 if the index
 *         cannot be found.
 * @protected
 */
oj.FlattenedTreeDataSource.prototype.getIndex = function(rowKey)
{
    var lastIndex, i, rowData;

    lastIndex = this._getLastIndex();
    for (i=0; i<=lastIndex; i++)
    {
        rowData = this._getEntry(i);
        if (rowData['key'] == rowKey)
        {
            return i;
        }
    }

    // can't find it, return -1
    return -1;
};

/**
 * Determines the key for the specified flattened index
 * @param {number} index the index in flattened view
 * @return {Object|null} the key for the specified index.  Returns null if the index has not been
 *         fetched yet or is invalid.
 * @protected
 */
oj.FlattenedTreeDataSource.prototype.getKey = function(index)
{
    // ensure the index is valid and in range
    if (index < 0 || index > this._getLastIndex())
    {
        return null;
    }

    // just return from internal cache
    return this._getEntry(index)['key'];
};

/**
 * Returns the currently fetched range.
 * @return {Object} the fetched range (start, end).
 * @protected
 */
oj.FlattenedTreeDataSource.prototype.getFetchedRange = function()
{
    return {'start': 0, 'end': this._getLastIndex()+1};
};

/**
 * Returns the key of the ancestors.
 * @param {Object} rowKey the row key to find the ancestors.
 * @return {Array} an array of the key of the ancestors from root to the row with specified row key.
 * @protected
 */
oj.FlattenedTreeDataSource.prototype.getAncestors = function(rowKey)
{
    var ancestors, index, parent;

    ancestors = [];
    index = this.getIndex(rowKey);
    parent = this._getParent(index);
    while (parent != null)
    {
        ancestors.push(parent);
        index = this.getIndex(parent);
        parent = this._getParent(index);
    }

    // reverse since we want to return from the root
    return ancestors.reverse();
};
///////////////////////////////////// methods subclass must override ////////////////////////////////////////////////////////
/**
 * Handles what happened when the maximum row count has been reached.
 * @param {Object} range the range of the fetch request which caused the max count to be reached.
 * @param {Object} callbacks the callbacks of the fetch request which caused the max count to be reached.
 * @protected
 */
oj.FlattenedTreeDataSource.prototype.handleMaxCountReached = function(range, callbacks)
{
    // send an error by default
    if (callbacks != null && callbacks['error'] != null)
    {
        callbacks['error'].call(null);
    }
};

/**
 * Abstract method to insert a set of rows into the DataGrid/Table
 * @param {number} insertAtIndex the flattened index of the node where the rows are inserted.
 * @param {Object} insertAtKey the key of the node where the rows are inserted (the parent key)
 * @param {Object} nodeSet the node set containing data/metadata of inserted rows
 * @protected
 */
oj.FlattenedTreeDataSource.prototype.insertRows = function(insertAtIndex, insertAtKey, nodeSet)
{
    oj.Assert.failedInAbstractFunction();
};

/**
 * Abstract method to remove the specified rows in the DataGrid/Table
 * @param {Array.<Object>} rowKeys an array of keys of the rows to be remove.
 * @protected
 */
oj.FlattenedTreeDataSource.prototype.removeRows = function(rowKeys)
{
    oj.Assert.failedInAbstractFunction();
};

///////////////////////////////// methods that manipulates the internal cache ///////////////////////////////////
/**
 * Retrieve the flattened index of the last entry fetched so far
 * @return {number} the flattened index of the last entry
 * @private
 */
oj.FlattenedTreeDataSource.prototype._getLastIndex = function()
{
    return this.m_cache.length-1;
};

/**
 * Retrieve the metadata for the last entry fetched so far
 * @return {Object} the metadata for the last entry
 * @private
 */
oj.FlattenedTreeDataSource.prototype._getLastEntry = function()
{
    return this.m_cache[this._getLastIndex()];
};

/**
 * Retrieve metadata info for the specified index.
 * @param {number} index the flattened index 
 * @return {Object} the metadata info
 * @private
 */
oj.FlattenedTreeDataSource.prototype._getEntry = function(index)
{
    return this.m_cache[index];
};

/**
 * Retrieve the parent key for the specified index.
 * @param {number} index the flattened index 
 * @return {Object} the parent key
 * @private
 */
oj.FlattenedTreeDataSource.prototype._getParent = function(index)
{
    var entry = this.m_cache[index];
    if (entry != null)
    {
        return entry['parent'];
    }
    return null;
};

/**
 * Add or insert entry to the cache
 * @param {Object} key the key
 * @param {number} depth the depth 
 * @param {number} index the index relative to its parent
 * @param {Object} parent the parent
 * @param {number=} insertAt insert the metadata entry at this flattened index
 * @private
 */
oj.FlattenedTreeDataSource.prototype._addEntry = function(key, depth, index, parent, insertAt)
{
    var rowData = new Object();
    rowData['key'] = key;
    rowData['depth'] = depth;
    rowData['index'] = index;
    rowData['parent'] = parent;

    if (insertAt === undefined)
    {
        this.m_cache.push(rowData);
    }
    else
    {
        this.m_cache.splice(insertAt, 0, rowData);
    }
};

/**
 * Remove entry from cache
 * @param {number} index the flattened index to start remove entry
 * @param {number} count how many entries to remove starting from the flattened index
 * @private
 */
oj.FlattenedTreeDataSource.prototype._removeEntry = function(index, count)
{
    this.m_cache.splice(index, count);
};

/**
 * Clears the internal cache
 * @private
 */
oj.FlattenedTreeDataSource.prototype._clearAll = function()
{
    this.m_cache.length = 0;
};