From d8ad718f1d06cdfa8ecb2cd76af20d1dab9945e5 Mon Sep 17 00:00:00 2001 From: alekseybobkov Date: Thu, 20 Nov 2014 22:12:36 -0800 Subject: [PATCH] Added pagination, improved the navigation code --- modules/backend/widgets/table/README.md | 39 +++- .../table/assets/js/table.datasource.base.js | 37 ++-- .../assets/js/table.datasource.client.js | 25 +-- .../assets/js/table.helper.navigation.js | 203 ++++++++++++++++-- .../backend/widgets/table/assets/js/table.js | 63 ++++-- .../table/assets/js/table.processor.base.js | 1 + .../table/assets/js/table.processor.string.js | 47 ++-- 7 files changed, 322 insertions(+), 93 deletions(-) diff --git a/modules/backend/widgets/table/README.md b/modules/backend/widgets/table/README.md index 8d07e5e48..2d8acc1ba 100644 --- a/modules/backend/widgets/table/README.md +++ b/modules/backend/widgets/table/README.md @@ -1,5 +1,3 @@ -# Documentation drafr for the Table widget - # Client-side table widget (table.xxx.js) ## Code organization @@ -38,7 +36,11 @@ SubClass.prototype.someMethod = function() { ### Namespaces -All classes for the table widget are be defined in the **$.oc.table** namespace. Cell processors are be defined in the **$.oc.table.processor** namespace. The client and server memory data sources are defined in the **$.oc.table.datasource** namespace. +All classes for the table widget are be defined in the **$.oc.table** namespace. There are several namespaces in this namespace: + +- **$.oc.table.processor** - cell processors +- **$.oc.table.datasource** - data sources +- **$.oc.table.helper** - helper classes ### Client-side performance and memory usage considerations @@ -68,15 +70,32 @@ Any `DIV` elements that have the `data-control="table"` attributes are automatic ### Options -The options below are listed in the JavaScript notation. Corresponding data attribtues would look like `data-client-data-source-class`. +The options below are listed in the JavaScript notation. Corresponding data attributes would look like `data-client-data-source-class`. - `clientDataSourceСlass` (default is **client**)- specifies the client-side data source class. There are two data source classes supported on the client side - **client** and **server**. - `data` - specifies the data in JSON format for the **client**. -- `recordsPerPage` - specifies how many records per page to display. If the value is not defined or `false` or `null`, the pagination feature is disabled and all records are displayed. +- `recordsPerPage` - specifies how many records per page to display. If the value is not defined or `false` or `null`, the pagination feature is disabled and all records are displayed. Pagination and `rowSorting` cannot be used in the same time. - `columns` - column definitions in JSON format, see the server-side column definition format below. +- `rowSorting` - enables the drag & drop row sorting. The sorting cannot be used with the pagination (`recordsPerPage` is not `null` or `false`). + +## Client-side helper classes + +Some auxiliary code is factored out from the table class to helper classes. The helper classes are defined in the **$.oc.table.helper** namespace. + + - **table.helper.navigation.js** - implements the keyboard navigation within the table and pagination. ## Data sources ($.oc.table.datasource) +### Adding and removing records + +Adding and removing records is an asynchronous process that involves updating records in the dataset. + +When a user adds a record, the table object calls the `addRecord(data, offset, count, onSuccess)` method of the data source. The data source adds an empty record to the underlying data set and calls the `onSuccess` callback parameter passed to the method. In the `onSuccess` handler the table object rebuilds the table and focuses a field in the new row. + +When user deletes a record, the table object calls the `deleteRecord(index, offset, count, onSuccess)` method of the data source. The data source removes the record from the underlying dataset and calls the `onSuccess` callback parameter, passing records of the current page (determined with the `offset` and `count` parameters) to the callback. + +The `onSuccess` callback parameters are: data (records), count. + ### Client memory data source ($.oc.table.datasource.client) The client memory data sources keeps the data in the client memory. The data is loaded from the control element's `data` property (`data-data` attribute) and posted back with the form data. @@ -107,8 +126,8 @@ The table object calls the `onFocus()` method of the cell processors when a cell Columns are defined as array with the `columns` property. The array keys correspond the column identifiers. The array elements are associative arrays with the following keys: -- title -- type (string, checkbox, dropdown, autocomplete) -- width -- readonly -- options (for drop-down elements and autocomplete types) \ No newline at end of file +- `title` +- `type` (string, checkbox, dropdown, autocomplete) +- `width` +- `readonly` +- `options` (for drop-down elements and autocomplete types) \ No newline at end of file diff --git a/modules/backend/widgets/table/assets/js/table.datasource.base.js b/modules/backend/widgets/table/assets/js/table.datasource.base.js index c2f7757a9..8a1bb38fb 100644 --- a/modules/backend/widgets/table/assets/js/table.datasource.base.js +++ b/modules/backend/widgets/table/assets/js/table.datasource.base.js @@ -30,35 +30,48 @@ /* * Fetches records from the underlying data source and * passes them to the onSuccess callback function. + * The onSuccess callback parameters: records, totalCount. */ Base.prototype.getRecords = function(offset, count, onSuccess) { onSuccess([]) } /* - * Returns the total number of records in the underlying set + * Creates a record with the passed data and returns the updated page records + * to the onSuccess callback function. + * + * - recordData - the record fields + * - offset - the current page's first record index (zero-based) + * - count - number of records to return + * - onSuccess - a callback function to execute when the updated data gets available. + * + * The onSuccess callback parameters: records, totalCount. */ - Base.prototype.count = function() { - return 0 - } - - /* - * Creates a record with the passed data and returns the new record index. - */ - Base.prototype.createRecord = function(recordData) { - return 0 + Base.prototype.createRecord = function(recordData, offset, count, onSuccess) { + onSuccess([], 0) } /* * Updates a record with the specified index with the passed data + * + * - index - the record index in the dataset (primary key, etc) + * - recordData - the record fields. */ Base.prototype.updateRecord = function(index, recordData) { } /* - * Deletes a record with the specified index + * Deletes a record with the specified index. + * + * - index - the record index in the dataset (primary key, etc). + * - offset - the current page's first record index (zero-based) + * - count - number of records to return + * - onSuccess - a callback function to execute when the updated data gets available. + * + * The onSuccess callback parameters: records, totalCount. */ - Base.prototype.deleteRecord = function(index) { + Base.prototype.deleteRecord = function(index, offset, count, onSuccess) { + onSuccess([], 0) } $.oc.table.datasource.base = Base; diff --git a/modules/backend/widgets/table/assets/js/table.datasource.client.js b/modules/backend/widgets/table/assets/js/table.datasource.client.js index 571314be0..1ed58682a 100644 --- a/modules/backend/widgets/table/assets/js/table.datasource.client.js +++ b/modules/backend/widgets/table/assets/js/table.datasource.client.js @@ -37,33 +37,28 @@ this.data = null } - /* - * Fetches records from the underlying data source and - * passes them to the onSuccess callback function. - */ Client.prototype.getRecords = function(offset, count, onSuccess) { - onSuccess(this.data) - } - - /* - * Returns the total number of records in the underlying set - */ - Client.prototype.count = function() { - return this.data.length + if (!count) { + // Return all records + onSuccess(this.data, this.data.length) + } else { + // Return a subset of records + onSuccess(this.data.slice(offset, offset+count), this.data.length) + } } /* * Creates a record with the passed data and returns the new record index. */ - Client.prototype.createRecord = function(recordData) { - return 0; + Client.prototype.createRecord = function(recordData, offset, count, onSuccess) { + } /* * Updates a record with the specified index with the passed data */ Client.prototype.updateRecord = function(index, recordData) { -// console.log('Update recird', index, recordData) + } $.oc.table.datasource.client = Client diff --git a/modules/backend/widgets/table/assets/js/table.helper.navigation.js b/modules/backend/widgets/table/assets/js/table.helper.navigation.js index 36d22502a..6bf50cf07 100644 --- a/modules/backend/widgets/table/assets/js/table.helper.navigation.js +++ b/modules/backend/widgets/table/assets/js/table.helper.navigation.js @@ -20,10 +20,16 @@ var Navigation = function(tableObj) { // Reference to the table object this.tableObj = tableObj - + + // The current page index + this.pageIndex = 0 + // Event handlers this.keydownHandler = this.onKeydown.bind(this) + // Number of pages in the pagination + this.pageCount = 0 + this.init() }; @@ -48,6 +54,112 @@ this.keydownHandler = null } + // PAGINATION + // ============================ + + Navigation.prototype.paginationEnabled = function() { + return this.tableObj.options.recordsPerPage != null && + this.tableObj.options.recordsPerPage != false + } + + Navigation.prototype.getPageFirstRowOffset = function() { + return this.pageIndex * this.tableObj.options.recordsPerPage + } + + Navigation.prototype.buildPagination = function(recordCount) { + if (!this.paginationEnabled()) + return + + var paginationContainer = this.tableObj.el.querySelector('.pagination'), + newPaginationContainer = false, + curRecordCount = 0 + + this.pageCount = this.calculatePageCount(recordCount, this.tableObj.options.recordsPerPage) + + if (!paginationContainer) { + paginationContainer = document.createElement('div') + paginationContainer.setAttribute('class', 'pagination') + newPaginationContainer = true + } else + curRecordCount = paginationContainer.getAttribute('data-record-count') + + // Generate the new page list only if the record count has changed + if (newPaginationContainer || curRecordCount != recordCount) { + paginationContainer.setAttribute('data-record-count', recordCount) + + var pageList = this.buildPaginationLinkList(recordCount, + this.tableObj.options.recordsPerPage, + this.pageIndex) + + if (!newPaginationContainer) + paginationContainer.replaceChild(paginationContainer.children[0], pageList) + else { + paginationContainer.appendChild(pageList) + this.tableObj.el.appendChild(paginationContainer) + } + } else { + // Do not re-generate the pages if the record count hasn't changed, + // but mark the new active item in the pagination list + + this.markActiveLinkItem(paginationContainer, this.pageIndex) + } + } + + Navigation.prototype.calculatePageCount = function(recordCount, recordsPerPage) { + var pageCount = Math.ceil(recordCount/recordsPerPage) + + if (!pageCount) + pageCount = 1 + + return pageCount + } + + Navigation.prototype.buildPaginationLinkList = function(recordCount, recordsPerPage, pageIndex) { + // This method could be refactored and moved to a pagination + // helper if we want to support other pagination markup options. + + var pageCount = this.calculatePageCount(recordCount, recordsPerPage), + pageList = document.createElement('ul') + + for (var i=0; i < pageCount; i++) { + var item = document.createElement('li'), + link = document.createElement('a') + + if (i == pageIndex) + item.setAttribute('class', 'active') + + link.innerText = i+1 + link.setAttribute('data-page-index', i) + link.setAttribute('href', '#') + + item.appendChild(link) + pageList.appendChild(item) + } + + return pageList + } + + Navigation.prototype.markActiveLinkItem = function(paginationContainer, pageIndex) { + // This method could be refactored and moved to a pagination + // helper if we want to support other pagination markup options. + + var activeItem = paginationContainer.querySelector('.active'), + list = paginationContainer.children[0] + + activeItem.setAttribute('class', '') + + for (var i=0, len = list.children.length; i < len; i++) { + if (i == pageIndex) + list.children[i].setAttribute('class', 'active') + } + } + + Navigation.prototype.gotoPage = function(pageIndex, onSuccess) { + this.pageIndex = pageIndex + + this.tableObj.updateDataTable(onSuccess) + } + // KEYBOARD NAVIGATION // ============================ @@ -63,13 +175,27 @@ row.nextElementSibling : row.parentNode.children[row.parentNode.children.length - 1] - if (!newRow) - return - - var cell = newRow.children[this.tableObj.activeCell.cellIndex] + if (newRow) { + var cell = newRow.children[this.tableObj.activeCell.cellIndex] - if (cell) - this.tableObj.focusCell(cell) + if (cell) + this.tableObj.focusCell(cell) + } else { + // Try to switch to the previous page if that's possible + + if (!this.paginationEnabled()) + return + + if (this.pageIndex < this.pageCount-1) { + var cellIndex = this.tableObj.activeCell.cellIndex, + self = this + + this.gotoPage(this.pageIndex+1, function navUpPageSuccess(){ + self.focusCell('top', cellIndex) + self = null + }) + } + } } Navigation.prototype.navigateUp = function(ev) { @@ -84,13 +210,27 @@ row.previousElementSibling : row.parentNode.children[0] - if (!newRow) - return + if (newRow) { + var cell = newRow.children[this.tableObj.activeCell.cellIndex] - var cell = newRow.children[this.tableObj.activeCell.cellIndex] + if (cell) + this.tableObj.focusCell(cell) + } else { + // Try to switch to the previous page if that's possible - if (cell) - this.tableObj.focusCell(cell) + if (!this.paginationEnabled()) + return + + if (this.pageIndex > 0) { + var cellIndex = this.tableObj.activeCell.cellIndex, + self = this + + this.gotoPage(this.pageIndex-1, function navUpPageSuccess(){ + self.focusCell('bottom', cellIndex) + self = null + }) + } + } } Navigation.prototype.navigateLeft = function(ev) { @@ -159,6 +299,25 @@ this.tableObj.stopEvent(ev) } + Navigation.prototype.focusCell = function(rowReference, cellIndex) { + var row = null, + dataTable = this.tableObj.dataTable + + if (rowReference == 'bottom') { + row = dataTable.children[dataTable.children.length-1] + } + else if (rowReference == 'top') { + row = dataTable.children[0] + } + + if (!row) + return + + var cell = row.children[cellIndex] + if (cell) + this.tableObj.focusCell(cell) + } + // EVENT HANDLERS // ============================ @@ -177,5 +336,25 @@ return this.navigateNext(ev) } + Navigation.prototype.onClick = function(ev) { + // The navigation object uses the table's click handler + // and doesn't register own click handler. + + var target = this.tableObj.getEventTarget(ev, 'A') + + if (!target) + return + + var pageIndex = target.getAttribute('data-page-index') + + if (pageIndex === null) + return + + this.gotoPage(pageIndex) + this.tableObj.stopEvent(ev) + + return false + } + $.oc.table.helper.navigation = Navigation; }(window.jQuery); \ No newline at end of file diff --git a/modules/backend/widgets/table/assets/js/table.js b/modules/backend/widgets/table/assets/js/table.js index 5d85f77a9..978388f4c 100644 --- a/modules/backend/widgets/table/assets/js/table.js +++ b/modules/backend/widgets/table/assets/js/table.js @@ -35,11 +35,6 @@ // A reference to the currently active table cell this.activeCell = null - // The current first record index. - // This index is zero based and has nothing to do - // with the database identifiers or any underlying data. - this.offset = 0 - // The index of the row which is being edited at the moment. // This index corresponds the data source row index which // uniquely identifies the row in the data set. When the @@ -47,7 +42,7 @@ // the previously edited record to the data source. this.editedRowIndex = null - // A reference to the data table. + // A reference to the data table this.dataTable = null // Event handlers @@ -172,21 +167,19 @@ return headersTable } - Table.prototype.updateDataTable = function() { + Table.prototype.updateDataTable = function(onSuccess) { var self = this; - this.getRecords(function onSuccess(records){ - self.buildDataTable(records) + this.fetchRecords(function onSuccessClosure(records, totalCount){ + self.buildDataTable(records, totalCount) + + if (onSuccess) + onSuccess() }) } - Table.prototype.buildDataTable = function(records) { - // Completely remove the existing data table. By convention there should - // be no event handlers or references bound to it. - if (this.dataTable !== null) - this.dataTable.parentNode.removeChild(this.dataTable); - - this.dataTable = document.createElement('table') + Table.prototype.buildDataTable = function(records, totalCount) { + var dataTable = document.createElement('table') for (var i = 0, len = records.length; i < len; i++) { var row = document.createElement('tr') @@ -213,16 +206,24 @@ row.appendChild(cell) } - this.dataTable.appendChild(row) + dataTable.appendChild(row) } - // Build the data table - this.el.appendChild(this.dataTable) + // Inject the data table to the DOM or replace the existing table + if (this.dataTable !== null) + this.el.replaceChild(dataTable, this.dataTable) + else + this.el.appendChild(dataTable) + + this.dataTable = dataTable + + // Update the pagination links + this.navigation.buildPagination(totalCount) } - Table.prototype.getRecords = function(onSuccess) { - return this.dataSource.getRecords( - this.offset, + Table.prototype.fetchRecords = function(onSuccess) { + this.dataSource.getRecords( + this.navigation.getPageFirstRowOffset(), this.options.recordsPerPage, onSuccess) } @@ -262,6 +263,18 @@ editedRow.setAttribute('data-dirty', 0) } + /* + * Removes editor from the currently edited cell and commits the row if needed. + */ + Table.prototype.unfocusTable = function() { + if (this.activeCellProcessor) + this.activeCellProcessor.onUnfocus() + + this.commitEditedRow() + this.activeCellProcessor = null + this.activeCell = null + } + /* * Calls the onFocus() method for the cell processor responsible for the * newly focused cell. Commit the previous edited row to the data source @@ -302,6 +315,9 @@ // ============================ Table.prototype.onClick = function(ev) { + if (this.navigation.onClick(ev) === false) + return + var target = this.getEventTarget(ev, 'TD') if (!target) @@ -317,6 +333,9 @@ // ============================ Table.prototype.dispose = function() { + // Remove an editor and commit the data if needed + this.unfocusTable() + // Dispose the data source and clean up the reference this.dataSource.dispose() this.dataSource = null diff --git a/modules/backend/widgets/table/assets/js/table.processor.base.js b/modules/backend/widgets/table/assets/js/table.processor.base.js index 9556b6e40..fe1492a82 100644 --- a/modules/backend/widgets/table/assets/js/table.processor.base.js +++ b/modules/backend/widgets/table/assets/js/table.processor.base.js @@ -72,6 +72,7 @@ /* * Forces the processor to hide the editor when the user navigates * away from the cell. Processors can update the sell value in this method. + * Processors must clear the reference to the active cell in this method. */ Base.prototype.onUnfocus = function() { } diff --git a/modules/backend/widgets/table/assets/js/table.processor.string.js b/modules/backend/widgets/table/assets/js/table.processor.string.js index 566243838..357c871ce 100644 --- a/modules/backend/widgets/table/assets/js/table.processor.string.js +++ b/modules/backend/widgets/table/assets/js/table.processor.string.js @@ -61,6 +61,27 @@ this.buildEditor(cellElement) } + /* + * Forces the processor to hide the editor when the user navigates + * away from the cell. Processors can update the sell value in this method. + * Processors must clear the reference to the active cell in this method. + */ + StringProcessor.prototype.onUnfocus = function() { + if (!this.activeCell) + return + + var editor = this.activeCell.querySelector('.string-input') + if (editor) { + // Update the cell value and remove the editor + this.tableObj.setCellValue(this.activeCell, editor.value) + this.setViewContainerValue(this.activeCell, editor.value) + editor.parentNode.removeChild(editor) + } + + this.showViewContainer(this.activeCell) + this.activeCell = null + } + StringProcessor.prototype.buildEditor = function(cellElement) { // Hide the view container this.hideViewContainer(this.activeCell) @@ -79,26 +100,6 @@ window.setTimeout(this.focusTimeoutHandler, 0) } - /* - * Forces the processor to hide the editor when the user navigates - * away from the cell. - */ - StringProcessor.prototype.onUnfocus = function() { - if (!this.activeCell) - return - - var editor = this.activeCell.querySelector('.string-input') - if (editor) { - // Update the cell value and remove the editor - this.tableObj.setCellValue(this.activeCell, editor.value) - this.setViewContainerValue(this.activeCell, editor.value) - editor.parentNode.removeChild(editor) - } - - this.showViewContainer(this.activeCell) - this.activeCell = null - } - /* * Determines if the keyboard navigation in the specified direction is allowed * by the cell processor. Some processors could reject the navigation, for example @@ -157,7 +158,8 @@ if (document.selection) { var range = input.createTextRange() - setTimeout(function(){ + setTimeout(function() { + // Asynchronous layout update, better performance range.collapse(true) range.moveStart("character", position) range.moveEnd("character", 0) @@ -166,7 +168,8 @@ } if (input.selectionStart !== undefined) { - setTimeout(function(){ + setTimeout(function() { + // Asynchronous layout update input.selectionStart = position input.selectionEnd = position }, 0)