respond to code review
[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     },
251     published: {
252       attr: null,
253       disabled: null,
254       value: null,
255       editableIndex: null, // number
256       title: "",
257       columns: null,
258       editorMixin: null,
259       summary: null,
260       workspace: null,
261       parentKey: null,
262       gridRowKind: "XV.GridRow",
263       gridEditorKind: "XV.GridEditor",
264       orderBy: null
265     },
266     bindCollection: function () {
267       var that = this,
268         collection = this.getValue();
269       this.unbindCollection();
270       collection.once("add remove", this.valueChanged, this);
271       _.each(collection.models, function (model) {
272         model.on("change status:DESTROYED_DIRTY", that.refreshLists, that);
273       });
274     },
275     buildComponents: function () {
276       var that = this,
277         title = this.getTitle(),
278         columns = this.getColumns() || [],
279         header = [],
280         readOnlyRow = [],
281         summary = this.getSummary(),
282         components = [],
283         editorMixin = this.getEditorMixin() || {},
284         editorCreate = editorMixin.create,
285         editor,
286         gridRowKind = this.getGridRowKind(),
287         gridEditorKind = this.getGridEditorKind();
288
289       editor = enyo.mixin({
290         kind: gridEditorKind,
291         name: "editableGridRow",
292         showing: false,
293         columns: columns
294       }, editorMixin);
295
296       // Hack: because unfortunately some mixins have implemented create
297       // and it's too much work to untangle that right now
298       if (editorCreate) {
299         editor.create = function () {
300           editorCreate.apply(this, arguments);
301           this.columnsChanged();
302         };
303       }
304
305       // View Header
306       components.push({
307         kind: "onyx.GroupboxHeader",
308         content: title,
309         classes: "xv-grid-groupbox-header"
310       });
311
312       // Row Header
313       _.each(columns, function (column) {
314         var contents = _.isArray(column.header) ? column.header : [column.header],
315           components = [];
316
317         _.each(contents, function (content) {
318           components.push({
319             //classes: "xv-grid-header " + (column.classes || "grid-item"),
320             content: content
321           });
322         });
323
324         header.push({kind: "FittableRows", components: components,
325           classes: "xv-grid-header " + (column.classes || "grid-item")});
326       });
327
328       components.push({
329         classes: "xv-grid-row",
330         name: "gridHeader",
331         components: header
332       });
333
334       // Row Definition
335       components.push(
336         {kind: "XV.Scroller", name: "mainGroup", horizontal: "hidden", fit: true, components: [
337           {kind: "List", name: "aboveGridList", classes: "xv-above-grid-list",
338               onSetupItem: "setupRowAbove", ontap: "gridRowTapAbove", components: [
339             { kind: gridRowKind, name: "aboveGridRow", columns: columns}
340           ]},
341           editor,
342           {kind: "List", name: "belowGridList", classes: "xv-below-grid-list",
343               onSetupItem: "setupRowBelow", ontap: "gridRowTapBelow", components: [
344             {kind: gridRowKind, name: "belowGridRow", columns: columns}
345           ]}
346         ]}
347       );
348
349       components.push({
350         kind: "FittableColumns",
351         name: "navigationButtonPanel",
352         classes: "xv-buttons",
353         components: [
354           {kind: "onyx.Button", name: "newButton", ontap: "newItem", content: "_new".loc(),
355             classes: "icon-plus text"},
356           {kind: "onyx.Button", name: "exportButton", ontap: "exportAttr",
357             classes: "icon-share text", content: "_export".loc()}
358         ]
359       });
360
361       // Summary
362       if (summary) {
363         components.push({
364           kind: summary,
365           name: "summaryPanel"
366         });
367       }
368
369       return components;
370     },
371     create: function () {
372       this.inherited(arguments);
373       this.createComponents(this.buildComponents());
374     },
375     allRowsSaved: function () {
376       return _.every(this.getValue().models,
377                      function (model) { return model.isReadyClean(); });
378     },
379     buttonTapped: function (inSender, inEvent) {
380       var editor = this.$.editableGridRow,
381         model,
382         that = this,
383         success;
384
385       switch (inEvent.originator.name) {
386       case "addGridRowButton":
387         this.newItem();
388         break;
389       case "deleteGridRowButton":
390         // note that can't remove the model from the middle of the collection w/o error
391         // we just destroy the model and hide the row.
392         model = inEvent.originator.parent.parent.parent.value; // XXX better ways to do this
393         model.destroy({
394           success: function () {
395             that.setEditableIndex(null);
396             that.$.editableGridRow.hide();
397             that.valueChanged();
398           }
399         });
400         break;
401       case "expandGridRowButton":
402         model = inEvent.originator.parent.parent.parent.value; // XXX better ways to do this
403         this.unbindCollection();
404         editor.value.off("notify", editor.notify, editor);
405         this.doChildWorkspace({
406           workspace: this.getWorkspace(),
407           collection: this.getValue(),
408           index: this.getValue().indexOf(model),
409           callback: function () {
410             editor.value.on("notify", editor.notify, editor);
411             that.bindCollection();
412           }
413         });
414         break;
415       }
416     },
417     destroy: function () {
418       var model = this.value[this.getParentKey()];
419       model.off("status:READY_CLEAN", this.reset, this);
420       this.unbindCollection();
421       this.inherited(arguments);
422     },
423     /**
424      Propagate down disability to widgets.
425      */
426     disabledChanged: function () {
427       this.$.newButton.setDisabled(this.getDisabled());
428     },
429     /*
430       When a user taps the grid row we open it up for editing
431     */
432     gridRowTapAbove: function (inSender, inEvent) {
433       this.gridRowTapEither(inEvent.index, 0);
434     },
435     gridRowTapBelow: function (inSender, inEvent) {
436       this.gridRowTapEither(inEvent.index, this.getEditableIndex() + 1);
437     },
438     gridRowTapEither: function (index, indexStart, firstFocus) {
439       firstFocus = firstFocus !== false;
440       var editableIndex = index + indexStart,
441         belowListCount = this.getValue().length - editableIndex - 1,
442         editor = this.$.editableGridRow;
443
444       if (this.getDisabled()) {
445         // read-only means read-only
446         return;
447       }
448       if (index === undefined) {
449         // tap somewhere other than a row item
450         return;
451       }
452       this.setEditableIndex(editableIndex);
453       this.$.aboveGridList.setCount(editableIndex);
454       this.$.aboveGridList.render();
455       // XXX hack. not sure why the port doesn't know to resize down
456       this.$.aboveGridList.$.port.applyStyle("height", (_readOnlyHeight * this.liveModels(editableIndex - 1).length) + "px");
457       editor.setValue(this.getValue().at(editableIndex));
458       if (!editor.showing) {
459         editor.show();
460         editor.render();
461       }
462       if (firstFocus) {
463         editor.setFirstFocus();
464       }
465       this.$.belowGridList.setCount(belowListCount);
466       this.$.belowGridList.render();
467       // XXX hack. not sure why the port doesn't know to resize down
468       this.$.belowGridList.$.port.applyStyle("height", (_readOnlyHeight * belowListCount) + "px");
469     },
470     /**
471       Get all the models that are not destroyed, up through optional maxIndex parameter
472     */
473     liveModels: function (maxIndex) {
474       return _.compact(_.map(this.getValue().models, function (model, index) {
475         if (maxIndex !== undefined && index > maxIndex) {
476           return null;
477         } else if (model.isDestroyed()) {
478           return null;
479         } else {
480           return model;
481         }
482       }));
483     },
484     moveDown: function (inSender, inEvent) {
485       var outIndex = this.getEditableIndex(),
486         rowCount = this.getValue().length,
487         wrap = inEvent.wrap === true,
488         i;
489
490       if (wrap && outIndex + 1 === rowCount) {
491         this.newItem();
492       } else {
493         // go to the next live row, as if it had been tapped
494         for (i = outIndex + 1; i < this.getValue().length; i++) {
495           if (!this.getValue().at(i).isDestroyed()) {
496             this.gridRowTapEither(i, 0, wrap);
497             break;
498           }
499         }
500       }
501     },
502     moveUp: function (inSender, inEvent) {
503       var outIndex = this.getEditableIndex(),
504         firstFocus = inEvent.firstFocus === true,
505         i;
506
507       if (outIndex >= 0) {
508         // go to the next live row, as if it had been tapped
509         for (i = outIndex - 1; i >= 0; i--) {
510           if (!this.getValue().at(i).isDestroyed()) {
511             this.gridRowTapEither(i, 0, firstFocus);
512             break;
513           }
514         }
515       }
516     },
517     /*
518       Add a row to the grid
519     */
520     newItem: function () {
521       var editableIndex = this.getValue().length,
522         aboveListCount = this.liveModels(editableIndex - 1).length,
523         Klass = this.getValue().model,
524         model = new Klass(null, {isNew: true}),
525         editor = this.$.editableGridRow;
526
527       this.getValue().add(model, {status: XM.Model.READY_NEW});
528       this.setEditableIndex(editableIndex);
529       this.valueChanged();
530       // XXX hack. not sure why the port doesn't know to resize down
531       this.$.aboveGridList.$.port.applyStyle("height", (_readOnlyHeight * aboveListCount) + "px");
532       editor.setValue(model);
533       editor.show();
534       editor.render();
535       editor.setFirstFocus();
536     },
537     exportAttr: function (inSender, inEvent) {
538       var gridbox = this;
539       this.doExportAttr({ attr: gridbox.attr });
540     },
541     refreshLists: function () {
542       var collection = this.getValue();
543       this.$.aboveGridList.refresh();
544       this.$.belowGridList.refresh();
545       this.$.exportButton.setDisabled(! this.allRowsSaved() ||
546                                       ! collection.length);
547     },
548     reset: function () {
549       this.setEditableIndex(null);
550       this.valueChanged();
551     },
552     setupRowAbove: function (inSender, inEvent) {
553       this.setupRowEither(inEvent.index, this.$.aboveGridRow, 0);
554     },
555     setupRowBelow: function (inSender, inEvent) {
556       this.setupRowEither(inEvent.index, this.$.belowGridRow, this.getEditableIndex() + 1);
557     },
558     setupRowEither: function (index, gridRow, indexStart) {
559       var that = this,
560         model = this.getValue().at(indexStart + index);
561
562       // set the contents of the row
563       gridRow.setValue(model);
564       gridRow.setShowing(model && !model.isDestroyed());
565
566       return true;
567     },
568     enterOut: function (inSender, inEvent) {
569       inEvent.wrap = true;
570       this.moveDown(inSender, inEvent);
571     },
572     unbindCollection: function () {
573       var collection = this.getValue(),
574         that = this;
575
576       collection.off("add remove", this.valueChanged, this);
577       _.each(collection.models, function (model) {
578         model.off("change status:DESTROYED_DIRTY", that.refreshLists, that);
579       });
580     },
581     setValue: function (value) {
582       var orderBy = this.getOrderBy(),
583         collection;
584       this._setProperty("value", value, "valueChanged");
585
586       collection = value;
587       if (orderBy && !collection.comparator) {
588         collection.comparator = function (a, b) {
589           var aval,
590             bval,
591             attr,
592             i;
593           for (i = 0; i < orderBy.length; i++) {
594             attr = orderBy[i].attribute;
595             aval = orderBy[i].descending ? b.getValue(attr) : a.getValue(attr);
596             bval = orderBy[i].descending ? a.getValue(attr) : b.getValue(attr);
597             aval = !isNaN(aval) ? aval - 0 : aval;
598             bval = !isNaN(aval) ? bval - 0 : bval;
599             if (aval !== bval) {
600               return aval > bval ? 1 : -1;
601             }
602           }
603           return 0;
604         };
605       }
606       if (orderBy) {
607         collection.sort();
608       }
609     },
610     valueChanged: function () {
611       var that = this,
612         collection = this.getValue(),
613         model = this.value[this.getParentKey()];
614
615       // Make sure lists refresh if data changes
616       this.bindCollection();
617       this.$.aboveGridList.setCount(this.getEditableIndex() !== null ? this.getEditableIndex() : this.getValue().length);
618       // XXX hack. not sure why the port doesn't know to resize down
619       this.$.aboveGridList.$.port.applyStyle("height", (_readOnlyHeight * this.liveModels().length) + "px");
620       this.$.belowGridList.setCount(0);
621       this.$.belowGridList.$.port.applyStyle("height", "0px");
622       this.$.editableGridRow.setShowing(false);
623       this.$.aboveGridList.render();
624       this.$.editableGridRow.render();
625
626       // Should the model get saved and the server changed something
627       // come back here and reset things.
628       model.once("status:READY_CLEAN", this.reset, this);
629
630       // Update summary panel if applicable
631       if (this.$.summaryPanel) {
632         this.$.summaryPanel.setValue(model);
633       }
634
635       this.$.exportButton.setDisabled(! this.allRowsSaved() ||
636                                       ! collection.length);
637     }
638   });
639 }());