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