Reorderable ListGrid with persistent order
We have developed this SmartClient project to demonstrate a reorderable ListGrid with persistent order. The user can drag & drop items and their preferred order is saved to a relational database, allowing their preferred order to be restored the next time the application is loaded.
Initial project setup
Download the latest SmartClient SDK and extract it to a temporary directory. Inside, you will see a directory called smartclientRuntime
and smartclientSDK
. smartclientRuntime
is a basic starter J2EE project which we will build off of to develop this sample. Note that in a Mavenized Java EE project, smartclientRuntime
is copied to src/main/webapp
and then the files at src/main/webapp/classes
are moved to src/main/resources
.
- Open a command prompt and change directory into a general projects directory.
- Run the following command to create a basic Maven J2EE project:
This creates a directory called
mvn archetype:create -DarchetypeArtifactId=maven-archetype-webapp -DgroupId=com.smartclient.sample -DartifactId=persistent-reorderable-ListGrid
persistent-reorderable-ListGrid
in your current working directory.cd
into this directory. - Copy the files in
smartclientRuntime
tosrc/main/webapp
and then rename the directorysrc/main/webapp/WEB-INF/classes
tosrc/main/resources
. - Save http://smartgwt.googlecode.com/svn-history/r524/trunk/samples/showcase/src/com/smartgwt/sample/showcase/public/ds/employees.ds.xml to
src/main/webapp/shared/ds/employees.ds.xml
and http://smartgwt.googlecode.com/svn-history/r524/trunk/samples/showcase/src/com/smartgwt/sample/showcase/public/ds/test_data/employees.data.xml tosrc/main/webapp/shared/ds/test_data/employees.data.xml
. - Copy
smartclientSDK/WEB-INF/db/hsqldb
tosrc/main/webapp/WEB-INF/db/hsqldb
.
The basic starter project smartclientRuntime
does not include the isomorphic_sql JARs which we will need to be able to use an SQL database. Copy all JARs from smartclientSDK/WEB-INF/lib
to src/main/webapp/WEB-INF/lib
.
We will now configure SmartClient's embedded HyperSQL DB.
- Open
src/main/resources/server.properties
in a text editor. Before the "Other Settings" section (which is commented out by default), insert the following lines:# -------------- PICK DATABASE TO USE -------------- sql.defaultDatabase: HSQLDB # If you want to use Mysql instead, uncomment the following line # and comment all other sql.defaultDatabase definitions #sql.defaultDatabase: Mysql # If you want to use Oracle instead, uncomment the following line # and comment all other sql.defaultDatabase definitions #sql.defaultDatabase: Oracle # If you want to use Postgres instead, uncomment the following line # and comment all other sql.defaultDatabase definitions #sql.defaultDatabase: PostgreSQL # If you want to use DB2 instead, uncomment the following line # and comment all other sql.defaultDatabase definitions #sql.defaultDatabase: DB2 # -------------- SETTINGS FOR HSQLDB --------------- sql.HSQLDB.database.type: hsqldb sql.HSQLDB.interface.type: driverManager sql.HSQLDB.driver: org.hsqldb.jdbcDriver sql.HSQLDB.driver.url: jdbc:hsqldb:file:$webRoot/WEB-INF/db/hsqldb/isomorphic
- Save.
Finally, we will start the HSQLShutdown
servlet to make sure that redeploying the application does not corrupt the HyperSQL database. Open src/main/webapp/WEB-INF/web.xml
in a text editor and add the following within the <web-app>
element:
<!-- this servlet cleanly shuts down the HSQL database whenever the container is reloaded. If you want container reloading to work reliably with HSQL, this servlet must be enabled --> <servlet> <servlet-name>HSQLShutdown</servlet-name> <servlet-class>com.isomorphic.sql.HSQLShutdown</servlet-class> <load-on-startup>3</load-on-startup> </servlet>
Setting up the database
Starting with the `isomorphic` HyperSQL 1.8.0.10 database that comes with the SmartClient SDK, we need to add a column to store the user's preferred ordering of each employee. We can use HyperSQL's Swing Manager tool to alter the sample database's employeeTable
table.
- Download hsqldb_1_8_0_10.zip and unzip. Within the extracted contents, there is a Windows batch file at
hsqldb\demo\runManagerSwing.bat
. Windows users can double-click on this batch file to run the Swing Manager. Linux users will need to open a terminal,cd
intohsqldb/data
and run:java -classpath ./../lib/hsqldb.jar org.hsqldb.util.DatabaseManagerSwing
- Once the Swing Manager tool opens, you will see a dialog asking for connection information. For "Type:", select "HSQL Database Engine Standalone". In "URL:", replace
«database/path?»
with the path tosrc/main/webapp/WEB-INF/db/hsqldb/isomorphic
. For example, if yourpersistent-reorderable-ListGrid
project is atC:\Users\Me\projects\persistent-reorderable-ListGrid
, then the full connection URL isjdbc:hsqldb:
file:C:\Users\Me\projects\persistent-reorderable-ListGrid\src\main\webapp\WEB-INF\db\hsqldb\isomorphic
.
- Click "OK" to connect.
- In the text area on top, paste in the following SQL:
CREATE SEQUENCE employeeTable_userOrder_seq; ALTER TABLE employeeTable ADD COLUMN userOrder INTEGER DEFAULT 0 NOT NULL; UPDATE employeeTable SET userOrder = NEXT VALUE FOR employeeTable_userOrder_seq;
- Click the "Execute SQL" button. This will add a
userOrder
column toemployeeTable
, which the sample will use to store the preferred ordering of employees. - Quit the program via File → Exit.
We also need to update the `employees` data source to add a userOrder
field.
- Open
src/main/webapp/shared/ds/employees.ds.xml
in a text editor. - Before the definition of the "Name" field, add the following:
<field name="userOrder" title="userOrder" type="integer" canEdit="false" hidden="true"/>
- Also change the testFileName to
shared/ds/test_data/employees.data.xml
.
The contents of employees.ds.xml
should now be:
<DataSource ID="employees" serverType="sql" tableName="employeeTable" recordName="employee" testFileName="shared/ds/test_data/employees.data.xml" > <fields> <field name="userOrder" title="userOrder" type="integer" canEdit="false" hidden="true"/> <field name="Name" title="Name" type="text" length="128"/> <field name="EmployeeId" title="Employee ID" type="integer" primaryKey="true" required="true"/> <field name="ReportsTo" title="Manager" type="integer" required="true" foreignKey="employees.EmployeeId" rootValue="1"/> <field name="Job" title="Title" type="text" length="128"/> <field name="Email" title="Email" type="text" length="128"/> <field name="EmployeeType" title="Employee Type" type="text" length="40"/> <field name="EmployeeStatus" title="Status" type="text" length="40"/> <field name="Salary" title="Salary" type="float"/> <field name="OrgUnit" title="Org Unit" type="text" length="128"/> <field name="Gender" title="Gender" type="text" length="7"> <valueMap> <value>male</value> <value>female</value> </valueMap> </field> <field name="MaritalStatus" title="Marital Status" type="text" length="10"> <valueMap> <value>married</value> <value>single</value> </valueMap> </field> </fields> </DataSource>
Programming the application
We will start with the following HTML template, which should be saved to src/main/webapp/index.html
:
<!DOCTYPE html> <html> <head> <title>Persistent Reorderable ListGrid Sample</title> <script>var isomorphicDir = "isomorphic/";</script> <script src="isomorphic/system/modules/ISC_Core.js"></script> <script src="isomorphic/system/modules/ISC_Foundation.js"></script> <script src="isomorphic/system/modules/ISC_Containers.js"></script> <script src="isomorphic/system/modules/ISC_Grids.js"></script> <script src="isomorphic/system/modules/ISC_Forms.js"></script> <script src="isomorphic/system/modules/ISC_DataBinding.js"></script> </head> <body> <script src="isomorphic/skins/TreeFrog/load_skin.js"></script> <script src="isomorphic/DataSourceLoader?dataSource=employees"></script> <script src="app.js"></script> </body> </html>
What it does is load the SmartClient foundation modules as well as the `employees` DataSource.
When the browser requests isomorphic/DataSourceLoader?dataSource=employees
, the server will invoke the DataSourceLoader servlet, which parses the employees.ds.xml
and returns a block of JavaScript code to construct a corresponding isc.DataSource
object.
Also create a file at src/main/webapp/app.js
containing the following:
var listGrid = isc.ListGrid.create({ width:900, height:500, dataSource:window.employees, autoFetchData:true, canEdit:true, canReorderRecords:true, autoSaveEdits:true });
This creates a basic ListGrid
backed by the `employees` data source.
At this point, we can run mvn install
to build a WAR. Deploying this WAR to a servlet container such as Tomcat and visiting index.html
, you will see something similar to:
Try playing around with the order of records. You can drag and drop individual records or several by selecting multiple records while holding the Ctrl
key and dropping the selection. At this point, the order of records is lost upon refreshing the page, but we will fix this in the next section.
Handling the recordDrop
event
We would now like to add the ability to persist the user's preferred order; after the user drops a selection of records, the "userOrder" field should update automatically.
The event that is triggered when a user drops records is recordDrop
. The first parameter to this callback is an array of ListGridRecord
objects. The second parameter is the ListGridRecord
of the target row. The third parameter is the index of the target row. Finally, the fourth parameter is the ListGrid
widget.
When a user drops a selection of records, the app will need to update not only the userOrder
values of the dragged records, but all non-selected records between dragged records and the target record. In this sample, we will compute the range of indices of records that need to be updated as all records at indexMin
through indexMax
, inclusive:
var data = this.data; var dropRecordIndices = dropRecords.map(function (record) { return data.findIndex("EmployeeId", record.EmployeeId); }); var indicesMin = Math.min(dropRecordIndices.min(), targetIndex); var indicesMax = Math.max(dropRecordIndices.max(), targetIndex - 1);
To update records, we can use the updateData
method of DataSource
.
We would like to update many records at once in a transaction. To accomplish this, we can call RPCManager.startQueue
to start a queue, or batch, of update requests that are sent at once to the IDACall servlet for executing. IDACall will in turn translate the requests to a set of UPDATE
statements and execute them within a transaction.
To update a record's userOrder
field, we use the updateData
method of the list grid's data source as follows:
//var ds = isc.DS.get(this.dataSource); //var recordToUpdate, request, newUserOrder; var updates = ds.filterPrimaryKeyFields(recordToUpdate); updates.userOrder = newUserOrder; ds.updateData(updates, null, request);
The calls to updateData
are combined with the algorithm to correctly permute the userOrder
values of each record at indexMin
through indexMax
, inclusive, as follows:
// Update the 'userOrder' fields for all records at indices [indicesMin, indicesMax]. var userOrders = new Array(indicesMax + 1 - indicesMin); var i; for (i = indicesMin; i <= indicesMax; ++i) { var record = data.get(i); userOrders[i - indicesMin] = record.userOrder; } var numDropRecordsAfterOrAtTargetIndex = 0; for (i = indicesMax; i >= targetIndex; --i) { if (dropRecordIndices.contains(i)) { ++numDropRecordsAfterOrAtTargetIndex; } } var numDropRecordsBeforeTargetIndex = dropRecords.length - numDropRecordsAfterOrAtTargetIndex; var j = 0; // how many drop records have been encountered so far. for (i = indicesMin; i < targetIndex; ++i) { var record = data.get(i); request.oldValues.userOrder = record.userOrder; request._originalRecord = isc.shallowClone(record); var updates = ds.filterPrimaryKeyFields(record); if (dropRecordIndices.contains(i)) { updates.userOrder = record.userOrder = userOrders[targetIndex - numDropRecordsBeforeTargetIndex + j - indicesMin]; ++j; } else { updates.userOrder = record.userOrder = userOrders[i - j - indicesMin]; } ds.updateData(updates, null, request); } j = 0; for (i = indicesMax; i >= targetIndex; --i) { var record = data.get(i); request.oldValues.userOrder = record.userOrder; request._originalRecord = isc.shallowClone(record); var updates = ds.filterPrimaryKeyFields(record); if (dropRecordIndices.contains(i)) { updates.userOrder = record.userOrder = userOrders[targetIndex + numDropRecordsAfterOrAtTargetIndex - 1 - j - indicesMin]; ++j; } else { updates.userOrder = record.userOrder = userOrders[i + j - indicesMin]; } ds.updateData(updates, null, request); }
The final app.js
is:
var listGrid = isc.ListGrid.create({ width:900, height:500, dataSource:window.employees, autoFetchData:true, canEdit:true, canGroupBy:false, canReorderRecords:true, canSort:false, // Disable user sorting because we rely on records being sorted by 'userOrder'. autoSaveEdits:true, recordDrop : function (dropRecords, targetRecord, targetIndex, sourceWidget) { if (this == sourceWidget && dropRecords.length != 0) { var data = this.data; var dropRecordIndices = dropRecords.map(function (record) { return data.findIndex("EmployeeId", record.EmployeeId); }); var indicesMin = Math.min(dropRecordIndices.min(), targetIndex); var indicesMax = Math.max(dropRecordIndices.max(), targetIndex - 1); var startedQueue = !isc.RPCManager.startQueue(); var ds = isc.DS.get(this.dataSource); var request = { operation:this.updateOperation, application:this.application, willHandleError:true, oldValues:{ userOrder:0 }, componentId:this.ID }; // Update the 'userOrder' fields for all records at indices [indicesMin, indicesMax]. var userOrders = new Array(indicesMax + 1 - indicesMin); var i; for (i = indicesMin; i <= indicesMax; ++i) { var record = data.get(i); userOrders[i - indicesMin] = record.userOrder; } var numDropRecordsAfterOrAtTargetIndex = 0; for (i = indicesMax; i >= targetIndex; --i) { if (dropRecordIndices.contains(i)) { ++numDropRecordsAfterOrAtTargetIndex; } } var numDropRecordsBeforeTargetIndex = dropRecords.length - numDropRecordsAfterOrAtTargetIndex; var j = 0; // how many drop records have been encountered so far. for (i = indicesMin; i < targetIndex; ++i) { var record = data.get(i); request.oldValues.userOrder = record.userOrder; request._originalRecord = isc.shallowClone(record); var updates = ds.filterPrimaryKeyFields(record); if (dropRecordIndices.contains(i)) { updates.userOrder = record.userOrder = userOrders[targetIndex - numDropRecordsBeforeTargetIndex + j - indicesMin]; ++j; } else { updates.userOrder = record.userOrder = userOrders[i - j - indicesMin]; } ds.updateData(updates, null, request); } j = 0; for (i = indicesMax; i >= targetIndex; --i) { var record = data.get(i); request.oldValues.userOrder = record.userOrder; request._originalRecord = isc.shallowClone(record); var updates = ds.filterPrimaryKeyFields(record); if (dropRecordIndices.contains(i)) { updates.userOrder = record.userOrder = userOrders[targetIndex + numDropRecordsAfterOrAtTargetIndex - 1 - j - indicesMin]; ++j; } else { updates.userOrder = record.userOrder = userOrders[i + j - indicesMin]; } ds.updateData(updates, null, request); } // If we're queuing, send the queue now. if (startedQueue) { isc.RPCManager.sendQueue(null, null, null, true); } } // Call the super implementation of recordDrop() to update the order of rows in the ListGrid. this.Super("recordDrop", arguments); } }); listGrid.addSort({ property:"userOrder" });
You can download the entire source code of this project (excluding the libraries in src/main/webapp/WEB-INF/lib
) here.