Added pagination, improved the navigation code

This commit is contained in:
alekseybobkov 2014-11-20 22:12:36 -08:00
parent 2bd40037d1
commit d8ad718f1d
7 changed files with 322 additions and 93 deletions

View File

@ -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)
- `title`
- `type` (string, checkbox, dropdown, autocomplete)
- `width`
- `readonly`
- `options` (for drop-down elements and autocomplete types)

View File

@ -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;

View File

@ -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

View File

@ -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);

View File

@ -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

View File

@ -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() {
}

View File

@ -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)