de85aa9f3585b00c57816c25687afb06bf2e804d
[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", onclick: "newItem", classes: "icon-plus"},
355           {kind: "font.TextIcon", name: "exportButton", onclick: "exportAttr", icon: "share", content: "_export".loc()} // TODO: classes?
356         ]
357       });
358
359       // Summary
360       if (summary) {
361         components.push({
362           kind: summary,
363           name: "summaryPanel"
364         });
365       }
366
367       return components;
368     },
369     create: function () {
370       this.inherited(arguments);
371       this.createComponents(this.buildComponents());
372     },
373     buttonTapped: function (inSender, inEvent) {
374       var editor = this.$.editableGridRow,
375         model,
376         that = this,
377         success;
378
379       switch (inEvent.originator.name) {
380       case "addGridRowButton":
381         this.newItem();
382         break;
383       case "deleteGridRowButton":
384         // note that can't remove the model from the middle of the collection w/o error
385         // we just destroy the model and hide the row.
386         model = inEvent.originator.parent.parent.parent.value; // XXX better ways to do this
387         model.destroy({
388           success: function () {
389             that.setEditableIndex(null);
390             that.$.editableGridRow.hide();
391             that.valueChanged();
392           }
393         });
394         break;
395       case "expandGridRowButton":
396         model = inEvent.originator.parent.parent.parent.value; // XXX better ways to do this
397         this.unbindCollection();
398         editor.value.off("notify", editor.notify, editor);
399         this.doChildWorkspace({
400           workspace: this.getWorkspace(),
401           collection: this.getValue(),
402           index: this.getValue().indexOf(model),
403           callback: function () {
404             editor.value.on("notify", editor.notify, editor);
405             that.bindCollection();
406           }
407         });
408         break;
409       }
410     },
411     destroy: function () {
412       var model = this.value[this.getParentKey()];
413       model.off("status:READY_CLEAN", this.reset, this);
414       this.unbindCollection();
415       this.inherited(arguments);
416     },
417     /**
418      Propagate down disability to widgets.
419      */
420     disabledChanged: function () {
421       this.$.newButton.setDisabled(this.getDisabled());
422     },
423     /*
424       When a user taps the grid row we open it up for editing
425     */
426     gridRowTapAbove: function (inSender, inEvent) {
427       this.gridRowTapEither(inEvent.index, 0);
428     },
429     gridRowTapBelow: function (inSender, inEvent) {
430       this.gridRowTapEither(inEvent.index, this.getEditableIndex() + 1);
431     },
432     gridRowTapEither: function (index, indexStart, firstFocus) {
433       firstFocus = firstFocus !== false;
434       var editableIndex = index + indexStart,
435         belowListCount = this.getValue().length - editableIndex - 1,
436         editor = this.$.editableGridRow;
437
438       if (this.getDisabled()) {
439         // read-only means read-only
440         return;
441       }
442       if (index === undefined) {
443         // tap somewhere other than a row item
444         return;
445       }
446       this.setEditableIndex(editableIndex);
447       this.$.aboveGridList.setCount(editableIndex);
448       this.$.aboveGridList.render();
449       // XXX hack. not sure why the port doesn't know to resize down
450       this.$.aboveGridList.$.port.applyStyle("height", (_readOnlyHeight * this.liveModels(editableIndex - 1).length) + "px");
451       editor.setValue(this.getValue().at(editableIndex));
452       if (!editor.showing) {
453         editor.show();
454         editor.render();
455       }
456       if (firstFocus) {
457         editor.setFirstFocus();
458       }
459       this.$.belowGridList.setCount(belowListCount);
460       this.$.belowGridList.render();
461       // XXX hack. not sure why the port doesn't know to resize down
462       this.$.belowGridList.$.port.applyStyle("height", (_readOnlyHeight * belowListCount) + "px");
463     },
464     /**
465       Get all the models that are not destroyed, up through optional maxIndex parameter
466     */
467     liveModels: function (maxIndex) {
468       return _.compact(_.map(this.getValue().models, function (model, index) {
469         if (maxIndex !== undefined && index > maxIndex) {
470           return null;
471         } else if (model.isDestroyed()) {
472           return null;
473         } else {
474           return model;
475         }
476       }));
477     },
478     moveDown: function (inSender, inEvent) {
479       var outIndex = this.getEditableIndex(),
480         rowCount = this.getValue().length,
481         wrap = inEvent.wrap === true,
482         i;
483
484       if (wrap && outIndex + 1 === rowCount) {
485         this.newItem();
486       } else {
487         // go to the next live row, as if it had been tapped
488         for (i = outIndex + 1; i < this.getValue().length; i++) {
489           if (!this.getValue().at(i).isDestroyed()) {
490             this.gridRowTapEither(i, 0, wrap);
491             break;
492           }
493         }
494       }
495     },
496     moveUp: function (inSender, inEvent) {
497       var outIndex = this.getEditableIndex(),
498         firstFocus = inEvent.firstFocus === true,
499         i;
500
501       if (outIndex >= 0) {
502         // go to the next live row, as if it had been tapped
503         for (i = outIndex - 1; i >= 0; i--) {
504           if (!this.getValue().at(i).isDestroyed()) {
505             this.gridRowTapEither(i, 0, firstFocus);
506             break;
507           }
508         }
509       }
510     },
511     /*
512       Add a row to the grid
513     */
514     newItem: function () {
515       var editableIndex = this.getValue().length,
516         aboveListCount = this.liveModels(editableIndex - 1).length,
517         Klass = this.getValue().model,
518         model = new Klass(null, {isNew: true}),
519         editor = this.$.editableGridRow;
520
521       this.getValue().add(model, {status: XM.Model.READY_NEW});
522       this.setEditableIndex(editableIndex);
523       this.valueChanged();
524       // XXX hack. not sure why the port doesn't know to resize down
525       this.$.aboveGridList.$.port.applyStyle("height", (_readOnlyHeight * aboveListCount) + "px");
526       editor.setValue(model);
527       editor.show();
528       editor.render();
529       editor.setFirstFocus();
530     },
531     exportAttr: function(inSender, inEvent) {
532       var gridbox = inEvent.originator.parent.parent.parent;
533       this.doExportAttr({ recordType: gridbox.parent.parent.model,
534                           uuid:       gridbox.parent.parent.value.attributes.uuid,
535                           attr:       gridbox.attr });
536     },
537     refreshLists: function () {
538       this.$.aboveGridList.refresh();
539       this.$.belowGridList.refresh();
540     },
541     reset: function () {
542       this.setEditableIndex(null);
543       this.valueChanged();
544     },
545     setupRowAbove: function (inSender, inEvent) {
546       this.setupRowEither(inEvent.index, this.$.aboveGridRow, 0);
547     },
548     setupRowBelow: function (inSender, inEvent) {
549       this.setupRowEither(inEvent.index, this.$.belowGridRow, this.getEditableIndex() + 1);
550     },
551     setupRowEither: function (index, gridRow, indexStart) {
552       var that = this,
553         model = this.getValue().at(indexStart + index);
554
555       // set the contents of the row
556       gridRow.setValue(model);
557       gridRow.setShowing(model && !model.isDestroyed());
558
559       return true;
560     },
561     enterOut: function (inSender, inEvent) {
562       inEvent.wrap = true;
563       this.moveDown(inSender, inEvent);
564     },
565     unbindCollection: function () {
566       var collection = this.getValue(),
567         that = this;
568
569       collection.off("add remove", this.valueChanged, this);
570       _.each(collection.models, function (model) {
571         model.off("change status:DESTROYED_DIRTY", that.refreshLists, that);
572       });
573     },
574     setValue: function (value) {
575       var orderBy = this.getOrderBy(),
576         collection;
577       this._setProperty("value", value, "valueChanged");
578
579       collection = value;
580       if (orderBy && !collection.comparator) {
581         collection.comparator = function (a, b) {
582           var aval,
583             bval,
584             attr,
585             i;
586           for (i = 0; i < orderBy.length; i++) {
587             attr = orderBy[i].attribute;
588             aval = orderBy[i].descending ? b.getValue(attr) : a.getValue(attr);
589             bval = orderBy[i].descending ? a.getValue(attr) : b.getValue(attr);
590             aval = !isNaN(aval) ? aval - 0 : aval;
591             bval = !isNaN(aval) ? bval - 0 : bval;
592             if (aval !== bval) {
593               return aval > bval ? 1 : -1;
594             }
595           }
596           return 0;
597         };
598       }
599       if (orderBy) {
600         collection.sort();
601       }
602     },
603     valueChanged: function () {
604       var that = this,
605         collection = this.getValue(),
606         model = this.value[this.getParentKey()];
607
608       // Make sure lists refresh if data changes
609       this.bindCollection();
610       this.$.aboveGridList.setCount(this.getEditableIndex() !== null ? this.getEditableIndex() : this.getValue().length);
611       // XXX hack. not sure why the port doesn't know to resize down
612       this.$.aboveGridList.$.port.applyStyle("height", (_readOnlyHeight * this.liveModels().length) + "px");
613       this.$.belowGridList.setCount(0);
614       this.$.belowGridList.$.port.applyStyle("height", "0px");
615       this.$.editableGridRow.setShowing(false);
616       this.$.aboveGridList.render();
617       this.$.editableGridRow.render();
618
619       // Should the model get saved and the server changed something
620       // come back here and reset things.
621       model.once("status:READY_CLEAN", this.reset, this);
622
623       // Update summary panel if applicable
624       if (this.$.summaryPanel) {
625         this.$.summaryPanel.setValue(model);
626       }
627     }
628   });
629 }());