Skip to content

Instantly share code, notes, and snippets.

@miracle2k
Last active December 19, 2015 01:29
Show Gist options
  • Save miracle2k/5876245 to your computer and use it in GitHub Desktop.
Save miracle2k/5876245 to your computer and use it in GitHub Desktop.
Qooxdoo Table controller.
/**
* Implement a very basic TableController.
*
* In particular, consider these limitations for now:
*
* - Only databind in one direction, from model to table.
* - When changes occur, we currently simply recreate all rows.
*
* Design-wise, one could imagine different approaches. Because the table
* is so old, it's usage is totally different from the rest of Qooxdoo. It
* already uses something called a "table model", but this is a specialized
* class which wraps a native Javascript array, and does not support
* modern-style models like qx.data.Array.
* So you might think about making the a controller design that syncs
* between a) qx.data.Array and the table widget directly, b) between
* qx.ui.table.model.Simple and the table widget or c) between qx.data.Array
* and a qx.ui.table.Simple model.
* It turns out the last is the only feasible option. In a), we would simply
* be hiding the table model the controller would have to create, making
* it's other features (column definitions) harder to access. In b), even
* if it were possible, we wouldn't have "regular" databinding with proper
* Qooxdoo arrays.
*
* The one thing I did not properly consider was writing a table model that
* would wrap a Qooxdoo array model. Though it sounds like an intriguing
* approach.
*
* -----
*
* We do not support filtering, but if wanted to, we might see whether we
* should do it manually, or by using the "Filtered" table model.
*
* http://bugzilla.qooxdoo.org/show_bug.cgi?id=3171
* Qooxdoo ticket for a table controller.
*/
qx.Class.define("ad.cms.data.TableController",
{
extend : qx.core.Object,
/**
* @param model {qx.data.Array?null} The array containing the data.
*
* @param target {qx.ui.table.model.Abstract?null} The table model.
*
* @param targetSelectionModel {qx.ui.table.selection.Model?null} The table
* selection model.
*
* @param mapping {Map?null} Custom mapping between column IDs and
* databinding paths to properties in the model. By default,
* the mapping would assume a 1:1 correspondence.
*/
construct : function(model, target, targetSelectionModel, mapping)
{
this.base(arguments);
this.setMapping(mapping || null);
this.setSelection(new qx.data.Array());
if (model != null) {
this.setModel(model);
}
if (target != null) {
this.setTarget(target);
}
if (targetSelectionModel != null) {
this.setTargetSelectionModel(targetSelectionModel);
}
},
properties :
{
/** Data array containing the data which should be shown in the table. */
model :
{
check: "qx.data.IListData",
apply: "_applyModel",
event: "changeModel",
nullable: true,
dereference: true
},
// TODO: Maybe it would be a better design to make the target the table,
// and then get both the selection model as well as the data model from it?
/** The target table model */
target :
{
apply: "_applyTarget",
event: "changeTarget",
nullable: true,
init: null,
dereference: true
},
/** The target table selection model */
targetSelectionModel :
{
apply: "_applyTargetSelectionModel",
event: "changeTargetSelectionModel",
nullable: true,
init: null,
dereference: true
},
/** Our selection model, synced to targetSelectionModel. */
selection :
{
check: "qx.data.Array",
event: "changeSelection",
apply: "_applySelection",
init: null
},
/** Maps the databinding - table column ids to model property paths. */
mapping :
{
event: "changeMapping",
init: null,
nullable: true
},
/** Maps column ids to converter functions to use during databinding.
These functions will be called with two arguments, the value and
the model, and are expected to return the cell value. */
converters :
{
event: "changeConverters",
init: null,
nullable: true
}
},
members :
{
__changeModelListenerId : null,
__selectionArrayListenerId : null,
__targetSelectionModelListenerId : null,
/**
* Apply-method which will be called if the model has been changed. It
* removes all the listeners from the old model and adds the needed
* listeners to the new model. It also invokes the initial filling of the
* target widgets if there is a target set.
*
* @param value {qx.data.Array|null} The new model array.
* @param old {qx.data.Array|null} The old model array.
*/
_applyModel: function(value, old) {
// remove the old listener
if (old != undefined) {
if (this.__changeModelListenerId != undefined) {
old.removeListenerById(this.__changeModelListenerId);
}
}
// erase the selection if there is something selected
if (this.getSelection() != undefined && this.getSelection().length > 0) {
this.getSelection().splice(0, this.getSelection().length).dispose();
}
// if a model is set
if (value != null) {
// watch for changes in the model.
this.__changeModelListenerId =
value.addListener("change", this.__changeModel, this);
// sync model to target
this.__fullRebuild();
}
else {
this.__clearTargetAndBindings();
}
},
/**
* Apply-method which will be called if the target has been changed.
* When the target changes, every binding needs to be reset and the old
* target needs to be cleaned up. If there is a model, the target will be
* filled with the data of the model.
*
* @param value {qx.ui.core.Widget|null} The new target.
* @param old {qx.ui.core.Widget|null} The old target.
*/
_applyTarget: function(value, old) {
// if there was an old target, clear it
if (old != undefined)
this.__clearTargetAndBindings(old);
if (value != null) {
if (this.getModel() != null)
this.__fullRebuild();
}
},
/**
* Will be called when the table selection model is switched.
*/
_applyTargetSelectionModel : function(value, old) {
// remove the old selection listener
if (this.__targetSelectionModelListenerId != undefined && old != undefined) {
old.removeListenerById(this.__targetSelectionModelListenerId);
}
if (value != null) {
// add a new selection listener
this.__targetSelectionModelListenerId = value.addListener(
"changeSelection", this._changeTargetSelection, this);
this._updateSelection();
}
},
/**
* Apply-method for setting a new selection array. Only the change listener
* will be removed from the old array and added to the new.
*
* @param value {qx.data.Array} The new data array for the selection.
* @param old {qx.data.Array|null} The old data array for the selection.
*/
_applySelection: function(value, old) {
// remove the old listener if necessary
if (this.__selectionArrayListenerId != undefined && old != undefined) {
old.removeListenerById(this.__selectionArrayListenerId);
}
// add a new change listener to the changeArray
this.__selectionArrayListenerId = value.addListener(
"change", this.__changeSelectionArray, this
);
// apply the new selection
this._updateSelection();
},
/**
* Event handler for the change event of the model. If the model changes,
* Only the selection needs to be changed. The change of the data will
* be done by the binding.
*/
__changeModel: function() {
// A proper controller would try to be smart here and only update those
// rows that have changed. We behave very simply instead, and recreate
// the whole table (while trying to maintain selection).
//
// TODO: One downside is that upon changing the model, the sort order
// is reset, because the table actually sorts the array backing it -
// and we recreate the array. The correct way would be to add the
// new rows in the right place according to the current sort.
this.__fullRebuild();
},
/**
* Event handler for the change of the data array holding the selection.
* If a change is in the selection array, the selection update will be
* invoked.
*/
__changeSelectionArray: function() {
this._updateSelection();
},
__fullRebuild : function() {
// The list controller simply ensures that the correct number of
// ListItems exist, and then binds from the model by index. That is,
// if the first item in the model is deleted, the databindings of
// all the items of the model would trigger and cause an update of
// all the list items, with each item moving one slot up.
//
// While in theory possible to recreate, this doesn't seem like a
// very feasible approach for us, because the table can be sorted,
// and when that happens, the indices with which we have to access
// a particular row changes accordingly. That is, we only get the
// visual selection index, not the index within the original data
// order (which is in fact lost).
// In fact, the *only* way for us to associate table rows back to an
// original model object is via the rememberMaps option of the
// "*AsMapArray" methods.
if (this.getTarget() == null)
return;
// Delete all existing table rows, and all the related bindings
this.__clearTargetAndBindings();
// Setup new rows and bindings
var rows = [];
this.getModel().forEach(function(obj, index) {
rows.push(this.__createRow(obj));
}, this);
this.getTarget().addRowsAsMapArray(rows, null, true);
// Restore the selection.
this._updateSelection();
},
__createRow: function(item) {
// We want to init "dataMap" with the models properties, and be notified
// when those properties change. Because the target is a native JS
// object without databinding support, we might think that we have to
// manually add change listeners to the properties instead. This would
// be a pain in two respects. Number one, support for recursive property
// paths like a.b.c is difficult to implement (and databinding already
// does), plus, setting the initial values is also something databinding
// would do for us.
//
// Fortunately, there is a trick here. Qooxdoo databinding allows the
// target to be null, and we can then use onUpdate to call a function.
// In other words, in this way we use data binding with the target
// being simply a function that is triggered.
var target = this.getTarget();
var converters = this.getConverters() || {};
// The map needs to have a reference to the model. This is the only
// way we can back-associate a table row to the original model data.
//
// First, add an empty row to the table. It will be initialized via
// databinding.
var dataMap = {__model: item, __bindingIds: [], __rowInitialized: false};
// Setup databindings. Since for every binding, the onUpdate function
// will be triggered once to set the initial values, by way of setting
// these up we can construct a map with column->value pairs; which we
// can ultimately add as a row to the table.
// In other words, this serves as both databinding setup and initial
// row map construction in one go.
var mapping = this.__getMapping();
var this$Controller = this;
for (var columnId in mapping) {
if (!mapping.hasOwnProperty(columnId))
continue;
(function(columnId) {
dataMap.__bindingIds.push(item.bind(mapping[columnId], null, '', {
onUpdate: function(source, _target, value) {
// Set the value for this column, as given to us via
// databinding, in the row data map.
if (converters[columnId])
value = converters[columnId](value, source);
dataMap[columnId] = value;
// If the row has already been added to the table, notify
// the table it has been changed.
if (dataMap.__rowInitialized) {
var index = this$Controller._findRowIndexForModel(source);
target.setRowsAsMapArray([dataMap], index, true);
}
}
}));
})(columnId);
}
// After databinding has been setup, the "initial setter calls" have
// constructed a dataMap with all the column values, which we now
// add to the table.
dataMap.__rowInitialized = true;
return dataMap;
},
/**
* Find the current index of the given model in the table. This changes
* whenever the user sorts the able, and the model stored with the row
* data is the only way for us to make the association.
*/
_findRowIndexForModel : function(model) {
var target = this.getTarget();
for (var i=0; i<target.getRowCount(); i++) {
if (target.getRowDataAsMap(i).__model == model)
return i;
}
return null;
},
/**
* Clear the given target, or if non given, the current target.
*/
__clearTargetAndBindings : function(target) {
// Remove all bindings
target = target || this.getTarget();
for (var i=0; i<target.getRowCount(); i++) {
var data = target.getRowDataAsMap(i);
while (data.__bindingIds.length)
data.__model.removeBinding(data.__bindingIds.pop());
}
// Clear the table
target.setData([]);
},
/**
* Return user specified mapping, or custom mapping based on columns.
*
* This is a function so that a change in the column configuration will
* be reflected in the next update cycle, and no cache-refresh
* is required.
*/
__getMapping : function() {
if (this.getMapping() != null)
return this.getMapping();
var mapping = {};
var target = this.getTarget();
for (var i=0; i<target.getColumnCount(); i++) {
mapping[target.getColumnId(i)] = target.getColumnId(i);
}
return mapping;
},
/**
* Sync our selection to the table.
* @private
*/
_updateSelection: function() {
try {
this.__changingTheirSelection = true;
// Transform the selection array to a table selection
var selectionModel = this.getTargetSelectionModel();
if (selectionModel == null)
return;
// Note: We use internal selection methods here (_-prefix), which do
// not trigger the change event; otherwise, the first change would
// immediately trigger the _changeTargetSelection handler which then
// tries to sync in reverse. We then would have to work around that
// by using a "inSelectionModification" lock, like
// qx.data.controller.MSelection does.
selectionModel._resetSelection();
this.getSelection().forEach(function(item) {
var idx = this._findRowIndexForModel(item);
selectionModel._addSelectionInterval(idx, idx);
}, this);
selectionModel._fireChangeSelection();
}
finally {
this.__changingTheirSelection = false;
}
},
/**
* Sync the table selection to us.
* @private
*/
_changeTargetSelection: function() {
// Avoid recursive event triggering between table selection and our model
if (this.__changingTheirSelection)
return;
var selectedModels = new qx.data.Array;
var selectionModel = this.getTargetSelectionModel();
selectionModel.iterateSelection(function(idx) {
selectedModels.push(this.getTarget().getRowDataAsMap(idx).__model);
}, this);
this.setSelection(selectedModels);
}
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment