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