d049198a9e99bc6b904722c5ef9da034d221c2ee
[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       {name: 'descriptionContainer', classes: 'xv-invisible-wrapper', components: [
70         {controlClasses: 'enyo-inline', components: [
71           {name: "name", classes: "xv-description"},
72           {name: "description", classes: "xv-description"}
73         ]}
74       ]}
75     ],
76     /**
77       Add a parameter to the query object on the widget. Parameter conventions should
78       follow those described in the documentation for `XM.Collection`.
79
80       @seealso XM.Collection
81       @param {Object} Parameter
82       @param {Boolean} Editable - default false. If true user can edit parameter selection in search.
83       @returns {Object} Receiver
84     */
85     addParameter: function (param, editable) {
86       var query = this.getQuery() || {},
87         attr = param.attribute;
88
89       // Update the query with new parameter
90       if (query.parameters) {
91         query.parameters = _.filter(query.parameters, function (p) {
92           return !_.isEqual(p.attribute, attr);
93         });
94       } else { query.parameters = []; }
95       query.parameters.push(param);
96       this.setQuery(query);
97
98       // Keep track of editable parameters
99       if (!editable) {
100         this._editableParams = _.without(this._editableParams, attr);
101       } else if (!_.contains(this._editableParams, attr)) {
102         this._editableParams.push(attr);
103       }
104       return this;
105     },
106     /**
107       Fill the value with the selected choice.
108      */
109     autocomplete: function (callback) {
110       var key = this.getKeyAttribute(),
111         value = this.getValue(),
112         inputValue = this.$.input.getValue(),
113         changed,
114         attrs = [];
115       if (_.isString(key)) {
116         attrs = value ? value.get(key) : "";
117         changed = inputValue !== attrs;
118       } else {
119         // key must be array. Handle it.
120         _.each(key, function (str) {
121           var attr = value ? value.get(str) : "";
122           attrs.push(attr);
123         });
124         // if changed or inputValue doesn't match an attr
125         changed = changed || !(_.find(attrs, function (attr) {return inputValue === attr; }));
126       }
127
128       if (inputValue && changed) {
129         this.fetchCollection(inputValue, 1, "_fetchSuccess");
130       } else if (!inputValue) {
131         this.setValue(null);
132       }
133     },
134     /**
135       Empty out the widget
136      */
137     clear: function (options) {
138       this.setValue(null, options);
139     },
140     create: function () {
141       this.inherited(arguments);
142       this._editableParams = [];
143       this.listChanged();
144       this.labelChanged();
145       this.disabledChanged();
146     },
147     /**
148      @todo Document the disabledChanged method.
149      */
150     disabledChanged: function () {
151       var disabled = this.getDisabled();
152       this.$.input.setDisabled(disabled);
153       this.$.searchItem.setDisabled(disabled);
154       this.$.newItem.setDisabled(_couldNotCreate.apply(this) || disabled);
155       this.$.label.addRemoveClass("disabled", disabled);
156
157       _.each(this.$.descriptionContainer.$, function (component) {
158         component.addRemoveClass("disabled", disabled);
159       }, this);
160     },
161     /**
162       Query the database.
163       Used by both autocomplete and keyup.
164      */
165     fetchCollection: function (value, rowLimit, callbackName) {
166       var key = this.getKeyAttribute(),
167         query = {},
168         param = {
169           attribute: key,
170           operator: "BEGINS_WITH",
171           value: value,
172           keySearch: this.keySearch
173         },
174         orderBy = [];
175
176
177       // selectively copy this.getQuery() into a new object. We can't
178       // use a dumb deep copy because the models in there might explode
179       if (this.getQuery()) {
180         if (this.getQuery().orderBy) {
181           query.orderBy = JSON.parse(JSON.stringify(this.getQuery().orderBy));
182         }
183         if (this.getQuery().parameters) {
184           query.parameters = [];
185           _.each(this.getQuery().parameters, function (param) {
186             query.parameters.push(param);
187           });
188         }
189       }
190
191       if (_.isString(key)) {
192         orderBy = [{attribute: key}];
193       } else {
194         // key must be array
195         _.each(key, function (attr) {
196           orderBy.push({attribute: attr});
197         });
198       }
199
200       // Add key search to our query
201       if (query.parameters) {
202         query.parameters.push(param);
203       } else {
204         query.parameters = [param];
205       }
206
207       // Add sort to our query
208       if (query.orderBy) {
209         query.orderBy = query.orderby.concat(orderBy);
210       } else {
211         query.orderBy = orderBy;
212       }
213
214       query.rowLimit = rowLimit || 10;
215
216       this._collection.fetch({
217         success: enyo.bind(this, callbackName),
218         query: query
219       });
220     },
221     /**
222      @todo Document the focus method.
223      */
224     focus: function () {
225       this.$.input.focus();
226     },
227     getFirstKey: function () {
228       var key = this.getKeyAttribute();
229       return _.isString(key) ? key : key[0];
230     },
231     /**
232      @todo Document the getValueToString method.
233      */
234     getValueToString: function () {
235       return this.value.getValue(this.getFirstKey());
236     },
237     /**
238      @todo Revisit or remove after ENYO-1104 is resolved.
239      */
240     keyDown: function (inSender, inEvent) {
241       this.inherited(arguments);
242
243       // If tabbed out...
244       inEvent.activator = this.$.decorator;
245       if (inEvent.keyCode === 9) {
246         this.$.completer.waterfall("onRequestHideMenu", inEvent);
247         this.autocomplete();
248       }
249     },
250     /**
251       We will typically want to query the database upon every keystroke
252      */
253     keyUp: function (inSender, inEvent) {
254       var key = this.getFirstKey(),
255         attr = this.getValue() ? this.getValue().get(key) : "",
256         value = this.$.input.getValue(),
257         completer = this.$.completer;
258
259       inEvent.activator = this.$.decorator;
260
261       // Look up if value changed
262       if (value && value !== attr && inEvent.keyCode !== 9) {
263         this.fetchCollection(value, 10, "_collectionFetchSuccess");
264       } else {
265         completer.waterfall("onRequestHideMenu", inEvent);
266       }
267     },
268     /**
269      @todo Document the itemSelected method.
270      */
271     itemSelected: function (inSender, inEvent) {
272       if (inEvent.originator.kind === 'onyx.MenuItem') {
273         this.relationSelected(inSender, inEvent);
274       } else {
275         this.menuItemSelected(inSender, inEvent);
276       }
277       return true;
278     },
279     /**
280      @todo Document the labelChanged method.
281      */
282     labelChanged: function () {
283       var label = (this.getLabel() || ("_" + this.attr || "").loc());
284       this.$.label.setContent(label + ":");
285     },
286     /**
287      @todo Document the listChanged method.
288      */
289     listChanged: function () {
290       var list = this.getList(),
291         Collection,
292         workspace;
293       delete this._List;
294       delete this._Workspace;
295
296       // Get List class
297       if (!list) { return; }
298       this._List = XT.getObjectByName(list);
299
300       // Get Workspace class
301       workspace = this._List.prototype.getWorkspace();
302       this._Workspace = workspace ? XT.getObjectByName(workspace) : null;
303
304       // Setup collection instance
305       Collection = this.getCollection() ?
306         XT.getObjectByName(this.getCollection()) : null;
307       if (!Collection) { return; }
308       this._collection = new Collection();
309     },
310     /**
311      @todo Document the menuItemSelected method.
312      */
313     menuItemSelected: function (inSender, inEvent) {
314       var that = this,
315         menuItem = inEvent.originator,
316         list = this.getList(),
317         model = this.getValue(),
318         id = model ? model.id : null,
319         workspace = this._List ? this._List.prototype.getWorkspace() : null,
320         query = this.getQuery() || {},
321         params = [],
322         callback,
323         conditions = [];
324
325       // Convert query to parameter widget friendly
326       _.each(query.parameters, function (param) {
327         // If attribute isn't explicitly set to `editable` then hide any matching control
328         // and pass as fixed condition.
329         if (!_.contains(that._editableParams, param.attribute)) {
330           params.push({name: param.attribute, showing: false});
331           conditions.push(param);
332         // Otherwise user can change value so pass default
333         } else {
334           params.push({name: param.attribute, value: param.value});
335         }
336       });
337
338       switch (menuItem.name)
339       {
340       case 'searchItem':
341         callback = function (value) {
342           that.setValue(value);
343         };
344         this.doSearch({
345           list: list,
346           callback: callback,
347           parameterItemValues: params,
348           conditions: conditions,
349           query: this.getQuery()
350         });
351         break;
352       case 'openItem':
353         this.doWorkspace({
354           workspace: workspace,
355           id: id,
356           allowNew: false
357         });
358         break;
359       case 'newItem':
360         // Callback options on commit of the workspace
361         // Find the model with matching id, fetch and set it.
362         callback = function (model) {
363           if (!model) { return; }
364           var Model = that._collection.model,
365             attrs = {},
366             value,
367             options = {};
368           options.success = function () {
369             that.setValue(value);
370           };
371           attrs[Model.prototype.idAttribute] = model.id;
372           value = Model.findOrCreate(attrs);
373           value.fetch(options);
374         };
375         this.doWorkspace({
376           workspace: workspace,
377           callback: callback,
378           allowNew: false
379         });
380         break;
381       }
382     },
383     /**
384      @todo Document the modelChanged method.
385      */
386     modelChanged: function (inSender, inEvent) {
387       var that = this,
388         List = this._List,
389         workspace = List.prototype.getWorkspace(),
390         Workspace = workspace ? XT.getObjectByName(workspace) : null,
391         options = { };
392       // If the model that changed was related to and exists on this widget
393       // refresh the local model.
394       if (!List || !Workspace || !this.value) {
395         return;
396       }
397       if (Workspace.prototype.model === inEvent.model &&
398         this.value.id === inEvent.id) {
399         options.success = function () {
400           that.setValue(this.value);
401         };
402         this.value.fetch(options);
403       }
404     },
405     /**
406       When we get a placeholder we want to actually display it.
407      */
408     placeholderChanged: function () {
409       var placeholder = this.getPlaceholder();
410       this.$.input.setPlaceholder(placeholder);
411     },
412     /**
413      @todo Document the receiveBlur method.
414      */
415     receiveBlur: function (inSender, inEvent) {
416       this.autocomplete();
417       this._hasFocus = false;
418     },
419     /**
420      @todo Document the receiveFocus method.
421      */
422     receiveFocus: function (inSender, inEvent) {
423       this._hasFocus = true;
424       this._relationSelected = false;
425     },
426     /**
427      @todo Document the relationSelected method.
428      */
429     relationSelected: function (inSender, inEvent) {
430       this._relationSelected = true;
431       inEvent.activator = this.$.decorator;
432       this.setValue(inEvent.originator.model);
433       this.$.completer.waterfall("onRequestHideMenu", inEvent);
434       return true;
435     },
436     /**
437       Removes a query parameter by attribute name from the widget's query object.
438
439       @param {String} Attribute
440       @returns {Object} Receiver
441     */
442     removeParameter: function (attr) {
443       var query = this.getQuery();
444       if (query && query.parameters) {
445         query.parameters = _.filter(query.parameters, function (param) {
446           return param.attribute !== attr;
447         });
448       }
449       this.setQuery(query);
450       this._editableParams = _.without(this._editableParams, attr);
451       return this;
452     },
453
454     /**
455       Programatically sets the value of this widget. If an id is set then the
456       correct model will be fetched and this function will be called again
457       recursively with the model.
458
459       *In case of "order" models, each order's workspace is different depending on the order type.
460       This causes the Workspace var to be null so get the value from the getWorkspace
461       defined on the relation.js sub-kind.
462
463       @param {XM.Model|Number|String} Value; can be a model or the id of a model.
464       @param {Object} options
465      */
466     setValue: function (value, options) {
467       options = options || {};
468       var that = this,
469         newId = value ? value.id : null,
470         oldId = this.value ? this.value.id : null,
471         key = this.getFirstKey(),
472         name = this.getNameAttribute(),
473         descrip = this.getDescripAttribute(),
474         additional = this.getAdditionalAttribute(),
475         inEvent = { value: value, originator: this },
476         keyValue = "",
477         nameValue = "",
478         descripValue = "",
479         additionalValue = "",
480         Workspace = this._Workspace || (newId ? this._List.prototype.getWorkspace(value) : null),
481         Model = this._collection.model,
482         id,
483         newValue,
484         listName = this.getList(),
485         List,
486         ListModel,
487         CollectionName,
488         Collection,
489         setPrivileges = function () {
490           if (value && newId) {
491             if (value.couldRead) {
492               that.$.openItem.setDisabled(!value.couldRead());
493             } else {
494               that.$.openItem.setDisabled(!value.getClass().canRead());
495             }
496           }
497         };
498
499       if (listName) {
500         List = XT.getObjectByName(listName);
501         CollectionName = List.prototype.collection;
502         Collection = XT.getObjectByName(CollectionName);
503         ListModel = Collection.prototype.model;
504       }
505
506       // Make sure we get are setting the right kind of object here.
507       // If we're being sent a number or a string, or something that's not a model
508       // then we assume that's the key, we fetch the model, and call this function
509       // again and give it the model for real.
510       // Of course, if the value is falsy, we don't want to fetch a model,
511       // we just want to continue (which will empty out the relation widget)
512       if (value && (_.isNumber(value) || _.isString(value) || !(value instanceof Model))) {
513         if (this.value === value || oldId === value) { return; }
514
515         // If we got here, but it is not the list's model type, we can't work with this
516         if (!ListModel || (value instanceof XM.Model && !(value instanceof ListModel))) {
517           throw "The type of model passed is incompatible with this widget";
518         }
519
520         id = _.isObject(value) ? value.id : value;
521
522         newValue = new Model();
523         options = {
524           id: id,
525           success: function () {
526             that.setValue(newValue);
527           },
528           error: function () {
529             XT.log("Error setting relational widget value");
530           }
531         };
532         newValue.fetch(options);
533         return;
534       }
535
536       this.value = value;
537       if (value && value.getValue) {
538         keyValue = value.getValue(key) || "";
539         nameValue = value.getValue(name) || "";
540         descripValue = value.getValue(descrip) || "";
541         additionalValue = value.getValue(additional) || "";
542       }
543       this.$.input.setValue(keyValue);
544
545       this.$.name.setShowing(nameValue);
546       this.$.name.setContent(nameValue);
547       this.$.description.setShowing(descripValue);
548       this.$.description.setContent(descripValue);
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 }());