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