TransferBox: A Tale of Two Lists
In this tutorial, we will show you how to use two instances of one of dgrid's lightweight base classes to create a transfer box widget that will handle thousands of options.
Getting started
We're going to start out with some boilerplate and we'll modify it as we go along. First, we're going to
create some fake data, instantiate an observable store, and instantiate our TransferBox
widget.
We'll do all of this in the main page:
var transferbox; require([ 'my/TransferBox', 'dstore/Memory', 'dstore/Trackable', 'dojo/_base/declare', 'dojo/domReady!' ], function (TransferBox, Memory, Trackable, declare) { var data = []; for (var i = 0; i < 100; i++) { data[i] = { id: i + 1, name: '' + (i + 1), value: i + 1 }; } var store = new (declare([ Memory, Trackable ]))({ idProperty: 'id', data: data }); var transferbox = new TransferBox({ store: store }, 'transferbox'); transferbox.startup(); });
Our widget will use a template for its layout. In the template, we'll put two nodes where we will attach our lists; in between those nodes, we'll place a node with two initially-disabled buttons which will be used to swap items between the two lists:
<div> <div data-dojo-attach-point="fromNode"></div> <div class="buttons"> <button type="button" disabled data-dojo-type="dijit/form/Button" data-dojo-attach-event="onClick: addSelectedItems" data-dojo-attach-point="addButton">></button> <button type="button" disabled data-dojo-type="dijit/form/Button" data-dojo-attach-event="onClick: removeSelectedItems" data-dojo-attach-point="removeButton"><</button> </div> <div data-dojo-attach-point="toNode"></div> </div>
Some simple styling will align the lists and buttons how we would like them:
.myTransferBox .dgrid, .myTransferBox .buttons { display: inline-block; vertical-align: middle; } .myTransferBox .dgrid { width: 200px; height: 200px; } .myTransferBox .buttons { padding: 2px; } .myTransferBox .dijitButton { display: block; }
Finally, some simple templated widget boilerplate:
define([ 'dojo/_base/declare', 'dijit/_WidgetBase', 'dijit/_TemplatedMixin', 'dijit/_WidgetsInTemplateMixin', 'dojo/text!my/TransferBox.html', 'dijit/form/Button' ], function (declare, _WidgetBase, _TemplatedMixin, _WidgetsInTemplateMixin, template) { return declare([ _WidgetBase, _TemplatedMixin, _WidgetsInTemplateMixin ], { templateString: template, baseClass: 'myTransferBox', addSelectedItems: function () {}, removeSelectedItems: function () {} }); });View Demo
The demo does not yet do anything - there are just two transfer buttons.
Adding the lists
The first thing we need to do is instantiate our lists. We're going to use
dgrid/OnDemandList
with the dgrid/Selection
and
dgrid/Keyboard
modules mixed in to add selection and keyboard
navigation:
define([ 'dojo/_base/declare', 'dijit/_WidgetBase', 'dijit/_TemplatedMixin', 'dijit/_WidgetsInTemplateMixin', 'dgrid/OnDemandList', 'dgrid/Selection', 'dgrid/Keyboard', 'dojo/dom-construct', 'dojo/text!my/TransferBox.html', 'dijit/form/Button' ], function (declare, _WidgetBase, _TemplatedMixin, _WidgetsInTemplateMixin, List, Selection, Keyboard, domConstruct, template) { var TBList = declare([ List, Selection, Keyboard ]); return declare([ _WidgetBase, _TemplatedMixin, _WidgetsInTemplateMixin ], { templateString: template, baseClass: 'myTransferBox', buildRendering: function () { var self = this; this.inherited(arguments); this.from = new TBList({ collection: this.store, renderRow: this.renderItem }, this.fromNode); this.to = new TBList({ collection: this.store, renderRow: this.renderItem }, this.toNode); this.own(this.from, this.to); }, startup: function () { if (this._started) { return; } this.inherited(arguments); this.from.startup(); this.to.startup(); }, addSelectedItems: function () {}, removeSelectedItems: function () {}, renderItem: function (item) { return domConstruct.create('div', { innerHTML: item.name }); } }); });
Since dgrid/OnDemandList
has no concept of columns, it is only concerned
with rendering items to rows; anything inheriting from dgrid/List
(including
dgrid/OnDemandList
) uses the renderRow
function to render data items to DOM nodes. Our
renderItem
method takes care of that for us.
Enabling buttons with selection
The next step to create this widget will be to associate the disabled status of each button with the
selection of its respective list. The dgrid-select
and dgrid-deselect
events will be
our focus for this step.
function handleSelection(list) { var button = list.transferButton; list.on('dgrid-select', function () { button.set('disabled', false); }); list.on('dgrid-deselect', function () { button.set('disabled', !hasSelection(list)); }); } function hasSelection(list) { for (var key in list.selection) { return true; } } // ... then, in buildRendering: this.from = new TBList({ collection: this.store, renderRow: this.renderItem, transferButton: this.addButton }, this.fromNode); handleSelection(this.from); this.to = new TBList({ collection: this.store, renderRow: this.renderItem, transferButton: this.removeButton }, this.toNode); handleSelection(this.to);
Rather than duplicate code, handleSelection
sets up our event handlers for us and keeps track
of the number of items selected. We then associate each list with its corresponding button.
Limiting results
One thing you may have noticed is that both lists contain all items from the store. Obviously this is not
the desired behavior. If you remember from the page this will be instantiated on, we're using an
Trackable
store; this means result sets are observable and will be notified of changes to items.
Because of this fact, we are going to use an internal property (__selected
) on items to signify if
they are in the right hand list or not. Using dstore's filter
method we can assign each list
an appropriately-filtered collection:
this.from = new TBList({ collection: this.store.filter(function (item) { return !item.__selected; }), renderRow: this.renderItem, transferButton: this.addButton }, this.fromNode); handleSelection(this.from); this.to = new TBList({ collection: this.store.filter(function (item) { return item.__selected; }), renderRow: this.renderItem transferButton: this.removeButton }, this.toNode); handleSelection(this.to);
The end result here is that items that are not "selected" will display in the left hand list, while items that are "selected" end up in the right hand list.
View DemoAdding and removing items
Now that items are displaying in the correct lists, we need to write the code to add and remove items from
the right hand list. The functions are already hooked up to the buttons from the template, but the code needs
to be added. There are two things we are going to have to do in order to accomplish this: retrieve the
items that have been selected in the list and set the __selected
property to either true
or false
.
Given that this process is exactly the same in each direction, just reversed, we're going to create a single function that can be used to create both methods:
function createSwapFunction(isAdded) { var source = isAdded ? 'from' : 'to', destination = isAdded ? 'to' : 'from'; return function () { var store = this.store; var sourceList = this[source]; var destinationList = this[destination]; for (var id in sourceList.selection) { sourceList.deselect(id); store.get(id).then(function (item) { item.__selected = isAdded; return store.put(item); }).then(function () { destinationList.select(id); }); } // dgrid selection events do not occur for rows that are not loaded, // so update the transfer buttons here. sourceList.transferButton.set('disabled', true); destinationList.transferButton.set('disabled', false); }; } // ... then, in the prototype: addSelectedItems: createSwapFunction(true), removeSelectedItems: createSwapFunction(false),
Since we mixed dgrid/Selection
into our list constructor, each list has a
selection
object with properties representing the ID of each item selected.
By using List#row
, we can retrieve a representation of the row, which includes
a data
property referencing the store item. From there, we set __selected
to the proper value and use the store's put
method to apply the change.
Proper placement
If you played around with the previous demo, you might have noticed that after removing items from the right
hand list, they don't return to the same place in the left hand list as they were before. In fact, they will be
added back to the left hand list after the last row. In order to get items to return to their former position
in the left hand list, we'll need to tell the lists to request sorted data from the store via the
sort
option:
this.from = new TBList({ collection: this.store.filter(function (item) { return !item.__selected; }), sort: 'id', renderRow: this.renderItem, transferButton: this.addButton }, this.fromNode); handleSelection(this.from); this.to = new TBList({ collection: this.store.filter(function (item) { return item.__selected; }), sort: 'id', renderRow: this.renderItem, transferButton: this.removeButton }, this.toNode); handleSelection(this.to);
dgrid's sort
property is an array of objects detailing what properties to sort by. In our case, we
only need to sort by the "id" property. Now the items of our lists return to their proper order.
While this simple widget does what we set out to accomplish, it isn't very reusable: the selection mode of the lists is static, the property to sort on is hard-coded, and there's no way to easily get or set the value of the widget. These functions can be accomplished many different ways, but an example has been provided to get you started:
return declare([ _WidgetBase, _TemplatedMixin, _WidgetsInTemplateMixin ], { templateString: template, baseClass: 'myTransferBox', sortProperty: 'id', selectionMode: 'extended', buildRendering: function () { this.inherited(arguments); var from = this.from = new TBList({ collection: this.store.filter(function (item) { return !item.__selected; }), selectionMode: this.selectionMode, sort: this.sortProperty, renderRow: this.renderItem, transferButton: this.addButton }, this.fromNode); handleSelection(from); var to = this.to = new TBList({ collection: this.store.filter(function (item) { return item.__selected; }), selectionMode: this.selectionMode, sort: this.sortProperty, renderRow: this.renderItem, transferButton: this.removeButton }, this.toNode); handleSelection(to); this.own(this.from, this.to); }, startup: function () { if (this._started) { return; } this.inherited(arguments); this.from.startup(); this.to.startup(); }, _setValueAttr: function (value) { var store = this.store; for (var i = 0, length = value.length; i < length; i++) { store.get(value[i]).then(function (item) { item.__selected = true; store.put(item); }); } }, _getValueAttr: function () { var store = this.store; return store.filter(function (item) { return item.__selected; }).fetch().then(function (items) { var ids = []; var l = items.length; for (var i = 0; i < l; i++) { ids.push(items[i].id); } return ids; }); }, addSelectedItems: createSwapFunction(true), removeSelectedItems: createSwapFunction(false), renderItem: function (item) { return domConstruct.create('div', { innerHTML: item.name }); } });View Demo
Conclusion
As you can see, using two instances of dgrid/OnDemandList
to create a transfer box widget is
a bit involved, but simple once you know all of the pieces that need to be used. The modular nature of dgrid
allows us to use its base List
module instead of the full-fledged Grid
.