Issue #23853: More widget refactoring.
[xtuple] / lib / enyo-x / source / widgets / picker.js
1 /*jshint indent:2, curly:true, eqeqeq:true, immed:true, latedef:true, newcap:true, noarg:true,
2 regexp:true, undef:true, trailing:true, white:true, strict:false */
3 /*global XT:true, Backbone:true, enyo:true, _:true */
4
5 (function () {
6
7   /**
8     @name XV.Picker
9     @class A picker control that implements a dropdown list of items which can be selected.<br />
10     Unlike the {@link XV.RelationWidget}, the collection is stored local to the widget.<br />
11     The superkind of {@link XV.CharacteristicPicker}.<br />
12
13     Accepts a single attribute mapping or an object attribute mapping where the object requires
14     two properties "colleciton" and "value." This technique can be used to bind the picker
15     to a collection on a local model in addition to the selected value.
16
17     Derived from <a href="http://enyojs.com/api/#enyo.Control">enyo.Control</a>.
18     @extends enyo.Control
19    */
20   enyo.kind(
21     /** @lends XV.PickerWidget# */{
22     name: "XV.PickerWidget",
23     kind: "enyo.Control",
24     classes: "xv-pickerwidget",
25     events: /** @lends XV.PickerWidget# */{
26       /**
27         @property {Object} inEvent The payload that's attached to bubbled-up events
28         @property {XV.PickerWidget} inEvent.originator This
29         @property inEvent.value The value passed up is the key of the object and not the object itself
30        */
31       onValueChange: ""
32     },
33     published: {
34       attr: null,
35       value: null,
36       collection: null,
37       disabled: false,
38       nameAttribute: "name",
39       orderBy: null,
40       noneText: "_none".loc(),
41       noneClasses: "",
42       showNone: true,
43       prerender: true,
44       defaultValue: null,
45       showLabel: true,
46       label: ""
47     },
48     handlers: {
49       onSelect: "itemSelected"
50     },
51     components: [
52       {controlClasses: 'enyo-inline', components: [
53         {name: "label", classes: "xv-label"},
54         {kind: "onyx.InputDecorator", name: "inputWrapper", components: [
55           {kind: "onyx.PickerDecorator", components: [
56             {kind: "XV.PickerButton", name: "pickerButton", content: "_none".loc(), onkeyup: "keyUp"},
57             {name: "picker", kind: "onyx.Picker"}
58           ]}
59         ]}
60       ]}
61     ],
62     /**
63      @todo Document the buildList method.
64      */
65     buildList: function (options) {
66       var nameAttribute = this.getNameAttribute(),
67         models = this.filteredList(options),
68         none = this.getNoneText(),
69         classes = this.getNoneClasses(),
70         picker = this.$.picker,
71         iconClass = this.iconClass,
72         iconVisible = this.iconVisible,
73         component,
74         i;
75
76       picker.destroyClientControls();
77       if (this.showNone) {
78         picker.createComponent({
79           kind: "XV.PickerMenuItem",
80           value: null,
81           content: none,
82           classes: classes
83         });
84       }
85
86       _.each(models, function (model) {
87         var name = model.getValue ? model.getValue(nameAttribute) : model.get(nameAttribute),
88           isActive = model && model.getValue ? model.getValue("isActive") !== false :
89             (model && _.isBoolean(model.get("isActive")) ? model.get("isActive") : true);
90
91         picker.createComponent({
92           kind: "XV.PickerMenuItem",
93           value: model,
94           disabled: !isActive,
95           content: name,
96           iconClass: iconClass,
97           iconVisible: iconVisible
98         });
99
100       });
101
102       // this is for an Enyo Bug relating
103       // to pickers inside of a popup
104       if (this.prerender) {
105         this.$.picker.render();
106       }
107     },
108     /**
109      @todo Document the clear method.
110      */
111     clear: function (options) {
112       this.setValue(null, options);
113     },
114     /**
115      Collection can either be a pointer to a real collection, or a string
116      that will be resolved to a real collection.
117      */
118     collectionChanged: function () {
119       var collection = _.isObject(this.collection) ? this.collection :
120           XT.getObjectByName(this.collection),
121         that = this,
122         didStartup = false,
123         callback;
124
125       // Remove any old bindings
126       if (this._collection) {
127         this._collection.off("add remove reset", this.buildList, this);
128       }
129
130      // If we don't have data yet, try again after start up tasks complete
131       if (!collection) {
132         if (didStartup) {
133           XT.log('Could not find collection ' + this.getCollection());
134           return;
135         }
136         callback = function () {
137           didStartup = true;
138           that.collectionChanged();
139         };
140         XT.getStartupManager().registerCallback(callback);
141         return;
142       }
143
144       this._collection = collection;
145       this._collection.on("add remove reset", this.buildList, this);
146       this.orderByChanged();
147       if (this._collection.comparator) { this._collection.sort(); }
148       this.buildList();
149     },
150     /**
151      @todo Document the create method.
152      */
153     create: function () {
154       var defaultValue = this.getDefaultValue();
155
156       this.inherited(arguments);
157       this.noneTextChanged();
158       if (this.getCollection()) {
159         this.collectionChanged();
160       }
161       if (defaultValue) {
162         this.setValue(defaultValue, {silent: true});
163       }
164
165       this.labelChanged();
166       this.showLabelChanged();
167     },
168     destroy: function () {
169       if (this._collection && this._collection.off) {
170         this._collection.off("add remove reset", this.buildList, this);
171       }
172       this.inherited(arguments);
173     },
174     /**
175      @todo Document the disabledChanged method.
176      */
177     disabledChanged: function (inSender, inEvent) {
178       var disabled = this.getDisabled();
179       this.$.pickerButton.setDisabled(disabled);
180       this.$.label.addRemoveClass("disabled", disabled);
181     },
182     /**
183      @todo Document the getValueToString method.
184      */
185     getValueToString: function () {
186       return this.$.pickerButton.getContent();
187     },
188     /**
189      @todo Document the itemSelected method.
190      */
191     itemSelected: function (inSender, inEvent) {
192       var value = this.$.picker.getSelected().value;
193       this.setValue(value);
194     },
195     /**
196       Implement your own filter function here. By default
197       simply returns the array of models passed.
198
199       @param {Array}
200       @returns {Array}
201     */
202     filter: function (models, options) {
203       return models || [];
204     },
205     /**
206       Returns array of models for current collection instance with `filter`
207       applied.
208     */
209     filteredList: function (options) {
210       return this._collection ? this.filter(this._collection.models, options) : [];
211     },
212     keyUp: function (inSender, inEvent) {
213       var keyCode = inEvent.keyCode,
214         currentSelection = this.$.picker.getSelected(),
215         controlsContents = _.map(this.$.picker.controls, function (control) {
216           return control.content;
217         }),
218         currentIndex = _.indexOf(controlsContents, currentSelection && currentSelection.content),
219         newSelection,
220         newIndex;
221
222       if (keyCode === 40) {
223         // down key: go down one
224         newIndex = Math.min(currentIndex + 1, this.$.picker.controls.length - 1);
225         this.$.picker.setSelected(this.$.picker.controls[newIndex]);
226         this.itemSelected(); // TODO: only select item on blur
227       } else if (keyCode === 38) {
228         // up key: go up one
229         // looks like the minimum picker option we want to allow is at index 1 ("none"), and not
230         // the undefined-value backed index 0
231         newIndex = Math.max(currentIndex - 1, 1);
232         this.$.picker.setSelected(this.$.picker.controls[newIndex]);
233         this.itemSelected();
234       } else if (keyCode >= 65 && keyCode <= 90) {
235         // alpha keycode: find the first option that starts with that letter
236         newSelection = _.find(this.$.picker.$, function (control) {
237           return control.content.charCodeAt(0) === keyCode;
238         });
239         if (newSelection) {
240           this.$.picker.setSelected(newSelection);
241           this.itemSelected();
242         }
243       }
244
245     },
246     /**
247      @todo Document the noneTextChanged method.
248      */
249     noneTextChanged: function () {
250       var noneText = this.getNoneText(),
251       button = this.$.pickerButton;
252       if (!this.value) {
253         button.setContent(noneText);
254       }
255       this.buildList();
256     },
257     /**
258      @todo Document the noneClassesChanged method.
259      */
260     noneClassesChanged: function () {
261       this.buildList();
262     },
263     /**
264      @todo Document the orderByChanged method.
265      */
266     orderByChanged: function () {
267       var orderBy = this.getOrderBy();
268       if (this._collection && orderBy) {
269         this._collection.comparator = function (a, b) {
270           var aval,
271             bval,
272             aValue,
273             bValue,
274             attr,
275             i;
276           for (i = 0; i < orderBy.length; i++) {
277             attr = orderBy[i].attribute;
278             // Add support for Backbone.Models in static.js
279             aValue = a.getValue ? a.getValue(attr) : a.get(attr);
280             bValue = b.getValue ? b.getValue(attr) : b.get(attr);
281             aval = orderBy[i].descending ? bValue : aValue;
282             bval = orderBy[i].descending ? aValue : bValue;
283             // Bad hack for null 'order' values
284             if (attr === "order" && !_.isNumber(aval)) { aval = 9999; }
285             if (attr === "order" && !_.isNumber(bval)) { bval = 9999; }
286             aval = !isNaN(aval) ? aval - 0 : aval;
287             bval = !isNaN(aval) ? bval - 0 : bval;
288             if (aval !== bval) {
289               return aval > bval ? 1 : -1;
290             }
291           }
292
293           return 0;
294         };
295       }
296     },
297     /**
298      @todo Document the select method.
299      */
300     select: function (index) {
301       var i = 0,
302         component = _.find(this.$.picker.getComponents(), function (c) {
303           if (c.kind === "onyx.MenuItem") { i++; }
304           return i > index;
305         });
306       if (component) {
307         this.setValue(component.value);
308       }
309     },
310
311     selectValue: function (value) {
312       var coll = this._collection,
313         key = this.idAttribute ||
314           (coll && coll.model ? coll.model.prototype.idAttribute : false),
315         components = this.$.picker.getComponents(),
316         component,
317         ret;
318
319       ret = value && key ? value.get(key) : value;
320       component = _.find(components, function (c) {
321         if (c.kind === "XV.PickerMenuItem") {
322           return (c.value ? c.value.get(key) : null) === ret;
323         }
324       });
325
326       if (!component) {
327         ret = null;
328         this.$.picker.setSelected(null);
329         this.$.pickerButton.setContent("_none".loc());
330       } else {
331         this.$.picker.setSelected(component);
332       }
333
334       return ret;
335     },
336
337     /**
338       Programatically sets the value of this widget.
339
340       Value can be a model or the id of a model (String or Number).
341       If it is an ID, then the correct model will be fetched and this
342       function will be called again recursively with the model.
343
344       The value passed can also be an object with two properties:
345       "collection" and "value". If this is passed the collection will
346       be set to the passed collection, and the value will be set to "value."
347
348       @param {Number|XM.Model|Object}
349       @param {Object} options
350      */
351     setValue: function (value, options) {
352       options = options || {};
353       var key = this.idAttribute || (this._collection && this._collection.model ?
354         this._collection.model.prototype.idAttribute : null),
355         oldValue = this.getValue(),
356         attr = this.getAttr(),
357         actualMenuItem,
358         actualModel,
359         inEvent,
360         selectedValue;
361
362       // here is where we find the model and re-call this method if we're given
363       // an id instead of a whole model.
364       // note that we assume that all of the possible models are already
365       // populated in the menu items of the picker
366       // note: value may be a '0' value
367       if (key !== null && value !== null && typeof value !== 'object') {
368         actualMenuItem = _.find(this.$.picker.controls, function (menuItem) {
369           var ret = false;
370           if (menuItem.value && menuItem.value.get) {
371             ret = menuItem.value.get(key) === value;
372           } else if (menuItem.value) {
373             ret = menuItem.value[key] === value;
374           }
375           return ret;
376         });
377         if (actualMenuItem) {
378           // a menu item matches the selection. Use the model back backs the menu item
379           actualModel = actualMenuItem.value;
380           this.setValue(actualModel, options);
381         }
382         // (else "none" is selected and there's no need to do anything)
383         return;
384       }
385
386       // Handle when a collection is passed in as part of a two part argument
387       if (_.isObject(value) && !(value instanceof Backbone.Model)) {
388         if (value.collection && value.collection !== this._collection) {
389           this.setCollection(value.collection);
390         }
391         this.setValue(value.value, options);
392         return;
393       }
394
395       if (value !== oldValue) {
396         selectedValue = this.selectValue(value);
397
398         if (selectedValue !== oldValue) {
399           this.value = value;
400
401           if (!options.silent) {
402             if (_.isObject(attr)) {
403               inEvent = {value: {collection: this._collection}};
404               inEvent.value[attr.value] = selectedValue;
405             } else {
406               inEvent = {value: selectedValue};
407             }
408
409             this.doValueChange(inEvent);
410           }
411         }
412       }
413     },
414     /**
415      @todo Document the labelChanged method.
416      */
417     labelChanged: function () {
418       var attr = this.attr && _.isString(this.attr) ? this.attr : (this.attr ? this.attr.value : false),
419         label = this.getLabel() || (attr ? ("_" + attr).loc() : "");
420       this.$.label.setShowing(!!label);
421       this.$.label.setContent(label + ":");
422     },
423     /**
424      @todo Document the showLabelChanged method.
425      */
426     showLabelChanged: function () {
427       this.$.label.setShowing(this.showLabel);
428     }
429   });
430
431   /**
432     This is a subclass of the onyx.PickerButton that is used in the PickerDecorator.
433     The default behavior is that inside of the decorator, the first empty kind is
434     set to be a PickerButton. When the change event is fired, the content of this
435     button is changed to the content of the selection.
436   */
437   enyo.kind(
438     /** @lends XV.PickerButton */{
439     name: "XV.PickerButton",
440     kind: "onyx.PickerButton",
441     classes: "xv-picker-button",
442     components: [
443       {name: "text", content: "", classes: "picker-content"},
444       {tag: "i", classes: "icon-caret-down picker-icon"} // font-awesome icon
445     ],
446     create: function () {
447       this.inherited(arguments);
448       this.contentChanged();
449     },
450     /**
451       When the content is changed on the parent PickerButton,
452       this sets the content of the text component inside the button.
453     */
454     contentChanged: function () {
455       this.$.text.setContent(this.getContent());
456     }
457   });
458
459   /**
460     This is a subclass of the onyx.MenuItem that is used in the Picker's menu.
461     Like the XV.PickerButton, it allows for content and an icon. In this case, the
462     icon is optional and may be made invisible if included.
463   */
464   enyo.kind(
465     /** @lends XV.PickerButton */{
466     name: "XV.PickerMenuItem",
467     kind: "onyx.MenuItem",
468     classes: "xv-picker-button",
469     value: null,
470     published: {
471       iconClass: "",
472       iconVisible: null,
473       disabled: false
474     },
475     components: [
476       {name: "text", content: ""},
477       {name: "icon", tag: "i", classes: "icon-dark picker-icon"} // font-awesome icon
478     ],
479     create: function () {
480       this.inherited(arguments);
481       this.contentChanged();
482       this.iconVisibleChanged();
483       this.disabledChanged();
484     },
485     /**
486       When the content is changed on the parent MenuItem,
487       this sets the content of the text component inside the button.
488     */
489     contentChanged: function () {
490       this.$.text.setContent(this.getContent());
491     },
492     disabledChanged: function () {
493       this.addRemoveClass("disabled", this.disabled);
494     },
495     /**
496       If there is an icon class, we determine if it is
497       showing based on the iconVisible logic.
498     */
499     iconVisibleChanged: function () {
500       var showing = this.iconVisible;
501       if (_.isFunction(this.iconVisible)) {
502         showing = this.iconVisible(this.value);
503       }
504       this.$.icon.setShowing(showing);
505
506       // if the menu item is showing an icon,
507       // set the icon class for the menu item
508       // and also set the css style to not allow
509       // for text overlap
510       if (showing) {
511         this.$.icon.addClass(this.getIconClass());
512         this.$.text.addClass("picker-content");
513       } else {
514         this.$.icon.removeClass(this.getIconClass());
515         this.$.text.removeClass("picker-content");
516       }
517     },
518     tap: function (inSender) {
519       if (!this.disabled) { return this.inherited(arguments); }
520     }
521   });
522
523 }());