UNPKG

@qooxdoo/framework

Version:

The JS Framework for Coders

913 lines (762 loc) 25.8 kB
/* ************************************************************************ qooxdoo - the new era of web development http://qooxdoo.org Copyright: 2006 STZ-IDA, Germany, http://www.stz-ida.de License: MIT: https://opensource.org/licenses/MIT See the LICENSE file in the project's top-level directory for details. Authors: * Til Schneider (til132) ************************************************************************ */ /** * A table model that loads its data from a backend. * <p> * Only a subset of the available rows, those which are within or near the * currently visible area, are loaded. If a quick scroll operation occurs, * rows will soon be displayed using asynchronous loading in the background. * All loaded data is managed through a cache which automatically removes * the oldest used rows when it gets full. * <p> * This class is abstract: The actual loading of row data must be done by * subclasses. */ qx.Class.define("qx.ui.table.model.Remote", { type : "abstract", extend : qx.ui.table.model.Abstract, /* ***************************************************************************** CONSTRUCTOR ***************************************************************************** */ construct : function() { this.base(arguments); this._sortColumnIndex = -1; this._sortAscending = true; this._rowCount = -1; this._lruCounter = 0; // Holds the index of the first block that is currently loading. // Is -1 if there is currently no request on its way. this._firstLoadingBlock = -1; // Holds the index of the first row that should be loaded when the response of // the current request arrives. Is -1 we need no following request. this._firstRowToLoad = -1; // Counterpart to _firstRowToLoad this._lastRowToLoad = -1; // Holds whether the current request will bring obsolete data. When true the // response of the current request will be ignored. this._ignoreCurrentRequest = false; this._rowBlockCache = {}; this._rowBlockCount = 0; this._sortableColArr = null; this._editableColArr = null; }, /* ***************************************************************************** PROPERTIES ***************************************************************************** */ properties : { /** The number of rows that are stored in one cache block. */ blockSize : { check : "Integer", init : 50 }, /** The maximum number of row blocks kept in the cache. */ maxCachedBlockCount : { check : "Integer", init : 15 }, /** * Whether to clear the cache when some rows are removed. * If true the rows are removed locally in the cache. */ clearCacheOnRemove : { check : "Boolean", init : false }, /** * Whether to block remote requests for the row count while a request for * the row count is pending. Row counts are requested at various times and * from various parts of the code, resulting in numerous requests to the * user-provided _loadRowCount() method, often while other requests are * already pending. The default behavior now ignores requests to load a * new row count if such a request is already pending. It is therefore now * conceivable that the row count changes between an initial request for * the row count and a later (ignored) request. Since the chance of this * is low, the desirability of reducing the server requests outweighs the * slight possibility of an altered count (which will, by the way, be * detected soon thereafter upon the next request for the row count). If * the old behavior is desired, set this property to false. */ blockConcurrentLoadRowCount: { check : "Boolean", init : true } }, /* ***************************************************************************** MEMBERS ***************************************************************************** */ members : { _rowCount : null, _ignoreCurrentRequest : null, _lruCounter : null, _firstLoadingBlock : null, _firstRowToLoad : null, _lastRowToLoad : null, _rowBlockCache : null, _rowBlockCount : null, _sortColumnIndex : null, _sortAscending : null, _editableColArr : null, _sortableColArr : null, _loadRowCountRequestRunning : false, _clearCache : false, /** * Returns whether the current request is ignored by the model. * * @return {Boolean} true when the current request is ignored by the model. */ _getIgnoreCurrentRequest : function() { return this._ignoreCurrentRequest; }, // overridden getRowCount : function() { if (this._rowCount == -1) { if (! this._loadRowCountRequestRunning || ! this.getBlockConcurrentLoadRowCount()) { this._loadRowCountRequestRunning = true; this._loadRowCount(); } // NOTE: _loadRowCount may set this._rowCount return (this._rowCount == -1) ? 0 : this._rowCount; } else { return this._rowCount; } }, /** * Implementing classes have to call {@link #_onRowCountLoaded} when the * server response arrived. That method has to be called! Even when there * was an error. * * @abstract * @throws {Error} the abstract function warning. */ _loadRowCount : function() { throw new Error("_loadRowCount is abstract"); }, /** * Sets the row count. * * Has to be called by {@link #_loadRowCount}. * * @param rowCount {Integer} the number of rows in this model or null if loading. */ _onRowCountLoaded : function(rowCount) { if (this.getBlockConcurrentLoadRowCount()) { // There's no longer a loadRowCount() in progress this._loadRowCountRequestRunning = false; } // this.debug("row count loaded: " + rowCount); if (rowCount == null || rowCount < 0) { rowCount = 0; } this._rowCount = Number(rowCount); // Inform the listeners var data = { firstRow : 0, lastRow : rowCount - 1, firstColumn : 0, lastColumn : this.getColumnCount() - 1 }; this.fireDataEvent("dataChanged", data); }, /** * Reloads the model and clears the local cache. * */ reloadData : function() { // If there is currently a request on its way, then this request will bring // obsolete data -> Ignore it if (this._firstLoadingBlock != -1) { var cancelingSucceed = this._cancelCurrentRequest(); if (cancelingSucceed) { // The request was canceled -> We're not loading any blocks any more this._firstLoadingBlock = -1; this._ignoreCurrentRequest = false; } else { // The request was not canceled -> Ignore it this._ignoreCurrentRequest = true; } } // Force clearing row cache, because of reloading data. this._clearCache = true; // Forget a possibly outstanding request // (_loadRowCount will tell the listeners anyway, that the whole table // changed) // // NOTE: This will inform the listeners as soon as the new row count is // known this._firstRowToLoad = -1; this._lastRowToLoad = -1; this._loadRowCountRequestRunning = true; this._loadRowCount(); }, /** * Clears the cache. * */ clearCache : function() { this._rowBlockCache = {}; this._rowBlockCount = 0; }, /** * Returns the current state of the cache. * <p> * Do not change anything in the returned data. This breaks the model state. * Use this method only together with {@link #restoreCacheContent} for backing * up state for a later restore. * * @return {Map} the current cache state. */ getCacheContent : function() { return { sortColumnIndex : this._sortColumnIndex, sortAscending : this._sortAscending, rowCount : this._rowCount, lruCounter : this._lruCounter, rowBlockCache : this._rowBlockCache, rowBlockCount : this._rowBlockCount }; }, /** * Restores a cache state created by {@link #getCacheContent}. * * @param cacheContent {Map} An old cache state. */ restoreCacheContent : function(cacheContent) { // If there is currently a request on its way, then this request will bring // obsolete data -> Ignore it if (this._firstLoadingBlock != -1) { // Try to cancel the current request var cancelingSucceed = this._cancelCurrentRequest(); if (cancelingSucceed) { // The request was canceled -> We're not loading any blocks any more this._firstLoadingBlock = -1; this._ignoreCurrentRequest = false; } else { // The request was not canceled -> Ignore it this._ignoreCurrentRequest = true; } } // Restore the cache content this._sortColumnIndex = cacheContent.sortColumnIndex; this._sortAscending = cacheContent.sortAscending; this._rowCount = cacheContent.rowCount; this._lruCounter = cacheContent.lruCounter; this._rowBlockCache = cacheContent.rowBlockCache; this._rowBlockCount = cacheContent.rowBlockCount; // Inform the listeners var data = { firstRow : 0, lastRow : this._rowCount - 1, firstColumn : 0, lastColumn : this.getColumnCount() - 1 }; this.fireDataEvent("dataChanged", data); }, /** * Cancels the current request if possible. * * Should be overridden by subclasses if they are able to cancel requests. This * allows sending a new request directly after a call of {@link #reloadData}. * * @return {Boolean} whether the request was canceled. */ _cancelCurrentRequest : function() { return false; }, /** * Iterates through all cached rows. * * The iterator will be called for each cached row with two parameters: The row * index of the current row (Integer) and the row data of that row (var[]). If * the iterator returns something this will be used as new row data. * * The iterator is called in the same order as the rows are in the model * (the row index is always ascending). * * @param iterator {Function} The iterator function to call. * @param object {Object} context of the iterator */ iterateCachedRows : function(iterator, object) { var blockSize = this.getBlockSize(); var blockCount = Math.ceil(this.getRowCount() / blockSize); // Remove the row and move the rows of all following blocks for (var block=0; block<=blockCount; block++) { var blockData = this._rowBlockCache[block]; if (blockData != null) { var rowOffset = block * blockSize; var rowDataArr = blockData.rowDataArr; for (var relRow=0; relRow<rowDataArr.length; relRow++) { // Call the iterator for this row var rowData = rowDataArr[relRow]; var newRowData = iterator.call(object, rowOffset + relRow, rowData); if (newRowData != null) { rowDataArr[relRow] = newRowData; } } } } }, // overridden prefetchRows : function(firstRowIndex, lastRowIndex) { // this.debug("Prefetch wanted: " + firstRowIndex + ".." + lastRowIndex); if (this._firstLoadingBlock == -1) { var blockSize = this.getBlockSize(); var totalBlockCount = Math.ceil(this._rowCount / blockSize); // There is currently no request running -> Start a new one // NOTE: We load one more block above and below to have a smooth // scrolling into the next block without blank cells var firstBlock = parseInt(firstRowIndex / blockSize, 10) - 1; if (firstBlock < 0) { firstBlock = 0; } var lastBlock = parseInt(lastRowIndex / blockSize, 10) + 1; if (lastBlock >= totalBlockCount) { lastBlock = totalBlockCount - 1; } // Check which blocks we have to load var firstBlockToLoad = -1; var lastBlockToLoad = -1; for (var block=firstBlock; block<=lastBlock; block++) { if ((this._clearCache && !this._loadRowCountRequestRunning)|| this._rowBlockCache[block] == null || this._rowBlockCache[block].isDirty) { // We don't have this block if (firstBlockToLoad == -1) { firstBlockToLoad = block; } lastBlockToLoad = block; } } // Load the blocks if (firstBlockToLoad != -1) { this._firstRowToLoad = -1; this._lastRowToLoad = -1; this._firstLoadingBlock = firstBlockToLoad; // this.debug("Starting server request. rows: " + firstRowIndex + ".." + lastRowIndex + ", blocks: " + firstBlockToLoad + ".." + lastBlockToLoad); this._loadRowData(firstBlockToLoad * blockSize, (lastBlockToLoad + 1) * blockSize - 1); } } else { // There is already a request running -> Remember this request // so it can be executed after the current one is finished. this._firstRowToLoad = firstRowIndex; this._lastRowToLoad = lastRowIndex; } }, /** * Loads some row data from the server. * * Implementing classes have to call {@link #_onRowDataLoaded} when the server * response arrived. That method has to be called! Even when there was an error. * * @abstract * @param firstRow {Integer} The index of the first row to load. * @param lastRow {Integer} The index of the last row to load. * @throws {Error} the abstract function warning. */ _loadRowData : function(firstRow, lastRow) { throw new Error("_loadRowData is abstract"); }, /** * Sets row data. * * Has to be called by {@link #_loadRowData}. * * @param rowDataArr {Map[]} the loaded row data or null if there was an error. */ _onRowDataLoaded : function(rowDataArr) { // Clear cache if function was called because of a reload. if (this._clearCache) { this.clearCache(); this._clearCache = false; } if (rowDataArr != null && !this._ignoreCurrentRequest) { var blockSize = this.getBlockSize(); var blockCount = Math.ceil(rowDataArr.length / blockSize); if (blockCount == 1) { // We got one block -> Use the rowData directly this._setRowBlockData(this._firstLoadingBlock, rowDataArr); } else { // We got more than one block -> We've to split the rowData for (var i=0; i<blockCount; i++) { var rowOffset = i * blockSize; var blockRowData = []; var mailCount = Math.min(blockSize, rowDataArr.length - rowOffset); for (var row=0; row<mailCount; row++) { blockRowData.push(rowDataArr[rowOffset + row]); } this._setRowBlockData(this._firstLoadingBlock + i, blockRowData); } } // this.debug("Got server answer. blocks: " + this._firstLoadingBlock + ".." + (this._firstLoadingBlock + blockCount - 1) + ". mail count: " + rowDataArr.length + " block count:" + blockCount); // Inform the listeners var data = { firstRow : this._firstLoadingBlock * blockSize, lastRow : (this._firstLoadingBlock + blockCount + 1) * blockSize - 1, firstColumn : 0, lastColumn : this.getColumnCount() - 1 }; this.fireDataEvent("dataChanged", data); } // We're not loading any blocks any more this._firstLoadingBlock = -1; this._ignoreCurrentRequest = false; // Check whether we have to start a new request if (this._firstRowToLoad != -1) { this.prefetchRows(this._firstRowToLoad, this._lastRowToLoad); } }, /** * Sets the data of one block. * * @param block {Integer} the index of the block. * @param rowDataArr {var[][]} the data to set. */ _setRowBlockData : function(block, rowDataArr) { if (this._rowBlockCache[block] == null) { // This is a new block -> Check whether we have to remove another block first this._rowBlockCount++; while (this._rowBlockCount > this.getMaxCachedBlockCount()) { // Find the last recently used block // NOTE: We never remove block 0 and 1 var lruBlock; var minLru = this._lruCounter; for (var currBlock in this._rowBlockCache) { var currLru = this._rowBlockCache[currBlock].lru; if (currLru < minLru && currBlock > 1) { minLru = currLru; lruBlock = currBlock; } } // Remove that block // this.debug("Removing block: " + lruBlock + ". current LRU: " + this._lruCounter); delete this._rowBlockCache[lruBlock]; this._rowBlockCount--; } } this._rowBlockCache[block] = { lru : ++this._lruCounter, rowDataArr : rowDataArr }; }, /** * Removes a row from the model. * * @param rowIndex {Integer} the index of the row to remove. */ removeRow : function(rowIndex) { if (this.getClearCacheOnRemove()) { this.clearCache(); // Inform the listeners var data = { firstRow : 0, lastRow : this.getRowCount() - 1, firstColumn : 0, lastColumn : this.getColumnCount() - 1 }; this.fireDataEvent("dataChanged", data); } else { var blockSize = this.getBlockSize(); var blockCount = Math.ceil(this.getRowCount() / blockSize); var startBlock = parseInt(rowIndex / blockSize, 10); // Remove the row and move the rows of all following blocks for (var block=startBlock; block<=blockCount; block++) { var blockData = this._rowBlockCache[block]; if (blockData != null) { // Remove the row in the start block // NOTE: In the other blocks the first row is removed // (This is the row that was) var removeIndex = 0; if (block == startBlock) { removeIndex = rowIndex - block * blockSize; } blockData.rowDataArr.splice(removeIndex, 1); if (block == blockCount - 1) { // This is the last block if (blockData.rowDataArr.length == 0) { // It is empty now -> Remove it delete this._rowBlockCache[block]; } } else { // Try to copy the first row of the next block to the end of this block // so this block can stays clean var nextBlockData = this._rowBlockCache[block + 1]; if (nextBlockData != null) { blockData.rowDataArr.push(nextBlockData.rowDataArr[0]); } else { // There is no row to move -> Mark this block as dirty blockData.isDirty = true; } } } } if (this._rowCount != -1) { this._rowCount--; } // Inform the listeners if (this.hasListener("dataChanged")) { var data = { firstRow : rowIndex, lastRow : this.getRowCount() - 1, firstColumn : 0, lastColumn : this.getColumnCount() - 1 }; this.fireDataEvent("dataChanged", data); } } }, /** * * See overridden method for details. * * @param rowIndex {Integer} the model index of the row. * @return {Object} Map containing a value for each column. */ getRowData : function(rowIndex) { var blockSize = this.getBlockSize(); var block = parseInt(rowIndex / blockSize, 10); var blockData = this._rowBlockCache[block]; if (blockData == null) { // This block is not (yet) loaded return null; } else { var rowData = blockData.rowDataArr[rowIndex - (block * blockSize)]; // Update the last recently used counter if (blockData.lru != this._lruCounter) { blockData.lru = ++this._lruCounter; } return rowData; } }, // overridden getValue : function(columnIndex, rowIndex) { var rowData = this.getRowData(rowIndex); if (rowData == null) { return null; } else { var columnId = this.getColumnId(columnIndex); return rowData[columnId]; } }, // overridden setValue : function(columnIndex, rowIndex, value) { var rowData = this.getRowData(rowIndex); if (rowData == null) { // row has not yet been loaded or does not exist return; } else { var columnId = this.getColumnId(columnIndex); rowData[columnId] = value; // Inform the listeners if (this.hasListener("dataChanged")) { var data = { firstRow : rowIndex, lastRow : rowIndex, firstColumn : columnIndex, lastColumn : columnIndex }; this.fireDataEvent("dataChanged", data); } } }, /** * Sets all columns editable or not editable. * * @param editable {Boolean} whether all columns are editable. */ setEditable : function(editable) { this._editableColArr = []; for (var col=0; col<this.getColumnCount(); col++) { this._editableColArr[col] = editable; } this.fireEvent("metaDataChanged"); }, /** * Sets whether a column is editable. * * @param columnIndex {Integer} the column of which to set the editable state. * @param editable {Boolean} whether the column should be editable. */ setColumnEditable : function(columnIndex, editable) { if (editable != this.isColumnEditable(columnIndex)) { if (this._editableColArr == null) { this._editableColArr = []; } this._editableColArr[columnIndex] = editable; this.fireEvent("metaDataChanged"); } }, // overridden isColumnEditable : function(columnIndex) { return (this._editableColArr ? (this._editableColArr[columnIndex] == true) : false); }, /** * Sets whether a column is sortable. * * @param columnIndex {Integer} the column of which to set the sortable state. * @param sortable {Boolean} whether the column should be sortable. */ setColumnSortable : function(columnIndex, sortable) { if (sortable != this.isColumnSortable(columnIndex)) { if (this._sortableColArr == null) { this._sortableColArr = []; } this._sortableColArr[columnIndex] = sortable; this.fireEvent("metaDataChanged"); } }, // overridden isColumnSortable : function(columnIndex) { return ( this._sortableColArr ? (this._sortableColArr[columnIndex] !== false) : true ); }, // overridden sortByColumn : function(columnIndex, ascending) { if (this._sortColumnIndex != columnIndex || this._sortAscending != ascending) { this._sortColumnIndex = columnIndex; this._sortAscending = ascending; this.clearCache(); // Inform the listeners this.fireEvent("metaDataChanged"); } }, // overridden getSortColumnIndex : function() { return this._sortColumnIndex; }, // overridden isSortAscending : function() { return this._sortAscending; }, /** * Sets the sorted column without sorting the data. * Use this method, if you want to mark the column as the sorted column, * (e.g. for appearance reason), but the sorting of the data will be done * in another step. * * @param sortColumnIndex {Integer} the column, which shall be marked as the sorted column. */ setSortColumnIndexWithoutSortingData : function(sortColumnIndex) { this._sortColumnIndex = sortColumnIndex; }, /** * Sets the direction of the sorting without sorting the data. * Use this method, if you want to set the direction of sorting, (e.g * for appearance reason), but the sorting of the data will be done in * another step. * * @param sortAscending {Boolean} whether the sorting direction is ascending * (true) or not (false). */ setSortAscendingWithoutSortingData : function (sortAscending) { this._sortAscending = sortAscending; } }, destruct : function() { this._sortableColArr = this._editableColArr = this._rowBlockCache = null; } });