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">&gt;</button>
		<button type="button" disabled data-dojo-type="dijit/form/Button"
			data-dojo-attach-event="onClick: removeSelectedItems"
			data-dojo-attach-point="removeButton">&lt;</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.

View Demo

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.

View Demo

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 Demo

Adding 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.

View Demo

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.

View Demo

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.

Resources