Tree Structured OpenCoweb Application
A step-by-step walkthrough on making a collaborative application based on tree structured data. We also have an extended tutorial to make this application
persistent.
1. The Data
The underlying JSON structure of your data should follow the following schema. If you choose to visually model the data in some way, the widget you choose is totally up to you.
id: 'root',
name:'Phonebook',
children: [
{
id: '0',
name:'Person',
children: [
{
id: '1',
name:'firstname',
children:[
{
id: '2',
name:'Paul',
children:[]
}
]
}
]
}
]
2. The Setup
The first step to making a complex collaborative data structure is to initiate a Coweb session from within the JavaScript application. This needs to be done before any sync events are sent or received.
this.collab = coweb.initCollab({id:'phonebook'});
Next, we want to subscribe to the Coweb topic change.* and register a callback for it. We'll also register a callback for stateRequest and stateResponse. We'll discuss the purpose of each subscription in the Callbacks section below.
this.collab.subscribeSync('change.*', this, 'onRemoteChange');
this.collab.subscribeStateRequest(this, 'onStateRequest');
this.collab.subscribeStateResponse(this, 'onStateResponse');
3. The Sync Events
This demo supports moving, adding, deleting, and updating nodes. When one of these events occurs locally, we sync specific information to all members in the session using the Coweb
sendSync(topic, value, op, position) method. Any member in the session that has subscribed to that topic using the Coweb
subscribeSync(topic, callback) method will receive the sync, the registered callback will be triggered, and it will receive an object containing the original sendSync's topic, value, op, and position. For more information on the
sendSync method or
subscribeSync method, see the OCW sendSync
documentation or OCW subscribeSync
documentation, respectively.
Adding a node
When a node is added locally, we make a sendSync( ) call with the following parameters:
- op parameter is 'insert'
- topic parameter is 'change.' + parent node ID, e.g. 'change.23'
- value parameter is an object containing necessary information to build a new node. Even though the node has already been created locally, this information will be used in the callback for the sync event of all other members in the session to build an identical node. In addition, we set an attribute 'force' to true indicating we are adding a new node (as opposed to a move operation, described below).
- position parameter is the int position of the added node in its parent node's "children" array
this.collab.sendSync('change.'+parentNodeID, {nodeID: 7, value: 'Paul Bouchon'}, 'insert', 2);
Deleting a node
When a node is deleted locally, we make a sendSync( ) call with the following parameters:
- op parameter is 'delete'
- topic parameter is 'change.' + parent node ID, e.g. 'change.71'
- value parameter is an object containing necessary information to find the node in the data (e.g. just an ID). Even though the node has already been deleted locally, this information will be used in the callback for the sync event of all other members in the session to find and delete the proper node. In addition, we set an attribute 'force' to true indicating we have no intention of reusing the deleted node (as opposed to a move operation, described below).
- position parameter is the int position of the deleted node in its parent node's "children" array
this.collab.sendSync('change.'+parentNodeID, {nodeID: 14}, 'delete', 2);
Important!
When deleting nodes, you must make sure to only delete leaf nodes. Deleting a non-leaf node requires doing, for example, a post order traversal to delete all children first, before deleting the non-leaf node. Following this pattern helps the OpenCoweb API detect conflicts among other remote collaborative clients. See more about using OpenCoweb with
tree structured data..
Updating a node
When a node is updated locally, we make a sendSync( ) call with the following parameters:
- op parameter is 'update'
- topic parameter is 'change.' + parent node ID, e.g. 'change.13'
- value parameter is a the new node value. Even though the node has already been updated locally, this information will be used in the callback for the sync event of all other members in the session to find and update the proper node.
- position parameter is the int position of the updated node in its parent node's "children" array
this.collab.sendSync('change.'+nodeID, {value: 'Chris'}, 'update', 2);
Moving a node
When a node is moved locally, we actually have two operations occurring: a deletion of the node from its current parent node, and an insertion of the node on its new parent node.
The first op is a delete, so we make a sendSync( ) call with the following parameters:
- op parameter is 'delete'
- topic parameter is 'change.' + current parent node ID, e.g. 'change.81'
- value parameter is an object containing necessary information to find the node (e.g. a node ID). We should also include a flag in the value object, such as 'force' : false, indicating to all other members in the session to only remove the node from its current parent node's "children" array and keep the node around. This is so we don't have to deep copy the node in order to insert it on its new parent node's "children" array.
- position parameter is the int position of the deleted node in its current parent node's "children" array
The next op is an insert, so we make a sendSync( ) call with the following parameters:
- op parameter is 'insert'
- topic parameter is 'change.' + new parent node ID, e.g. 'change.115'
- value parameter is an object containing necessary information to find the node (e.g. a node ID). We should also include a flag in the value object, such as 'force' : false, indicating to all other members in the session to only add the node to its new parent node's "children" array. There's no need to build a new node since we didn't actually delete the node with the previous shallow delete.
- position parameter is the int position of the deleted node in its parent node's "children" array
this.collab.sendSync('change.'+currentParentNodeID, {nodeID: 7, 'shallow' : true}, 'delete', 3);
this.collab.sendSync('change.'+newParentNodeID, {nodeID: 7, 'shallow' : true}, 'insert', 1);
4. The Callbacks
In the Setup section above, you'll notice we subscribed to a single topic, change.*. Notice that wildcards are accepted in topic names, so in this case, any topic that begins with change. will trigger the callback registered with this topic, onRemoteChange. Because this method will be triggered for all incoming sync events, and we don't want to have a single massive method with logic handling inserts, deletes, moves, and updates, we will instead use this callback method to route the incoming syncs to specific methods handling each case. See the example below:
onRemoteChange: function(obj){
// Normal insert
if(obj.type == 'insert' && obj.value['force'])
this.onRemoteAddNode(obj);
// Normal delete
else if(obj.type == 'delete' && obj.value['force'])
this.onRemoteDeleteNode(obj);
// Normal update
else if(obj.type == 'update')
this.onRemoteUpdateNode(obj);
// Shallow insert (used when moving a node)
else if(obj.type == 'insert' && !obj.value['force'])
this.onRemoteMoveNode(obj);
// Shallow delete (used when moving a node)
else if(obj.type == 'delete' && !obj.value['force'])
this.onRemoteMoveNode(obj);
},
Adding a node
When a node is added remotely, the above
onRemoteChange function will route the incoming sync event to an
onRemoteAddNode function. In that function we do the following:
- Make a new node using the information that the callback receives
- Find the parent node and update its "children" array by adding this new item at the position specified in information the callback receives
onRemoteAddNode: function(obj) {
// Get parent item from synced parentId
var parentItem = this._getItemById(obj.value.parentId);
// If parent item found...
if(parentItem){
// add the new item to data store
var newItem = this.store.newItem({ id: obj.value.id, name:obj.value.value});
var parentId = obj.value.parentId;
// update parent node's children in store & save
var children = parentItem.children;
if(children == undefined)
children = [newItem];
else {
children = children.slice(0); // Must make a copy for this to work with dojo tree.
children.splice(obj.position, 0, newItem);
}
this.store.setValue(parentItem,'children',children);
this.store.save();
} else {
/* We can't honor the insert operation since the parent was already deleted. By assumption,
other clients will also delete this parent at some point, thereby negating their
insert of the desired node. Thus, we ignore this insert request. */
}
},
Deleting a node
When a node is deleted remotely, the above
onRemoteChange function will route the incoming sync event to an
onRemoteDeleteNode function. In that function we do the following:
- Find node in data using information that callback receives & delete it
- Update parent node's "children" array by deleting the item
onRemoteDeleteNode: function(obj){
var prevSelectedItemId = null;
// Remove node focus temporarily & save ref
if(this.tree.selectedItem) {
prevSelectedItemId = this.tree.selectedItem.id[0];
// Hide UI buttons temporarily
this.tree.attr('selectedItem','_null');
dojo.place('buttonContainer',document.body,'last');
dojo.style('buttonContainer','display','none');
}
// Get targeted item by synced id
var p = this._getItemById(obj.value.parentId);
if (!p) {
return;
}
var targetItem = p.children[obj.position];
// Delete targeted item from store & save
this.store.deleteItem(targetItem);
this.store.save();
// Re-focus prevSelectedItem if it exists
if (prevSelectedItemId)
this.tree.attr('selectedItem',prevSelectedItemId);
// Try to show buttons again
this._click();
},
Updating a node
When a node's value is updated remotely, the above
onRemoteChange function will route the incoming sync event to an
onRemoteUpdateNode function. In that function we do the following:
- Find the node in the data & update it's value using the information that the callback receives
onRemoteUpdateNode: function(obj){
// Get targeted item by synced id
var targetItem, p;
if (obj.value.parentId == this.superRootId) {
/* parentId is set to superRootId, some preset global, before calling onLocalUpdate.
This is ok because we don't allow the root to be deleted. */
targetItem = this._getItemById(obj.value.id);
} else {
p = this._getItemById(obj.value.parentId);
if (!p) {
/* We can't honor the update operation since the parent was already deleted. By assumption,
other clients will also delete this parent at some point, thereby negating their
update of the desired node. Thus, we ignore this update request. */
return;
}
targetItem = p.children[obj.position];
}
// Set new name, update store & save
var name = obj.value.name;
this.store.setValue(targetItem,'name',name);
this.store.save();
},
Moving a node
When a node is moved remotely, we actually have two operations occurring: a deletion of the node from its current parent node, and an insertion of the node on its new parent node. Thus, the above
onRemoteChange function will receive the first incoming sync event, which as specified in the Sync Event section above will have a 'shallow' : true flag set in the sync value object. It will then route this first sync to an
onRemoteMoveNode. In that function, we do the following:
- Update parent node's "children" array by deleting the node from the array, making sure to keep the node itself around for the coming insert.
The above
onRemoteChange function will then receive the second incoming event, which as specified in the Sync Event section above will have a 'shallow' : true flag set in the sync value object. It will then route this second sync to an
onRemoteMoveNode. In that function, we do the following:
- Simply add the node that we kept around during the previous delete operation to the new parent node's "children" array at the position specified in information the callback receives
onRemoteMoveNode: function(obj){
if(obj.type == 'delete'){
// Get parent item's children
var prevParent = this._getItemById(obj.value.prevParentID);
if (!prevParent) {
return;
}
var children = prevParent.children;
// Remove target item from children
children = children.slice(0);
children.splice(obj.position, 1);
// Update store & save
this.store.setValue(prevParent,'children',children);
this.store.save();
}else if(obj.type == 'insert'){
// Get parent item's children
var newItem = this._getItemById(obj.value.targetID);
var newParent = this._getItemById(obj.value.newParentID);
if (!newItem || !newParent) {
return;
}
var children = newParent.children;
// Add target item to children in proper pos
if(children == undefined)
children = [];
else {
children = children.slice(0);
children.splice(obj.position, 0, newItem);
}
// Update store & save
this.store.setValue(newParent,'children',children);
this.store.save();
}
},
5. Adding Persistence
See our extended tutorial for making this application
persistent.