UNPKG

@qooxdoo/framework

Version:

The JS Framework for Coders

1,105 lines (957 loc) 34 kB
/* ************************************************************************ qooxdoo - the new era of web development http://qooxdoo.org Copyright: 2004-2009 1&1 Internet AG, Germany, http://www.1und1.de License: MIT: https://opensource.org/licenses/MIT See the LICENSE file in the project's top-level directory for details. Authors: * Martin Wittemann (martinwittemann) ************************************************************************ */ /** * <h2>List Controller</h2> * * *General idea* * The list controller is responsible for synchronizing every list like widget * with a data array. It does not matter if the array contains atomic values * like strings of complete objects where one property holds the value for * the label and another property holds the icon url. You can even use converters * that make the label show a text corresponding to the icon, by binding both * label and icon to the same model property and converting one of them. * * *Features* * * * Synchronize the model and the target * * Label and icon are bindable * * Takes care of the selection * * Passes on the options used by {@link qx.data.SingleValueBinding#bind} * * *Usage* * * As model, only {@link qx.data.Array}s do work. The currently supported * targets are * * * {@link qx.ui.form.SelectBox} * * {@link qx.ui.form.List} * * {@link qx.ui.form.ComboBox} * * All the properties like model, target or any property path is bindable. * Especially the model is nice to bind to another selection for example. * The controller itself can only work if it has a model and a target set. The * rest of the properties may be empty. * * *Cross reference* * * * If you want to bind single values, use {@link qx.data.controller.Object} * * If you want to bind a tree widget, use {@link qx.data.controller.Tree} * * If you want to bind a form widget, use {@link qx.data.controller.Form} */ qx.Class.define("qx.data.controller.List", { extend : qx.core.Object, include: qx.data.controller.MSelection, implement : qx.data.controller.ISelection, /* ***************************************************************************** CONSTRUCTOR ***************************************************************************** */ /** * @param model {qx.data.Array?null} The array containing the data. * * @param target {qx.ui.core.Widget?null} The widget which should show the * ListItems. * * @param labelPath {String?null} If the model contains objects, the labelPath * is the path reference to the property in these objects which should be * shown as label. */ construct : function(model, target, labelPath) { this.base(arguments); // lookup table for filtering and sorting this.__lookupTable = []; // register for bound target properties and onUpdate methods // from the binding options this.__boundProperties = []; this.__boundPropertiesReverse = []; this.__onUpdate = {}; if (labelPath != null) { this.setLabelPath(labelPath); } if (model != null) { this.setModel(model); } if (target != null) { this.setTarget(target); } }, /* ***************************************************************************** PROPERTIES ***************************************************************************** */ properties : { /** Data array containing the data which should be shown in the list. */ model : { check: "qx.data.IListData", apply: "_applyModel", event: "changeModel", nullable: true, dereference: true }, /** The target widget which should show the data. */ target : { apply: "_applyTarget", event: "changeTarget", nullable: true, init: null, dereference: true }, /** * The path to the property which holds the information that should be * shown as a label. This is only needed if objects are stored in the model. */ labelPath : { check: "String", apply: "_applyLabelPath", nullable: true }, /** * The path to the property which holds the information that should be * shown as an icon. This is only needed if objects are stored in the model * and if the icon should be shown. */ iconPath : { check: "String", apply: "_applyIconPath", nullable: true }, /** * A map containing the options for the label binding. The possible keys * can be found in the {@link qx.data.SingleValueBinding} documentation. */ labelOptions : { apply: "_applyLabelOptions", nullable: true }, /** * A map containing the options for the icon binding. The possible keys * can be found in the {@link qx.data.SingleValueBinding} documentation. */ iconOptions : { apply: "_applyIconOptions", nullable: true }, /** * Delegation object, which can have one or more functions defined by the * {@link IControllerDelegate} interface. */ delegate : { apply: "_applyDelegate", event: "changeDelegate", init: null, nullable: true }, /** * Whether a special "null" value is included in the list */ allowNull : { apply: "_applyAllowNull", event: "changeAllowNull", init: false, nullable: false, check: "Boolean" }, /** * Title for the special null value entry */ nullValueTitle: { apply: "_applyNullValueTitle", event: "changeNullValueTitle", init: null, nullable: true, check: "String" }, /** * Icon for the special null value entry */ nullValueIcon: { apply: "_applyNullValueIcon", event: "changeNullValueIcon", init: null, nullable: true, check: "String" } }, /* ***************************************************************************** MEMBERS ***************************************************************************** */ members : { // private members __changeModelListenerId : null, __lookupTable : null, __onUpdate : null, __boundProperties : null, __boundPropertiesReverse : null, __syncTargetSelection : null, __syncModelSelection : null, /* --------------------------------------------------------------------------- PUBLIC API --------------------------------------------------------------------------- */ /** * Updates the filter and the target. This could be used if the filter * uses an additional parameter which changes the filter result. */ update: function() { this.__changeModelLength(); this.__renewBindings(); this._updateSelection(); }, /* --------------------------------------------------------------------------- APPLY METHODS --------------------------------------------------------------------------- */ /** * If a new delegate is set, it applies the stored configuration for the * list items to the already created list items once. * * @param value {qx.core.Object|null} The new delegate. * @param old {qx.core.Object|null} The old delegate. */ _applyDelegate: function(value, old) { this._setConfigureItem(value, old); this._setFilter(value, old); this._setCreateItem(value, old); this._setBindItem(value, old); }, /** * Apply-method which will be called if the icon options has been changed. * It invokes a renewing of all set bindings. * * @param value {Map|null} The new icon options. * @param old {Map|null} The old icon options. */ _applyIconOptions: function(value, old) { this.__renewBindings(); }, /** * Apply-method which will be called if the label options has been changed. * It invokes a renewing of all set bindings. * * @param value {Map|null} The new label options. * @param old {Map|null} The old label options. */ _applyLabelOptions: function(value, old) { this.__renewBindings(); }, /** * Apply-method which will be called if the icon path has been changed. * It invokes a renewing of all set bindings. * * @param value {String|null} The new icon path. * @param old {String|null} The old icon path. */ _applyIconPath: function(value, old) { this.__renewBindings(); }, /** * Apply-method which will be called if the label path has been changed. * It invokes a renewing of all set bindings. * * @param value {String|null} The new label path. * @param old {String|null} The old label path. */ _applyLabelPath: function(value, old) { this.__renewBindings(); }, /** * Apply method for the `allowNull` property */ _applyAllowNull: function(value, oldValue) { this.__refreshModel(); }, /** * Apply method for the `allowNull` property */ _applyNullValueTitle: function(value, oldValue) { this.__refreshModel(); }, /** * Apply method for the `allowNull` property */ _applyNullValueIcon: function(value, oldValue) { this.__refreshModel(); }, /** * Refreshes the model, uses when the model and target are not changing but the appearance * and bindings may need to be updated */ __refreshModel: function() { if (this.getModel() && this.getTarget()) { this.update(); } }, /** * 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) { // add a new listener this.__changeModelListenerId = value.addListener("change", this.__changeModel, this); // renew the index lookup table this.__buildUpLookupTable(); // check for the new length this.__changeModelLength(); // as we only change the labels of the items, the selection change event // may be missing so we invoke it here if (old == null) { this._changeTargetSelection(); } else { // update the selection asynchronously this.__syncTargetSelection = true; qx.ui.core.queue.Widget.add(this); } } else { var target = this.getTarget(); // if the model is set to null, we should remove all items in the target if (target != null) { // we need to remove the bindings too so use the controller method // for removing items var length = target.getChildren().length; for (var i = 0; i < length; i++) { this.__removeItem(); }; } } }, /** * 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) { // add a listener for the target change this._addChangeTargetListener(value, old); // if there was an old target if (old != undefined) { // remove all element of the old target var removed = old.removeAll(); for (var i=0; i<removed.length; i++) { removed[i].destroy(); } // remove all bindings this.removeAllBindings(); } if (value != null) { if (this.getModel() != null) { // add a binding for all elements in the model for (var i = 0; i < this.__lookupTable.length; i++) { this.__addItem(this.__lookup(i)); } } } }, /* --------------------------------------------------------------------------- EVENT HANDLER --------------------------------------------------------------------------- */ /** * 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. */ __inChangeModel: false, /** * Event handler for the changeModel of the model. Updates the controller. */ __changeModel: function() { if (this.__inChangeModel) { return; } this.__inChangeModel = true; // need an asynchronous selection update because the bindings have to be // executed to update the selection probably (using the widget queue) // this.__syncTargetSelection = true; this.__syncModelSelection = true; qx.ui.core.queue.Widget.add(this); // update on filtered lists... (bindings need to be renewed) this.update(); this.__inChangeModel = false; }, /** * Internal method used to sync the selection. The controller uses the * widget queue to schedule the selection update. An asynchronous handling of * the selection is needed because the bindings (event listeners for the * binding) need to be executed before the selection is updated. * @internal */ syncWidget : function() { if (this.__syncTargetSelection) { this._changeTargetSelection(); } if (this.__syncModelSelection) { this._updateSelection(); } this.__syncModelSelection = this.__syncTargetSelection = null; }, /** * Event handler for the changeLength of the model. If the length changes * of the model, either ListItems need to be removed or added to the target. */ __changeModelLength: function() { // only do something if there is a target if (this.getTarget() == null) { return; } // build up the look up table this.__buildUpLookupTable(); // get the length var newLength = this.__lookupTable.length; var currentLength = this.getTarget().getChildren().length; // if there are more item if (newLength > currentLength) { // add the new elements for (var j = currentLength; j < newLength; j++) { this.__addItem(this.__lookup(j)); } // if there are less elements } else if (newLength < currentLength) { // remove the unnecessary items for (var j = currentLength; j > newLength; j--) { this.__removeItem(); } } // build up the look up table this.__buildUpLookupTable(); // sync the target selection in case someone deleted a item in // selection mode "one" [BUG #4839] this.__syncTargetSelection = true; qx.ui.core.queue.Widget.add(this); }, /** * Helper method which removes and adds the change listener of the * controller to the model. This is sometimes necessary to ensure that the * listener of the controller is executed as the last listener of the chain. */ __moveChangeListenerAtTheEnd : function() { var model = this.getModel(); // it can be that the bindings has been reset without the model so // maybe there is no model in some scenarios if (model != null) { model.removeListenerById(this.__changeModelListenerId); this.__changeModelListenerId = model.addListener("change", this.__changeModel, this); } }, /* --------------------------------------------------------------------------- ITEM HANDLING --------------------------------------------------------------------------- */ /** * Creates a ListItem and delegates the configure method if a delegate is * set and the needed function (configureItem) is available. * * @return {qx.ui.form.ListItem} The created and configured ListItem. */ _createItem: function() { var delegate = this.getDelegate(); // check if a delegate and a create method is set if (delegate != null && delegate.createItem != null) { var item = delegate.createItem(); } else { var item = new qx.ui.form.ListItem(); } // if there is a configure method, invoke it if (delegate != null && delegate.configureItem != null) { delegate.configureItem(item); } return item; }, /** * Internal helper to add ListItems to the target including the creation * of the binding. * * @param index {Number} The index of the item to add. */ __addItem: function(index) { // create a new ListItem var listItem = this._createItem(); // set up the binding this._bindListItem(listItem, index); // add the ListItem to the target this.getTarget().add(listItem); }, /** * Internal helper to remove ListItems from the target. Also the binding * will be removed properly. */ __removeItem: function() { this._startSelectionModification(); var children = this.getTarget().getChildren(); // get the last binding id var index = children.length - 1; // get the item var oldItem = children[index]; this._removeBindingsFrom(oldItem); // remove the item this.getTarget().removeAt(index); oldItem.destroy(); this._endSelectionModification(); }, /** * Returns all models currently visible by the list. This method is only * useful if you use the filter via the {@link #delegate}. * * @return {qx.data.Array} A new data array container all the models * which representation items are currently visible. */ getVisibleModels : function() { var visibleModels = []; var target = this.getTarget(); if (target != null) { var items = target.getChildren(); for (var i = 0; i < items.length; i++) { visibleModels.push(items[i].getModel()); }; } return new qx.data.Array(visibleModels); }, /* --------------------------------------------------------------------------- BINDING STUFF --------------------------------------------------------------------------- */ /** * Sets up the binding for the given ListItem and index. * * @param item {qx.ui.form.ListItem} The internally created and used * ListItem. * @param index {Number} The index of the ListItem. */ _bindListItem: function(item, index) { // -1 is the special, "null" value item. Nothing to bind, just fix the display and model if (index < 0) { item.setLabel(this.getNullValueTitle()||""); item.setIcon(this.getNullValueIcon()); item.setModel(null); return; } var delegate = this.getDelegate(); // if a delegate for creating the binding is given, use it if (delegate != null && delegate.bindItem != null) { delegate.bindItem(this, item, index); // otherwise, try to bind the listItem by default } else { this.bindDefaultProperties(item, index); } }, /** * Helper-Method for binding the default properties (label, icon and model) * from the model to the target widget. * * This method should only be called in the * {@link qx.data.controller.IControllerDelegate#bindItem} function * implemented by the {@link #delegate} property. * * @param item {qx.ui.form.ListItem} The internally created and used * ListItem. * @param index {Number} The index of the ListItem. */ bindDefaultProperties : function(item, index) { // model this.bindProperty( "", "model", null, item, index ); // label this.bindProperty( this.getLabelPath(), "label", this.getLabelOptions(), item, index ); // if the iconPath is set if (this.getIconPath() != null) { this.bindProperty( this.getIconPath(), "icon", this.getIconOptions(), item, index ); } }, /** * Helper-Method for binding a given property from the model to the target * widget. * This method should only be called in the * {@link qx.data.controller.IControllerDelegate#bindItem} function * implemented by the {@link #delegate} property. * * @param sourcePath {String | null} The path to the property in the model. * If you use an empty string, the whole model item will be bound. * @param targetProperty {String} The name of the property in the target * widget. * @param options {Map | null} The options used by * {@link qx.data.SingleValueBinding#bind} to use for the binding. * @param targetWidget {qx.ui.core.Widget} The target widget. * @param index {Number} The index of the current binding. */ bindProperty: function(sourcePath, targetProperty, options, targetWidget, index) { // create the options for the binding containing the old options // including the old onUpdate function if (options != null) { var options = qx.lang.Object.clone(options); this.__onUpdate[targetProperty] = options.onUpdate; delete options.onUpdate; } else { options = {}; this.__onUpdate[targetProperty] = null; } options.onUpdate = qx.lang.Function.bind(this._onBindingSet, this, index); options.ignoreConverter = "model"; // build up the path for the binding var bindPath = "model[" + index + "]"; if (sourcePath != null && sourcePath != "") { bindPath += "." + sourcePath; } // create the binding var id = this.bind(bindPath, targetWidget, targetProperty, options); targetWidget.setUserData(targetProperty + "BindingId", id); // save the bound property if (!this.__boundProperties.includes(targetProperty)) { this.__boundProperties.push(targetProperty); } }, /** * Helper-Method for binding a given property from the target widget to * the model. * This method should only be called in the * {@link qx.data.controller.IControllerDelegate#bindItem} function * implemented by the {@link #delegate} property. * * @param targetPath {String | null} The path to the property in the model. * @param sourcePath {String} The name of the property in the target. * @param options {Map | null} The options to use by * {@link qx.data.SingleValueBinding#bind} for the binding. * @param sourceWidget {qx.ui.core.Widget} The source widget. * @param index {Number} The index of the current binding. */ bindPropertyReverse: function( targetPath, sourcePath, options, sourceWidget, index ) { // build up the path for the binding var targetBindPath = "model[" + index + "]"; if (targetPath != null && targetPath != "") { targetBindPath += "." + targetPath; } // create the binding var id = sourceWidget.bind(sourcePath, this, targetBindPath, options); sourceWidget.setUserData(targetPath + "ReverseBindingId", id); // save the bound property if (!this.__boundPropertiesReverse.includes(targetPath)) { this.__boundPropertiesReverse.push(targetPath); } }, /** * Method which will be called on the invoke of every binding. It takes * care of the selection on the change of the binding. * * @param index {Number} The index of the current binding. * @param sourceObject {qx.core.Object} The source object of the binding. * @param targetObject {qx.core.Object} The target object of the binding. */ _onBindingSet: function(index, sourceObject, targetObject) { // ignore the binding set if the model is already set to null if (this.getModel() == null || this._inSelectionModification()) { return; } // go through all bound target properties for (var i = 0; i < this.__boundProperties.length; i++) { // if there is an onUpdate for one of it, invoke it if (this.__onUpdate[this.__boundProperties[i]] != null) { this.__onUpdate[this.__boundProperties[i]](); } } }, /** * Internal helper method to remove the binding of the given item. * * @param item {Number} The item of which the binding which should * be removed. */ _removeBindingsFrom: function(item) { // go through all bound target properties for (var i = 0; i < this.__boundProperties.length; i++) { // get the binding id and remove it, if possible var id = item.getUserData(this.__boundProperties[i] + "BindingId"); if (id != null) { this.removeBinding(id); item.setUserData(this.__boundProperties[i] + "BindingId", null); } } // go through all reverse bound properties for (var i = 0; i < this.__boundPropertiesReverse.length; i++) { // get the binding id and remove it, if possible var id = item.getUserData(this.__boundPropertiesReverse[i] + "ReverseBindingId"); if (id != null) { item.removeBinding(id); item.getUserData(this.__boundPropertiesReverse[i] + "ReverseBindingId", null); } }; }, /** * Internal helper method to renew all set bindings. */ __renewBindings: function() { // ignore, if no target is set (startup) if (this.getTarget() == null || this.getModel() == null) { return; } // get all children of the target var items = this.getTarget().getChildren(); // go through all items for (var i = 0; i < items.length; i++) { this._removeBindingsFrom(items[i]); // add the new binding this._bindListItem(items[i], this.__lookup(i)); } // move the controllers change handler for the model to the end of the // listeners queue this.__moveChangeListenerAtTheEnd(); }, /* --------------------------------------------------------------------------- DELEGATE HELPER --------------------------------------------------------------------------- */ /** * Helper method for applying the delegate It checks if a configureItem * is set end invokes the initial process to apply the given function. * * @param value {Object} The new delegate. * @param old {Object} The old delegate. */ _setConfigureItem: function(value, old) { if (value != null && value.configureItem != null && this.getTarget() != null) { var children = this.getTarget().getChildren(); for (var i = 0; i < children.length; i++) { value.configureItem(children[i]); } } }, /** * Helper method for applying the delegate It checks if a bindItem * is set end invokes the initial process to apply the given function. * * @param value {Object} The new delegate. * @param old {Object} The old delegate. */ _setBindItem: function(value, old) { // if a new bindItem function is set if (value != null && value.bindItem != null) { // do nothing if the bindItem function did not change if (old != null && old.bindItem != null && value.bindItem == old.bindItem) { return; } this.__renewBindings(); } }, /** * Helper method for applying the delegate It checks if a createItem * is set end invokes the initial process to apply the given function. * * @param value {Object} The new delegate. * @param old {Object} The old delegate. */ _setCreateItem: function(value, old) { if ( this.getTarget() == null || this.getModel() == null || value == null || value.createItem == null ) { return; } this._startSelectionModification(); // remove all bindings var children = this.getTarget().getChildren(); for (var i = 0, l = children.length; i < l; i++) { this._removeBindingsFrom(children[i]); } // remove all elements of the target var removed = this.getTarget().removeAll(); for (var i=0; i<removed.length; i++) { removed[i].destroy(); } // update this.update(); this._endSelectionModification(); this._updateSelection(); }, /** * Apply-Method for setting the filter. It removes all bindings, * check if the length has changed and adds or removes the items in the * target. After that, the bindings will be set up again and the selection * will be updated. * * @param value {Function|null} The new filter function. * @param old {Function|null} The old filter function. */ _setFilter: function(value, old) { // update the filter if it has been removed if ((value == null || value.filter == null) && (old != null && old.filter != null)) { this.__removeFilter(); } // check if it is necessary to do anything if ( this.getTarget() == null || this.getModel() == null || value == null || value.filter == null ) { return; } // if yes, continue this._startSelectionModification(); // remove all bindings var children = this.getTarget().getChildren(); for (var i = 0, l = children.length; i < l; i++) { this._removeBindingsFrom(children[i]); } // store the old lookup table var oldTable = this.__lookupTable; // generate a new lookup table this.__buildUpLookupTable(); // if there are lesser items if (oldTable.length > this.__lookupTable.length) { // remove the unnecessary items for (var j = oldTable.length; j > this.__lookupTable.length; j--) { this.getTarget().removeAt(j - 1).destroy(); } // if there are more items } else if (oldTable.length < this.__lookupTable.length) { // add the new elements for (var j = oldTable.length; j < this.__lookupTable.length; j++) { var tempItem = this._createItem(); this.getTarget().add(tempItem); } } // bind every list item again var listItems = this.getTarget().getChildren(); for (var i = 0; i < listItems.length; i++) { this._bindListItem(listItems[i], this.__lookup(i)); } // move the controllers change handler for the model to the end of the // listeners queue this.__moveChangeListenerAtTheEnd(); this._endSelectionModification(); this._updateSelection(); }, /** * This helper is responsible for removing the filter and setting the * controller to a valid state without a filtering. */ __removeFilter : function() { // renew the index lookup table this.__buildUpLookupTable(); // check for the new length this.__changeModelLength(); // renew the bindings this.__renewBindings(); // need an asynchronous selection update because the bindings have to be // executed to update the selection probably (using the widget queue) this.__syncModelSelection = true; qx.ui.core.queue.Widget.add(this); }, /* --------------------------------------------------------------------------- LOOKUP STUFF --------------------------------------------------------------------------- */ /** * Helper-Method which builds up the index lookup for the filter feature. * If no filter is set, the lookup table will be a 1:1 mapping. */ __buildUpLookupTable: function() { var model = this.getModel(); if (model == null) { return; } var delegate = this.getDelegate(); if (delegate != null) { var filter = delegate.filter; } this.__lookupTable = []; // -1 is a special lookup value, to represent the "null" option if (this.isAllowNull()) { this.__lookupTable.push(-1); } for (var i = 0; i < model.getLength(); i++) { if (filter == null || filter(model.getItem(i))) { this.__lookupTable.push(i); } } }, /** * Function for accessing the lookup table. * * @param index {Integer} The index of the lookup table. * @return {Number} Item index from lookup table */ __lookup: function(index) { return this.__lookupTable[index]; } }, /* ***************************************************************************** DESTRUCTOR ***************************************************************************** */ destruct : function() { this.__lookupTable = this.__onUpdate = this.__boundProperties = null; this.__boundPropertiesReverse = null; // remove yourself from the widget queue qx.ui.core.queue.Widget.remove(this); } });