Merge pull request #1843 from xtuple/4_6_x
[xtuple] / lib / enyo-x / source / views / grid_box.js
1   /*jshint bitwise:false, indent:2, curly:true, eqeqeq:true, immed:true,
2 latedef:true, newcap:true, noarg:true, regexp:true, undef:true,
3 trailing:true, white:true, strict: false*/
4 /*global XT:true, XM:true, XV:true, _:true, enyo:true, Globalize:true*/
5
6 (function () {
7   var _events = "change readOnlyChange statusChange",
8     _readOnlyHeight = 57;
9
10   // TODO: picker onblur
11   // http://forums.enyojs.com/discussion/1578/picker-onblur#latest
12
13   /**
14     @name XV.GridAttr
15     @class Holds the data within the cell of a row of a grid.<br />
16   */
17   enyo.kind(/** @lends XV.GridAttr# */{
18     name: "XV.GridAttr",
19     classes: "xv-grid-attr",
20     published: {
21       attr: "",
22       isKey: false,
23       isLayout: false,
24       placeholder: null
25     }
26   });
27
28   /**
29     @name XV.Column
30     @class Represents a column within the read only section of a grid.
31   */
32   enyo.kind(/** @lends XV.Column# */{
33     name: "XV.GridColumn",
34     classes: "xv-grid-column"
35   });
36
37   /**
38     @name XV.GridRow
39     @class Represents a row within the read only section of a grid.
40
41   */
42   var readOnlyRow = enyo.mixin(/** @lends XV.GridRow# */{
43     name: "XV.GridRow",
44     classes: "xv-grid-row readonly",
45     published: {
46       value: null,
47       columns: null
48     },
49
50     create: function () {
51       this.inherited(arguments);
52       this.columnsChanged();
53     },
54
55     columnsChanged: function () {
56       this.inherited(arguments);
57       var columns = this.getColumns() || [],
58         that = this;
59       _.each(columns, function (column) {
60         var components = [];
61
62         // Build component array
63         _.each(column.rows, function (row) {
64           components.push({
65             kind: "XV.GridAttr",
66             attr: row.readOnlyAttr || row.attr,
67             formatter: row.formatter,
68             placeholder: row.placeholder
69           });
70         });
71
72         that.createComponent({
73           kind: "XV.GridColumn",
74           classes: column.classes || "grid-item",
75           components: components
76         });
77       });
78     },
79     setValue: function (value) {
80       this.value = value;
81       this.valueChanged();
82     },
83     valueChanged: function () {
84       var model = this.getValue(),
85         that = this,
86         views = _.filter(this.$, function (view) {
87           return view.attr || view.formatter;
88         }),
89         handled = [];
90
91       if (!model) { return; }
92
93       // Loop through each view matched to an attribute and set the value
94       _.each(views, function (view) {
95         var prop = view.attr,
96           attr = prop.indexOf(".") > -1 ? prop.prefix() : prop,
97           isRequired = _.contains(model.requiredAttributes, attr),
98           value = model.getValue(view.attr),
99           type = model.getType(view.attr),
100           isNothing = _.isUndefined(value) || _.isNull(value) || value === "",
101           isError = isNothing && isRequired,
102           isPlaceholder = false;
103
104         // Set this first in case other formatting sets error
105         view.addRemoveClass("error", isError);
106
107         // Show "required" placeholder if necessary,
108         // but only once per top level attribute
109         if (isError) {
110           if (!_.contains(handled, attr)) {
111             value = _.contains(handled, attr) ? "" : "_required".loc();
112             handled.push(attr);
113           }
114
115         // Handle formatting if applicable
116         } else if (view.formatter) {
117           value = that[view.formatter](value, view, model);
118
119         // Handle placeholder if applicable
120         } else if (isNothing) {
121           value = view.placeholder || "";
122           isPlaceholder = true;
123
124         } else if (_.contains(that.formatted, type)) {
125           value = that["format" + type](value, view, model);
126         }
127         view.setContent(value);
128         view.addRemoveClass("placeholder", isPlaceholder);
129       });
130
131     }
132   }, XV.FormattingMixin);
133
134   enyo.kind(readOnlyRow);
135
136   /**
137     The editable row of a GridBox for grid entry.
138     @see XV.RelationsEditorMixin.
139   */
140   var editor = enyo.mixin({}, XV.RelationsEditorMixin);
141   editor = enyo.mixin(editor, /** @lends XV.GridEditor */{
142     name: "XV.GridEditor",
143     classes: "xv-grid-row selected",
144     published: {
145       columns: null,
146       value: null
147     },
148     addButtonKeyup: function (inSender, inEvent) {
149       inEvent.preventDefault();
150     },
151     create: function () {
152       this.inherited(arguments);
153       this.columnsChanged();
154     },
155     columnsChanged: function () {
156       this.inherited(arguments);
157       var that = this,
158         columns = this.getColumns() || [],
159         components = [];
160
161       // Loop through each column and build rows
162       _.each(columns, function (column) {
163
164         // Create the column
165         components.push({
166           classes: "xv-grid-column " + (column.classes || "grid-item"),
167           components: _.pluck(column.rows, "editor")
168         });
169       });
170
171       // Create the controls
172       components.push(
173         {classes: "xv-grid-column grid-actions", components: [
174           {components: [
175             {kind: "enyo.Button",
176               classes: "icon-plus xv-gridbox-button",
177               name: "addGridRowButton",
178               onkeypress: "addButtonKeyup",
179             },
180             {kind: "enyo.Button",
181               classes: "icon-folder-open xv-gridbox-button",
182               name: "expandGridRowButton" },
183             {kind: "enyo.Button",
184               classes: "icon-remove-sign xv-gridbox-button",
185               name: "deleteGridRowButton" }
186           ]}
187         ]}
188       );
189       this.createComponents(components, {owner: this});
190     },
191     keyUp: function (inSender, inEvent) {
192       if (inEvent.keyCode === XV.KEY_DOWN) {
193         this.doMoveDown(inEvent);
194         return true;
195       } else if (inEvent.keyCode === XV.KEY_UP) {
196         this.doMoveUp(inEvent);
197         return true;
198       } else if (inEvent.keyCode === XV.KEY_ENTER) {
199         this.doEnterOut(inEvent);
200         return true;
201       }
202     },
203     /**
204       The editor will select the first non-disabled widget it can.
205
206       @param {Object} Widget
207     */
208     setFirstFocus: function () {
209       var editors = [],
210         first;
211
212       _.each(this.children, function (column) {
213         editors = editors.concat(column.children);
214       });
215       first = _.find(editors, function (editor) {
216         return !editor.disabled && editor.focus;
217       });
218
219       if (first) { first.focus(); }
220     }
221   });
222   _.extend(editor.events, {
223       onMoveUp: "",
224       onMoveDown: "",
225       onEnterOut: ""
226     });
227   _.extend(editor.handlers, {
228       onkeyup: "keyUp"
229     });
230   enyo.kind(editor);
231
232   /**
233     Input system for grid entry
234     @extends XV.Groupbox
235    */
236   enyo.kind(
237   /** @lends XV.GridBox# */{
238     name: "XV.GridBox",
239     kind: "XV.Groupbox",
240     classes: "panel xv-grid-box",
241     events: {
242       onChildWorkspace: "",
243       onExportAttr:     ""
244     },
245     handlers: {
246       ontap: "buttonTapped",
247       onEnterOut: "enterOut",
248       onMoveUp: "moveUp",
249       onMoveDown: "moveDown",
250       onChildWorkspaceValueChange: "valueChanged"
251     },
252     published: {
253       attr: null,
254       disabled: null,
255       value: null,
256       editableIndex: null, // number
257       title: "",
258       columns: null,
259       editorMixin: null,
260       summary: null,
261       workspace: null,
262       parentKey: null,
263       gridRowKind: "XV.GridRow",
264       gridEditorKind: "XV.GridEditor",
265       orderBy: null
266     },
267     bindCollection: function () {
268       var that = this,
269         collection = this.getValue();
270       this.unbindCollection();
271       collection.once("add remove", this.valueChanged, this);
272       _.each(collection.models, function (model) {
273         model.on("change status:DESTROYED_DIRTY", that.refreshLists, that);
274       });
275     },
276     buildComponents: function () {
277       var that = this,
278         title = this.getTitle(),
279         columns = this.getColumns() || [],
280         header = [],
281         readOnlyRow = [],
282         summary = this.getSummary(),
283         components = [],
284         editorMixin = this.getEditorMixin() || {},
285         editorCreate = editorMixin.create,
286         editor,
287         gridRowKind = this.getGridRowKind(),
288         gridEditorKind = this.getGridEditorKind();
289
290       editor = enyo.mixin({
291         kind: gridEditorKind,
292         name: "editableGridRow",
293         showing: false,
294         columns: columns
295       }, editorMixin);
296
297       // Hack: because unfortunately some mixins have implemented create
298       // and it's too much work to untangle that right now
299       if (editorCreate) {
300         editor.create = function () {
301           editorCreate.apply(this, arguments);
302           this.columnsChanged();
303         };
304       }
305
306       // View Header
307       components.push({
308         kind: "onyx.GroupboxHeader",
309         content: title,
310         classes: "xv-grid-groupbox-header"
311       });
312
313       // Row Header
314       _.each(columns, function (column) {
315         var contents = _.isArray(column.header) ? column.header : [column.header],
316           components = [];
317
318         _.each(contents, function (content) {
319           components.push({
320             //classes: "xv-grid-header " + (column.classes || "grid-item"),
321             content: content
322           });
323         });
324
325         header.push({kind: "FittableRows", components: components,
326           classes: "xv-grid-header " + (column.classes || "grid-item")});
327       });
328
329       components.push({
330         classes: "xv-grid-row",
331         name: "gridHeader",
332         components: header
333       });
334
335       // Row Definition
336       components.push(
337         {kind: "XV.Scroller", name: "mainGroup", horizontal: "hidden", fit: true, components: [
338           {kind: "List", name: "aboveGridList", classes: "xv-above-grid-list",
339               onSetupItem: "setupRowAbove", ontap: "gridRowTapAbove", components: [
340             { kind: gridRowKind, name: "aboveGridRow", columns: columns}
341           ]},
342           editor,
343           {kind: "List", name: "belowGridList", classes: "xv-below-grid-list",
344               onSetupItem: "setupRowBelow", ontap: "gridRowTapBelow", components: [
345             {kind: gridRowKind, name: "belowGridRow", columns: columns}
346           ]}
347         ]}
348       );
349
350       components.push({
351         kind: "FittableColumns",
352         name: "navigationButtonPanel",
353         classes: "xv-buttons",
354         components: [
355           {kind: "onyx.Button", name: "newButton", ontap: "newItem", content: "_new".loc(),
356             classes: "icon-plus text"},
357           {kind: "onyx.Button", name: "exportButton", ontap: "exportAttr",
358             classes: "icon-share text", content: "_export".loc()}
359         ]
360       });
361
362       // Summary
363       if (summary) {
364         components.push({
365           kind: summary,
366           name: "summaryPanel"
367         });
368       }
369
370       return components;
371     },
372     create: function () {
373       this.inherited(arguments);
374       this.createComponents(this.buildComponents());
375     },
376     allRowsSaved: function () {
377       return _.every(this.getValue().models,
378                      function (model) { return model.isReadyClean(); });
379     },
380     buttonTapped: function (inSender, inEvent) {
381       var editor = this.$.editableGridRow,
382         model,
383         that = this,
384         success;
385
386       switch (inEvent.originator.name) {
387       case "addGridRowButton":
388         this.newItem();
389         break;
390       case "deleteGridRowButton":
391         // note that can't remove the model from the middle of the collection w/o error
392         // we just destroy the model and hide the row.
393         model = inEvent.originator.parent.parent.parent.value; // XXX better ways to do this
394         model.destroy({
395           success: function () {
396             that.setEditableIndex(null);
397             that.$.editableGridRow.hide();
398             that.valueChanged();
399           }
400         });
401         break;
402       case "expandGridRowButton":
403         model = inEvent.originator.parent.parent.parent.value; // XXX better ways to do this
404         this.unbindCollection();
405         editor.value.off("notify", editor.notify, editor);
406         this.doChildWorkspace({
407           workspace: this.getWorkspace(),
408           collection: this.getValue(),
409           index: this.getValue().indexOf(model),
410           callback: function () {
411             editor.value.on("notify", editor.notify, editor);
412             that.bindCollection();
413           }
414         });
415         break;
416       }
417     },
418     destroy: function () {
419       var model = this.value[this.getParentKey()];
420       model.off("status:READY_CLEAN", this.reset, this);
421       this.unbindCollection();
422       this.inherited(arguments);
423     },
424     /**
425      Propagate down disability to widgets.
426      */
427     disabledChanged: function () {
428       this.$.newButton.setDisabled(this.getDisabled());
429     },
430     /*
431       When a user taps the grid row we open it up for editing
432     */
433     gridRowTapAbove: function (inSender, inEvent) {
434       this.gridRowTapEither(inEvent.index, 0);
435     },
436     gridRowTapBelow: function (inSender, inEvent) {
437       this.gridRowTapEither(inEvent.index, this.getEditableIndex() + 1);
438     },
439     gridRowTapEither: function (index, indexStart, firstFocus) {
440       firstFocus = firstFocus !== false;
441       var editableIndex = index + indexStart,
442         belowListCount = this.getValue().length - editableIndex - 1,
443         editor = this.$.editableGridRow;
444
445       if (this.getDisabled()) {
446         // read-only means read-only
447         return;
448       }
449       if (index === undefined) {
450         // tap somewhere other than a row item
451         return;
452       }
453       this.setEditableIndex(editableIndex);
454       this.$.aboveGridList.setCount(editableIndex);
455       this.$.aboveGridList.render();
456       // XXX hack. not sure why the port doesn't know to resize down
457       this.$.aboveGridList.$.port.applyStyle("height", (_readOnlyHeight * this.liveModels(editableIndex - 1).length) + "px");
458       editor.setValue(this.getValue().at(editableIndex));
459       if (!editor.showing) {
460         editor.show();
461         editor.render();
462       }
463       if (firstFocus) {
464         editor.setFirstFocus();
465       }
466       this.$.belowGridList.setCount(belowListCount);
467       this.$.belowGridList.render();
468       // XXX hack. not sure why the port doesn't know to resize down
469       this.$.belowGridList.$.port.applyStyle("height", (_readOnlyHeight * belowListCount) + "px");
470     },
471     /**
472       Get all the models that are not destroyed, up through optional maxIndex parameter
473     */
474     liveModels: function (maxIndex) {
475       return _.compact(_.map(this.getValue().models, function (model, index) {
476         if (maxIndex !== undefined && index > maxIndex) {
477           return null;
478         } else if (model.isDestroyed()) {
479           return null;
480         } else {
481           return model;
482         }
483       }));
484     },
485     moveDown: function (inSender, inEvent) {
486       var outIndex = this.getEditableIndex(),
487         rowCount = this.getValue().length,
488         wrap = inEvent.wrap === true,
489         i;
490
491       if (wrap && outIndex + 1 === rowCount) {
492         this.newItem();
493       } else {
494         // go to the next live row, as if it had been tapped
495         for (i = outIndex + 1; i < this.getValue().length; i++) {
496           if (!this.getValue().at(i).isDestroyed()) {
497             this.gridRowTapEither(i, 0, wrap);
498             break;
499           }
500         }
501       }
502     },
503     moveUp: function (inSender, inEvent) {
504       var outIndex = this.getEditableIndex(),
505         firstFocus = inEvent.firstFocus === true,
506         i;
507
508       if (outIndex >= 0) {
509         // go to the next live row, as if it had been tapped
510         for (i = outIndex - 1; i >= 0; i--) {
511           if (!this.getValue().at(i).isDestroyed()) {
512             this.gridRowTapEither(i, 0, firstFocus);
513             break;
514           }
515         }
516       }
517     },
518     /*
519       Add a row to the grid
520     */
521     newItem: function () {
522       var editableIndex = this.getValue().length,
523         aboveListCount = this.liveModels(editableIndex - 1).length,
524         Klass = this.getValue().model,
525         model = new Klass(null, {isNew: true}),
526         editor = this.$.editableGridRow;
527
528       this.getValue().add(model, {status: XM.Model.READY_NEW});
529       this.setEditableIndex(editableIndex);
530       this.valueChanged();
531       // XXX hack. not sure why the port doesn't know to resize down
532       this.$.aboveGridList.$.port.applyStyle("height", (_readOnlyHeight * aboveListCount) + "px");
533       editor.setValue(model);
534       editor.show();
535       editor.render();
536       editor.setFirstFocus();
537     },
538     exportAttr: function (inSender, inEvent) {
539       var gridbox = this;
540       this.doExportAttr({ attr: gridbox.attr });
541     },
542     refreshLists: function () {
543       var collection = this.getValue();
544       this.$.aboveGridList.refresh();
545       this.$.belowGridList.refresh();
546       this.$.exportButton.setDisabled(! this.allRowsSaved() ||
547                                       ! collection.length);
548     },
549     reset: function () {
550       this.setEditableIndex(null);
551       this.valueChanged();
552     },
553     setupRowAbove: function (inSender, inEvent) {
554       this.setupRowEither(inEvent.index, this.$.aboveGridRow, 0);
555     },
556     setupRowBelow: function (inSender, inEvent) {
557       this.setupRowEither(inEvent.index, this.$.belowGridRow, this.getEditableIndex() + 1);
558     },
559     setupRowEither: function (index, gridRow, indexStart) {
560       var that = this,
561         model = this.getValue().at(indexStart + index);
562
563       // set the contents of the row
564       gridRow.setValue(model);
565       gridRow.setShowing(model && !model.isDestroyed());
566
567       return true;
568     },
569     enterOut: function (inSender, inEvent) {
570       inEvent.wrap = true;
571       this.moveDown(inSender, inEvent);
572     },
573     unbindCollection: function () {
574       var collection = this.getValue(),
575         that = this;
576
577       collection.off("add remove", this.valueChanged, this);
578       _.each(collection.models, function (model) {
579         model.off("change status:DESTROYED_DIRTY", that.refreshLists, that);
580       });
581     },
582     setValue: function (value) {
583       var orderBy = this.getOrderBy(),
584         collection;
585       this._setProperty("value", value, "valueChanged");
586
587       collection = value;
588       if (this.getEditableIndex() !== null) {
589         // do not try to sort while the user is entering data
590         return;
591       }
592
593       if (orderBy && !collection.comparator) {
594         collection.comparator = function (a, b) {
595           var aval,
596             bval,
597             attr,
598             i;
599           for (i = 0; i < orderBy.length; i++) {
600             attr = orderBy[i].attribute;
601             aval = orderBy[i].descending ? b.getValue(attr) : a.getValue(attr);
602             bval = orderBy[i].descending ? a.getValue(attr) : b.getValue(attr);
603             aval = !isNaN(aval) ? aval - 0 : aval;
604             bval = !isNaN(aval) ? bval - 0 : bval;
605             if (aval !== bval) {
606               return aval > bval ? 1 : -1;
607             }
608           }
609           return 0;
610         };
611       }
612       if (orderBy) {
613         collection.sort();
614       }
615     },
616     valueChanged: function () {
617       var that = this,
618         collection = this.getValue(),
619         model = this.value[this.getParentKey()];
620
621       // Make sure lists refresh if data changes
622       this.bindCollection();
623       this.$.aboveGridList.setCount(this.getEditableIndex() !== null ? this.getEditableIndex() : this.getValue().length);
624       // XXX hack. not sure why the port doesn't know to resize down
625       this.$.aboveGridList.$.port.applyStyle("height", (_readOnlyHeight * this.liveModels().length) + "px");
626       this.$.belowGridList.setCount(0);
627       this.$.belowGridList.$.port.applyStyle("height", "0px");
628       this.$.editableGridRow.setShowing(false);
629       this.$.aboveGridList.render();
630       this.$.editableGridRow.render();
631
632       // Should the model get saved and the server changed something
633       // come back here and reset things.
634       model.once("status:READY_CLEAN", this.reset, this);
635
636       // Update summary panel if applicable
637       if (this.$.summaryPanel) {
638         this.$.summaryPanel.setValue(model);
639       }
640
641       this.$.exportButton.setDisabled(! this.allRowsSaved() ||
642                                       ! collection.length);
643     }
644   });
645 }());