1 /**
  2  * Olives http://flams.github.com/olives
  3  * The MIT License (MIT)
  4  * Copyright (c) 2012-2014 Olivier Scherrer <pode.fr@gmail.com> - Olivier Wietrich <olivier.wietrich@gmail.com>
  5  */
  6 "use strict";
  7 
  8 
  9 var Store = require("emily").Store,
 10     Observable = require("emily").Observable,
 11     Tools = require("emily").Tools,
 12     DomUtils = require("./DomUtils");
 13 
 14 /**
 15  * @class
 16  * This plugin links dom nodes to a model
 17  * @requires Store, Observable, Tools, DomUtils
 18  */
 19 module.exports = function BindPluginConstructor($model, $bindings) {
 20 
 21     /**
 22      * The model to watch
 23      * @private
 24      */
 25     var _model = null,
 26 
 27     /**
 28      * The list of custom bindings
 29      * @private
 30      */
 31     _bindings = {},
 32 
 33     /**
 34      * The list of itemRenderers
 35      * each foreach has its itemRenderer
 36      * @private
 37      */
 38     _itemRenderers = {},
 39 
 40     /**
 41      * The observers handlers
 42      * @private
 43      */
 44     _observers = {};
 45 
 46     /**
 47      * Exposed for debugging purpose
 48      * @private
 49      */
 50     this.observers = _observers;
 51 
 52     function _removeObserversForId(id) {
 53         if (_observers[id]) {
 54             _observers[id].forEach(function (handler) {
 55                 _model.unwatchValue(handler);
 56             });
 57             delete _observers[id];
 58         }
 59     }
 60 
 61     /**
 62      * Define the model to watch for
 63      * @param {Store} model the model to watch for changes
 64      * @returns {Boolean} true if the model was set
 65      */
 66     this.setModel = function setModel(model) {
 67         if (model instanceof Store) {
 68             // Set the model
 69             _model = model;
 70             return true;
 71         } else {
 72             return false;
 73         }
 74     };
 75 
 76     /**
 77      * Get the store that is watched for
 78      * for debugging only
 79      * @private
 80      * @returns the Store
 81      */
 82     this.getModel = function getModel() {
 83         return _model;
 84     };
 85 
 86     /**
 87      * The item renderer defines a dom node that can be duplicated
 88      * It is made available for debugging purpose, don't use it
 89      * @private
 90      */
 91     this.ItemRenderer = function ItemRenderer($plugins, $rootNode) {
 92 
 93         /**
 94          * The node that will be cloned
 95          * @private
 96          */
 97         var _node = null,
 98 
 99         /**
100          * The object that contains plugins.name and plugins.apply
101          * @private
102          */
103         _plugins = null,
104 
105         /**
106          * The _rootNode where to append the created items
107          * @private
108          */
109         _rootNode = null,
110 
111         /**
112          * The lower boundary
113          * @private
114          */
115         _start = null,
116 
117         /**
118          * The number of item to display
119          * @private
120          */
121         _nb = null;
122 
123         /**
124          * Set the duplicated node
125          * @private
126          */
127         this.setRenderer = function setRenderer(node) {
128             _node = node;
129             return true;
130         };
131 
132         /**
133          * Returns the node that is going to be used for rendering
134          * @private
135          * @returns the node that is duplicated
136          */
137         this.getRenderer = function getRenderer() {
138             return _node;
139         };
140 
141         /**
142          * Sets the rootNode and gets the node to copy
143          * @private
144          * @param {HTMLElement|SVGElement} rootNode
145          * @returns
146          */
147         this.setRootNode = function setRootNode(rootNode) {
148             var renderer;
149             if (DomUtils.isAcceptedType(rootNode)) {
150                 _rootNode = rootNode;
151                 renderer = _rootNode.querySelector("*");
152                 this.setRenderer(renderer);
153                 if (renderer) {
154                     _rootNode.removeChild(renderer);
155                 }
156                 return true;
157             } else {
158                 return false;
159             }
160         };
161 
162         /**
163          * Gets the rootNode
164          * @private
165          * @returns _rootNode
166          */
167         this.getRootNode = function getRootNode() {
168             return _rootNode;
169         };
170 
171         /**
172          * Set the plugins objet that contains the name and the apply function
173          * @private
174          * @param plugins
175          * @returns true
176          */
177         this.setPlugins = function setPlugins(plugins) {
178             _plugins = plugins;
179             return true;
180         };
181 
182         /**
183          * Get the plugins object
184          * @private
185          * @returns the plugins object
186          */
187         this.getPlugins = function getPlugins() {
188             return _plugins;
189         };
190 
191         /**
192          * The nodes created from the items are stored here
193          * @private
194          */
195         this.items = {};
196 
197         /**
198          * Set the start limit
199          * @private
200          * @param {Number} start the value to start rendering the items from
201          * @returns the value
202          */
203         this.setStart = function setStart(start) {
204             _start = parseInt(start, 10);
205             return _start;
206         };
207 
208         /**
209          * Get the start value
210          * @private
211          * @returns the start value
212          */
213         this.getStart = function getStart() {
214             return _start;
215         };
216 
217         /**
218          * Set the number of item to display
219          * @private
220          * @param {Number/String} nb the number of item to display or "*" for all
221          * @returns the value
222          */
223         this.setNb = function setNb(nb) {
224             _nb = nb == "*" ? nb : parseInt(nb, 10);
225             return _nb;
226         };
227 
228         /**
229          * Get the number of item to display
230          * @private
231          * @returns the value
232          */
233         this.getNb = function getNb() {
234             return _nb;
235         };
236 
237         /**
238          * Adds a new item and adds it in the items list
239          * @private
240          * @param {Number} id the id of the item
241          * @returns
242          */
243         this.addItem = function addItem(id) {
244             var node,
245                 next;
246 
247             if (typeof id == "number" && !this.items[id]) {
248                 next = this.getNextItem(id);
249                 node = this.create(id);
250                 if (node) {
251                     // IE (until 9) apparently fails to appendChild when insertBefore's second argument is null, hence this.
252                     if (next) {
253                         _rootNode.insertBefore(node, next);
254                     } else {
255                         _rootNode.appendChild(node);
256                     }
257                     return true;
258                 } else {
259                     return false;
260                 }
261             } else {
262                 return false;
263             }
264         };
265 
266         /**
267          * Get the next item in the item store given an id.
268          * @private
269          * @param {Number} id the id to start from
270          * @returns
271          */
272         this.getNextItem = function getNextItem(id) {
273             var keys = Object.keys(this.items).map(function (string) {
274                     return Number(string);
275                 }),
276                 closest = Tools.closestGreater(id, keys),
277                 closestId = keys[closest];
278 
279             // Only return if different
280             if (closestId != id) {
281                 return this.items[closestId];
282             } else {
283                 return;
284             }
285         };
286 
287         /**
288          * Remove an item from the dom and the items list
289          * @private
290          * @param {Number} id the id of the item to remove
291          * @returns
292          */
293         this.removeItem = function removeItem(id) {
294             var item = this.items[id];
295             if (item) {
296                 _rootNode.removeChild(item);
297                 delete this.items[id];
298                 _removeObserversForId(id);
299                 return true;
300             } else {
301                 return false;
302             }
303         };
304 
305         /**
306          * create a new node. Actually makes a clone of the initial one
307          * and adds pluginname_id to each node, then calls plugins.apply to apply all plugins
308          * @private
309          * @param id
310          * @param pluginName
311          * @returns the associated node
312          */
313         this.create = function create(id) {
314             if (_model.has(id)) {
315                 var newNode = _node.cloneNode(true),
316                 nodes = DomUtils.getNodes(newNode);
317 
318                 Tools.toArray(nodes).forEach(function (child) {
319                     child.setAttribute("data-" + _plugins.name+"_id", id);
320                 });
321 
322                 this.items[id] = newNode;
323                 _plugins.apply(newNode);
324                 return newNode;
325             }
326         };
327 
328         /**
329          * Renders the dom tree, adds nodes that are in the boundaries
330          * and removes the others
331          * @private
332          * @returns true boundaries are set
333          */
334         this.render = function render() {
335             // If the number of items to render is all (*)
336             // Then get the number of items
337             var _tmpNb = _nb == "*" ? _model.getNbItems() : _nb;
338 
339             // This will store the items to remove
340             var marked = [];
341 
342             // Render only if boundaries have been set
343             if (_nb !== null && _start !== null) {
344 
345                 // Loop through the existing items
346                 Tools.loop(this.items, function (value, idx) {
347                     // If an item is out of the boundary
348                     idx = Number(idx);
349 
350                     if (idx < _start || idx >= (_start + _tmpNb) || !_model.has(idx)) {
351                         // Mark it
352                         marked.push(idx);
353                     }
354                 }, this);
355 
356                 // Remove the marked item from the highest id to the lowest
357                 // Doing this will avoid the id change during removal
358                 // (removing id 2 will make id 3 becoming 2)
359                 marked.sort(Tools.compareNumbers).reverse().forEach(this.removeItem, this);
360 
361                 // Now that we have removed the old nodes
362                 // Add the missing one
363                 for (var i=_start, l=_tmpNb+_start; i<l; i++) {
364                     this.addItem(i);
365                 }
366                 return true;
367             } else {
368                 return false;
369             }
370         };
371 
372         this.setPlugins($plugins);
373         this.setRootNode($rootNode);
374     };
375 
376     /**
377      * Save an itemRenderer according to its id
378      * @private
379      * @param {String} id the id of the itemRenderer
380      * @param {ItemRenderer} itemRenderer an itemRenderer object
381      */
382     this.setItemRenderer = function setItemRenderer(id, itemRenderer) {
383         id = id || "default";
384         _itemRenderers[id] = itemRenderer;
385     };
386 
387     /**
388      * Get an itemRenderer
389      * @private
390      * @param {String} id the name of the itemRenderer
391      * @returns the itemRenderer
392      */
393     this.getItemRenderer = function getItemRenderer(id) {
394         return _itemRenderers[id];
395     };
396 
397     /**
398      * Expands the inner dom nodes of a given dom node, filling it with model's values
399      * @param {HTMLElement|SVGElement} node the dom node to apply foreach to
400      */
401     this.foreach = function foreach(node, idItemRenderer, start, nb) {
402         var itemRenderer = new this.ItemRenderer(this.plugins, node);
403 
404         itemRenderer.setStart(start || 0);
405         itemRenderer.setNb(nb || "*");
406 
407         itemRenderer.render();
408 
409         // Add the newly created item
410         _model.watch("added", itemRenderer.render, itemRenderer);
411 
412         // If an item is deleted
413         _model.watch("deleted", function (idx) {
414             itemRenderer.render();
415             // Also remove all observers
416             _removeObserversForId(idx);
417         },this);
418 
419         this.setItemRenderer(idItemRenderer, itemRenderer);
420      };
421 
422      /**
423       * Update the lower boundary of a foreach
424       * @param {String} id the id of the foreach to update
425       * @param {Number} start the new value
426       * @returns true if the foreach exists
427       */
428      this.updateStart = function updateStart(id, start) {
429          var itemRenderer = this.getItemRenderer(id);
430          if (itemRenderer) {
431              itemRenderer.setStart(start);
432              return true;
433          } else {
434              return false;
435          }
436      };
437 
438      /**
439       * Update the number of item to display in a foreach
440       * @param {String} id the id of the foreach to update
441       * @param {Number} nb the number of items to display
442       * @returns true if the foreach exists
443       */
444      this.updateNb = function updateNb(id, nb) {
445          var itemRenderer = this.getItemRenderer(id);
446          if (itemRenderer) {
447              itemRenderer.setNb(nb);
448              return true;
449          } else {
450              return false;
451          }
452      };
453 
454      /**
455       * Refresh a foreach after having modified its limits
456       * @param {String} id the id of the foreach to refresh
457       * @returns true if the foreach exists
458       */
459      this.refresh = function refresh(id) {
460         var itemRenderer = this.getItemRenderer(id);
461         if (itemRenderer) {
462             itemRenderer.render();
463             return true;
464         } else {
465             return false;
466         }
467      };
468 
469     /**
470      * Both ways binding between a dom node attributes and the model
471      * @param {HTMLElement|SVGElement} node the dom node to apply the plugin to
472      * @param {String} name the name of the property to look for in the model's value
473      * @returns
474      */
475     this.bind = function bind(node, property, name) {
476 
477         // Name can be unset if the value of a row is plain text
478         name = name || "";
479 
480         // In case of an array-like model the id is the index of the model's item to look for.
481         // The _id is added by the foreach function
482         var id = node.getAttribute("data-" + this.plugins.name+"_id"),
483 
484         // Else, it is the first element of the following
485         split = name.split("."),
486 
487         // So the index of the model is either id or the first element of split
488         modelIdx = id || split.shift(),
489 
490         // And the name of the property to look for in the value is
491         prop = id ? name : split.join("."),
492 
493         // Get the model's value
494         get =  Tools.getNestedProperty(_model.get(modelIdx), prop),
495 
496         // When calling bind like bind:newBinding,param1, param2... we need to get them
497         extraParam = Tools.toArray(arguments).slice(3);
498 
499         // 0 and false are acceptable falsy values
500         if (get || get === 0 || get === false) {
501             // If the binding hasn't been overriden
502             if (!this.execBinding.apply(this,
503                     [node, property, get]
504                 // Extra params are passed to the new binding too
505                     .concat(extraParam))) {
506                 // Execute the default one which is a simple assignation
507                 //node[property] = get;
508                 DomUtils.setAttribute(node, property, get);
509             }
510         }
511 
512         // Only watch for changes (double way data binding) if the binding
513         // has not been redefined
514         if (!this.hasBinding(property)) {
515             node.addEventListener("change", function (event) {
516                 if (_model.has(modelIdx)) {
517                     if (prop) {
518                         _model.update(modelIdx, name, node[property]);
519                     } else {
520                         _model.set(modelIdx, node[property]);
521                     }
522                 }
523             }, true);
524 
525         }
526 
527         // Watch for changes
528         this.observers[modelIdx] = this.observers[modelIdx] || [];
529         this.observers[modelIdx].push(_model.watchValue(modelIdx, function (value) {
530             if (!this.execBinding.apply(this,
531                     [node, property, Tools.getNestedProperty(value, prop)]
532                     // passing extra params too
533                     .concat(extraParam))) {
534                 //node[property] = Tools.getNestedProperty(value, prop);
535                 DomUtils.setAttribute(node, property, Tools.getNestedProperty(value, prop));
536             }
537         }, this));
538 
539     };
540 
541     /**
542      * Set the node's value into the model, the name is the model's property
543      * @private
544      * @param {HTMLElement|SVGElement} node
545      * @returns true if the property is added
546      */
547     this.set = function set(node) {
548         if (DomUtils.isAcceptedType(node) && node.name) {
549             _model.set(node.name, node.value);
550             return true;
551         } else {
552             return false;
553         }
554     };
555 
556     this.getItemIndex = function getElementId(dom) {
557         var dataset = DomUtils.getDataset(dom);
558 
559         if (dataset && typeof dataset[this.plugins.name + "_id"] != "undefined") {
560             return +dataset[this.plugins.name + "_id"];
561         } else {
562             return false;
563         }
564     };
565 
566     /**
567      * Prevents the submit and set the model with all form's inputs
568      * @param {HTMLFormElement} DOMfrom
569      * @returns true if valid form
570      */
571     this.form = function form(DOMform) {
572         if (DOMform && DOMform.nodeName == "FORM") {
573             var that = this;
574             DOMform.addEventListener("submit", function (event) {
575                 Tools.toArray(DOMform.querySelectorAll("[name]")).forEach(that.set, that);
576                 event.preventDefault();
577             }, true);
578             return true;
579         } else {
580             return false;
581         }
582     };
583 
584     /**
585      * Add a new way to handle a binding
586      * @param {String} name of the binding
587      * @param {Function} binding the function to handle the binding
588      * @returns
589      */
590     this.addBinding = function addBinding(name, binding) {
591         if (name && typeof name == "string" && typeof binding == "function") {
592             _bindings[name] = binding;
593             return true;
594         } else {
595             return false;
596         }
597     };
598 
599     /**
600      * Execute a binding
601      * Only used by the plugin
602      * @private
603      * @param {HTMLElement} node the dom node on which to execute the binding
604      * @param {String} name the name of the binding
605      * @param {Any type} value the value to pass to the function
606      * @returns
607      */
608     this.execBinding = function execBinding(node, name) {
609         if (this.hasBinding(name)) {
610             _bindings[name].apply(node, Array.prototype.slice.call(arguments, 2));
611             return true;
612         } else {
613             return false;
614         }
615     };
616 
617     /**
618      * Check if the binding exists
619      * @private
620      * @param {String} name the name of the binding
621      * @returns
622      */
623     this.hasBinding = function hasBinding(name) {
624         return _bindings.hasOwnProperty(name);
625     };
626 
627     /**
628      * Get a binding
629      * For debugging only
630      * @private
631      * @param {String} name the name of the binding
632      * @returns
633      */
634     this.getBinding = function getBinding(name) {
635         return _bindings[name];
636     };
637 
638     /**
639      * Add multiple binding at once
640      * @param {Object} list the list of bindings to add
641      * @returns
642      */
643     this.addBindings = function addBindings(list) {
644         return Tools.loop(list, function (binding, name) {
645             this.addBinding(name, binding);
646         }, this);
647     };
648 
649     // Inits the model
650     this.setModel($model);
651     // Inits bindings
652     this.addBindings($bindings);
653 };
654