Merge pull request #1609 from xtuple/4_5_x
[xtuple] / lib / enyo-x / source / widgets / relation.js
1 /*jshint node:true, 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, XV:true, XM:true, Backbone:true, enyo:true, _:true, X:true */
4
5 (function () {
6
7   /**
8     @name XV.RelationWidget
9     @class A picker control that implements a dropdown list of items which can be selected.<br />
10     Unlike the {@link XV.PickerWidget}, the collection is not stored local to the widget.<br />
11     Derived from <a href="http://enyojs.com/api/#enyo.Control">enyo.Control</a>.
12     @extends enyo.Control
13    */
14   enyo.kind(
15     /** @lends XV.RelationWidget# */{
16     name: "XV.RelationWidget",
17     kind: enyo.Control,
18     classes: "xv-input xv-relationwidget",
19     published: {
20       attr: null,
21       label: "",
22       placeholder: "",
23       value: null,
24       list: "",
25       collection: "",
26       disabled: false,
27       /**
28         This can be a string or an array. If an array, all attributes
29         will be searched, the first will be the value used in the widget.
30       */
31       keyAttribute: "number",
32       sidecarAttribute: "",
33       nameAttribute: "name",
34       descripAttribute: "",
35       additionalAttribute: "",
36       // some widgets do not want to filter the completer based on the text of the input
37       skipCompleterFilter: false,
38       query: null, // whatever the subkind wants it to be,
39       keySearch: false // Attach a  misc special parameter that can used by the server
40     },
41     events: {
42       onSearch: "",
43       onValueChange: "",
44       onWorkspace: ""
45     },
46     handlers: {
47       onModelChange: "modelChanged",
48       onSelect: "itemSelected"
49     },
50     components: [
51       {kind: "FittableColumns", components: [
52         {name: "label", content: "", fit: true, classes: "xv-flexible-label"},
53         // TODO: Put the InputDecorator and description in a FittableRows
54         {kind: "onyx.InputDecorator", name: "decorator",
55           classes: "xv-input-decorator", components: [
56           {name: 'input', kind: "onyx.Input", classes: "xv-subinput",
57             onkeyup: "keyUp", onkeydown: "keyDown", onblur: "receiveBlur",
58             onfocus: "receiveFocus", fit: true
59           },
60           {kind: "onyx.MenuDecorator", components: [
61             {kind: "onyx.IconButton", classes: "icon-folder-open-alt"},
62             {name: 'popupMenu', floating: true, kind: "onyx.Menu",
63               components: [
64               {kind: "XV.MenuItem", name: 'searchItem', content: "_search".loc()},
65               {kind: "XV.MenuItem", name: 'openItem', content: "_open".loc(),
66                 disabled: true},
67               {kind: "XV.MenuItem", name: 'newItem', content: "_new".loc(),
68                 disabled: true}
69             ]}
70           ]},
71           {name: "completer", kind: "XV.Completer"}
72         ]}
73       ]},
74       {name: "name", classes: "xv-relationwidget-description"},
75       {name: "description", classes: "xv-relationwidget-description xv-relationwidget-secondarydescription"}
76     ],
77     /**
78       Add a parameter to the query object on the widget. Parameter conventions should
79       follow those described in the documentation for `XM.Collection`.
80
81       @seealso XM.Collection
82       @param {Object} Parameter
83       @param {Boolean} Editable - default false. If true user can edit parameter selection in search.
84       @returns {Object} Receiver
85     */
86     addParameter: function (param, editable) {
87       var query = this.getQuery() || {},
88         attr = param.attribute;
89
90       // Update the query with new parameter
91       if (query.parameters) {
92         query.parameters = _.filter(query.parameters, function (p) {
93           return !_.isEqual(p.attribute, attr);
94         });
95       } else { query.parameters = []; }
96       query.parameters.push(param);
97       this.setQuery(query);
98
99       // Keep track of editable parameters
100       if (!editable) {
101         this._editableParams = _.without(this._editableParams, attr);
102       } else if (!_.contains(this._editableParams, attr)) {
103         this._editableParams.push(attr);
104       }
105       return this;
106     },
107     /**
108       Fill the value with the selected choice.
109      */
110     autocomplete: function (callback) {
111       var key = this.getKeyAttribute(),
112         value = this.getValue(),
113         inputValue = this.$.input.getValue(),
114         changed,
115         attrs = [];
116       if (_.isString(key)) {
117         attrs = value ? value.get(key) : "";
118         changed = inputValue !== attrs;
119       } else {
120         // key must be array. Handle it.
121         _.each(key, function (str) {
122           var attr = value ? value.get(str) : "";
123           attrs.push(attr);
124         });
125         // if changed or inputValue doesn't match an attr
126         changed = changed || !(_.find(attrs, function (attr) {return inputValue === attr; }));
127       }
128
129       if (inputValue && changed) {
130         this.fetchCollection(inputValue, 1, "_fetchSuccess");
131       } else if (!inputValue) {
132         this.setValue(null);
133       }
134     },
135     /**
136       Empty out the widget
137      */
138     clear: function (options) {
139       this.setValue(null, options);
140     },
141     create: function () {
142       this.inherited(arguments);
143       this._editableParams = [];
144       this.listChanged();
145       this.labelChanged();
146       this.disabledChanged();
147     },
148     /**
149      @todo Document the disabledChanged method.
150      */
151     disabledChanged: function () {
152       var disabled = this.getDisabled();
153       this.$.input.setDisabled(disabled);
154       this.$.searchItem.setDisabled(disabled);
155       this.$.newItem.setDisabled(_couldNotCreate.apply(this) || disabled);
156       this.$.name.addRemoveClass("disabled", disabled);
157       this.$.description.addRemoveClass("disabled", disabled);
158       this.$.label.addRemoveClass("disabled", disabled);
159     },
160     /**
161       Query the database.
162       Used by both autocomplete and keyup.
163      */
164     fetchCollection: function (value, rowLimit, callbackName) {
165       var key = this.getKeyAttribute(),
166         query = {},
167         param = {
168           attribute: key,
169           operator: "BEGINS_WITH",
170           value: value,
171           keySearch: this.keySearch
172         },
173         orderBy = [];
174
175
176       // selectively copy this.getQuery() into a new object. We can't
177       // use a dumb deep copy because the models in there might explode
178       if (this.getQuery()) {
179         if (this.getQuery().orderBy) {
180           query.orderBy = JSON.parse(JSON.stringify(this.getQuery().orderBy));
181         }
182         if (this.getQuery().parameters) {
183           query.parameters = [];
184           _.each(this.getQuery().parameters, function (param) {
185             query.parameters.push(param);
186           });
187         }
188       }
189
190       if (_.isString(key)) {
191         orderBy = [{attribute: key}];
192       } else {
193         // key must be array
194         _.each(key, function (attr) {
195           orderBy.push({attribute: attr});
196         });
197       }
198
199       // Add key search to our query
200       if (query.parameters) {
201         query.parameters.push(param);
202       } else {
203         query.parameters = [param];
204       }
205
206       // Add sort to our query
207       if (query.orderBy) {
208         query.orderBy = query.orderby.concat(orderBy);
209       } else {
210         query.orderBy = orderBy;
211       }
212
213       query.rowLimit = rowLimit || 10;
214
215       this._collection.fetch({
216         success: enyo.bind(this, callbackName),
217         query: query
218       });
219     },
220     /**
221      @todo Document the focus method.
222      */
223     focus: function () {
224       this.$.input.focus();
225     },
226     getFirstKey: function () {
227       var key = this.getKeyAttribute();
228       return _.isString(key) ? key : key[0];
229     },
230     /**
231      @todo Document the getValueToString method.
232      */
233     getValueToString: function () {
234       return this.value.getValue(this.getFirstKey());
235     },
236     /**
237      @todo Revisit or remove after ENYO-1104 is resolved.
238      */
239     keyDown: function (inSender, inEvent) {
240       this.inherited(arguments);
241
242       // If tabbed out...
243       inEvent.activator = this.$.decorator;
244       if (inEvent.keyCode === 9) {
245         this.$.completer.waterfall("onRequestHideMenu", inEvent);
246         this.autocomplete();
247       }
248     },
249     /**
250       We will typically want to query the database upon every keystroke
251      */
252     keyUp: function (inSender, inEvent) {
253       var key = this.getFirstKey(),
254         attr = this.getValue() ? this.getValue().get(key) : "",
255         value = this.$.input.getValue(),
256         completer = this.$.completer;
257
258       inEvent.activator = this.$.decorator;
259
260       // Look up if value changed
261       if (value && value !== attr && inEvent.keyCode !== 9) {
262         this.fetchCollection(value, 10, "_collectionFetchSuccess");
263       } else {
264         completer.waterfall("onRequestHideMenu", inEvent);
265       }
266     },
267     /**
268      @todo Document the itemSelected method.
269      */
270     itemSelected: function (inSender, inEvent) {
271       if (inEvent.originator.kind === 'onyx.MenuItem') {
272         this.relationSelected(inSender, inEvent);
273       } else {
274         this.menuItemSelected(inSender, inEvent);
275       }
276       return true;
277     },
278     /**
279      @todo Document the labelChanged method.
280      */
281     labelChanged: function () {
282       var label = (this.getLabel() || ("_" + this.attr || "").loc());
283       this.$.label.setContent(label + ":");
284     },
285     /**
286      @todo Document the listChanged method.
287      */
288     listChanged: function () {
289       var list = this.getList(),
290         Collection,
291         workspace;
292       delete this._List;
293       delete this._Workspace;
294
295       // Get List class
296       if (!list) { return; }
297       this._List = XT.getObjectByName(list);
298
299       // Get Workspace class
300       workspace = this._List.prototype.getWorkspace();
301       this._Workspace = workspace ? XT.getObjectByName(workspace) : null;
302
303       // Setup collection instance
304       Collection = this.getCollection() ?
305         XT.getObjectByName(this.getCollection()) : null;
306       if (!Collection) { return; }
307       this._collection = new Collection();
308     },
309     /**
310      @todo Document the menuItemSelected method.
311      */
312     menuItemSelected: function (inSender, inEvent) {
313       var that = this,
314         menuItem = inEvent.originator,
315         list = this.getList(),
316         model = this.getValue(),
317         id = model ? model.id : null,
318         workspace = this._List ? this._List.prototype.getWorkspace() : null,
319         query = this.getQuery() || {},
320         params = [],
321         callback,
322         conditions = [];
323
324       // Convert query to parameter widget friendly
325       _.each(query.parameters, function (param) {
326         // If attribute isn't explicitly set to `editable` then hide any matching control
327         // and pass as fixed condition.
328         if (!_.contains(that._editableParams, param.attribute)) {
329           params.push({name: param.attribute, showing: false});
330           conditions.push(param);
331         // Otherwise user can change value so pass default
332         } else {
333           params.push({name: param.attribute, value: param.value});
334         }
335       });
336
337       switch (menuItem.name)
338       {
339       case 'searchItem':
340         callback = function (value) {
341           that.setValue(value);
342         };
343         this.doSearch({
344           list: list,
345           callback: callback,
346           parameterItemValues: params,
347           conditions: conditions,
348           query: this.getQuery()
349         });
350         break;
351       case 'openItem':
352         this.doWorkspace({
353           workspace: workspace,
354           id: id,
355           allowNew: false
356         });
357         break;
358       case 'newItem':
359         // Callback options on commit of the workspace
360         // Find the model with matching id, fetch and set it.
361         callback = function (model) {
362           if (!model) { return; }
363           var Model = that._collection.model,
364             attrs = {},
365             value,
366             options = {};
367           options.success = function () {
368             that.setValue(value);
369           };
370           attrs[Model.prototype.idAttribute] = model.id;
371           value = Model.findOrCreate(attrs);
372           value.fetch(options);
373         };
374         this.doWorkspace({
375           workspace: workspace,
376           callback: callback,
377           allowNew: false
378         });
379         break;
380       }
381     },
382     /**
383      @todo Document the modelChanged method.
384      */
385     modelChanged: function (inSender, inEvent) {
386       var that = this,
387         List = this._List,
388         workspace = List.prototype.getWorkspace(),
389         Workspace = workspace ? XT.getObjectByName(workspace) : null,
390         options = { };
391       // If the model that changed was related to and exists on this widget
392       // refresh the local model.
393       if (!List || !Workspace || !this.value) {
394         return;
395       }
396       if (Workspace.prototype.model === inEvent.model &&
397         this.value.id === inEvent.id) {
398         options.success = function () {
399           that.setValue(this.value);
400         };
401         this.value.fetch(options);
402       }
403     },
404     /**
405       When we get a placeholder we want to actually display it.
406      */
407     placeholderChanged: function () {
408       var placeholder = this.getPlaceholder();
409       this.$.input.setPlaceholder(placeholder);
410     },
411     /**
412      @todo Document the receiveBlur method.
413      */
414     receiveBlur: function (inSender, inEvent) {
415       this.autocomplete();
416       this._hasFocus = false;
417     },
418     /**
419      @todo Document the receiveFocus method.
420      */
421     receiveFocus: function (inSender, inEvent) {
422       this._hasFocus = true;
423       this._relationSelected = false;
424     },
425     /**
426      @todo Document the relationSelected method.
427      */
428     relationSelected: function (inSender, inEvent) {
429       this._relationSelected = true;
430       inEvent.activator = this.$.decorator;
431       this.setValue(inEvent.originator.model);
432       this.$.completer.waterfall("onRequestHideMenu", inEvent);
433       return true;
434     },
435     /**
436       Removes a query parameter by attribute name from the widget's query object.
437
438       @param {String} Attribute
439       @returns {Object} Receiver
440     */
441     removeParameter: function (attr) {
442       var query = this.getQuery();
443       if (query && query.parameters) {
444         query.parameters = _.filter(query.parameters, function (param) {
445           return param.attribute !== attr;
446         });
447       }
448       this.setQuery(query);
449       this._editableParams = _.without(this._editableParams, attr);
450       return this;
451     },
452
453     /**
454       Programatically sets the value of this widget. If an id is set then the
455       correct model will be fetched and this function will be called again
456       recursively with the model.
457
458       *In case of "order" models, each order's workspace is different depending on the order type.
459       This causes the Workspace var to be null so get the value from the getWorkspace
460       defined on the relation.js sub-kind.
461
462       @param {XM.Model|Number|String} Value; can be a model or the id of a model.
463       @param {Object} options
464      */
465     setValue: function (value, options) {
466       options = options || {};
467       var that = this,
468         newId = value ? value.id : null,
469         oldId = this.value ? this.value.id : null,
470         key = this.getFirstKey(),
471         name = this.getNameAttribute(),
472         descrip = this.getDescripAttribute(),
473         additional = this.getAdditionalAttribute(),
474         inEvent = { value: value, originator: this },
475         keyValue = "",
476         nameValue = "",
477         descripValue = "",
478         additionalValue = "",
479         Workspace = this._Workspace || (newId ? this._List.prototype.getWorkspace(value) : null),
480         Model = this._collection.model,
481         id,
482         newValue,
483         listName = this.getList(),
484         List,
485         ListModel,
486         CollectionName,
487         Collection,
488         setPrivileges = function () {
489           if (value && newId) {
490             if (value.couldRead) {
491               that.$.openItem.setDisabled(!value.couldRead());
492             } else {
493               that.$.openItem.setDisabled(!value.getClass().canRead());
494             }
495           }
496         };
497
498       if (listName) {
499         List = XT.getObjectByName(listName);
500         CollectionName = List.prototype.collection;
501         Collection = XT.getObjectByName(CollectionName);
502         ListModel = Collection.prototype.model;
503       }
504
505       // Make sure we get are setting the right kind of object here.
506       // If we're being sent a number or a string, or something that's not a model
507       // then we assume that's the key, we fetch the model, and call this function
508       // again and give it the model for real.
509       // Of course, if the value is falsy, we don't want to fetch a model,
510       // we just want to continue (which will empty out the relation widget)
511       if (value && (_.isNumber(value) || _.isString(value) || !(value instanceof Model))) {
512         if (this.value === value || oldId === value) { return; }
513
514         // If we got here, but it is not the list's model type, we can't work with this
515         if (!ListModel || (value instanceof XM.Model && !(value instanceof ListModel))) {
516           throw "The type of model passed is incompatible with this widget";
517         }
518
519         id = _.isObject(value) ? value.id : value;
520
521         newValue = new Model();
522         options = {
523           id: id,
524           success: function () {
525             that.setValue(newValue);
526           },
527           error: function () {
528             XT.log("Error setting relational widget value");
529           }
530         };
531         newValue.fetch(options);
532         return;
533       }
534
535       this.value = value;
536       if (value && value.getValue) {
537         keyValue = value.getValue(key) || "";
538         nameValue = value.getValue(name) || "";
539         descripValue = value.getValue(descrip) || "";
540         additionalValue = value.getValue(additional) || "";
541       }
542       this.$.input.setValue(keyValue);
543       this.$.name.setShowing(nameValue);
544       this.$.name.setContent(nameValue);
545       this.$.description.setShowing(descripValue);
546       this.$.description.setContent(descripValue);
547       //this.$.additionalInfo.setShowing(additionalValue);
548       //this.$.additionalInfo.setContent(additionalValue);
549
550       // Only notify if selection actually changed
551       if (newId !== oldId && !options.silent) { this.doValueChange(inEvent); }
552
553       // Handle menu actions
554       that.$.openItem.setShowing(Workspace);
555       that.$.newItem.setShowing(Workspace);
556       that.$.openItem.setDisabled(true);
557       that.$.newItem.setDisabled(_couldNotCreate.apply(this) || this.disabled);
558       if (Model && Workspace) {
559         if (XT.session) {
560           setPrivileges();
561         } else {
562           XT.getStartupManager().registerCallback(setPrivileges);
563         }
564       }
565     },
566
567     /** @private */
568     _collectionFetchSuccess: function () {
569       if (!this._hasFocus) { return; }
570       var key = this.getKeyAttribute(),
571         sidecarKey = this.getSidecarAttribute(),
572         value = this.$.input.getValue(),
573         models = this._collection.models,
574         inEvent = { activator: this.$.decorator };
575       if (models.length) {
576         this.$.completer.buildList(key, value, models, sidecarKey, this.skipCompleterFilter);
577         if (!this.$.completer.showing) {
578           this.$.completer.waterfall("onRequestShowMenu", inEvent);
579         }
580         this.$.completer.adjustPosition();
581       } else {
582         this.$.completer.waterfall("onRequestHideMenu", inEvent);
583       }
584     },
585
586     /** @private */
587     _fetchSuccess: function () {
588       if (this._relationSelected) { return; }
589       var value = this._collection.length ? this._collection.models[0] : null,
590         target = enyo.dispatcher.captureTarget;
591       this.setValue(value);
592       enyo.dispatcher.captureTarget = target;
593     }
594
595   });
596
597   /** @private */
598   var _couldNotCreate = function () {
599     var Workspace = this._Workspace,
600       Model = this._collection.model,
601       couldNotCreate = true;
602
603     if (Model && Model.couldCreate) {
604       // model is a list item or relation
605       couldNotCreate = !Model.couldCreate();
606     } else if (Model) {
607       // model is a first-class model
608       couldNotCreate = !Model.canCreate();
609     }
610     return couldNotCreate;
611   };
612
613 }());