-
Notifications
You must be signed in to change notification settings - Fork 4
/
nimbly.js
1541 lines (1201 loc) · 68.9 KB
/
nimbly.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* Nimbly
* Version 0.1.5
* https://github.com/elliotnb/nimbly
*
* Licensed under the MIT license:
* http://www.opensource.org/licenses/MIT
*
* Nimbly is a JavaScript component framework for single page applications. The objectives of Nimbly are as follows:
*
*/
var Nimbly = function($, ObservableSlim, MutationObserver, HTMLElement, HTMLUnknownElement, DOMParser, document) {
if (typeof $ === "undefined") throw new Error("Nimbly requires jQuery 1.9+.");
if (typeof ObservableSlim === "undefined") throw new Error("Nimbly requires ObservableSlim 0.1.0+.");
var baseClassInstance = 0;
// The queueRefresh function is used for queueing up refreshes of all components on the page and triggering them in batches
// in order to avoid the unnecessary page re-draws that would occur if we updated the DOM immediately when a component refresh
// is invoked
var refreshQueue = [];
var uniqueIndex = [];
var queueRefresh = function(refreshData) {
if (refreshQueue.indexOf(refreshData.instanceId) === -1) {
uniqueIndex.push(refreshData.instanceId);
refreshQueue.push(refreshData.refresh)
}
var refreshCount = refreshQueue.length;
setTimeout(function() {
if (refreshCount == refreshQueue.length) {
var i = refreshQueue.length;
while (i--) refreshQueue.pop()();
uniqueIndex.length = 0;
}
},10);
};
//monitoredInsertion is an array of every component that has been rendered with a _afterInDocument defined that has not
// already been inserted into the document.
var monitoredInsertion = [];
// observe all mutations on the document, we use this as a means to trigger the `_afterInDocument` method
// when a component has been inserted to the page
var documentObserver = new MutationObserver(function(mutations) {
var i = monitoredInsertion.length;
// loop over every component that we're monitoring for insertion into the DOM
while (i--) {
// if the component has not been deleted (via .destroy()) and it is now in the document
if (monitoredInsertion[i].jqDom !== null && document.body.contains(monitoredInsertion[i].jqDom[0])) {
// invoke the _afterInDocument lifecycle method for the component
monitoredInsertion[i]._afterInDocument();
// the component is now in the document so we can remove it from the array of components we're monitoring
// for insertion into the document
monitoredInsertion.splice(i,1);
}
}
});
documentObserver.observe(document, {attributes: false, childList: true, characterData: false, subtree:true});
/* Function: _getTemplate(templateElmtId)
Simple utility function for retrieving component templates (typically Mustache templates).
This function is only used for retrieving templates included on the HTML page via special script tags:
<script type="text/template" id="template_id_goes_here">
<div class="hello">Hello {{first_name}}</div>
</script>
Templates included via script tags can be used for supporting ES5 browsers in lieu of including the template
directly in the component definition using template literals for multi-line support (ES5 browsers do not support
template literals nor multi-line strings and would require transpiling).
Parameters:
templateElmtId - String - The DOM element ID of the template.
Returns:
String, raw HTML template.
*/
var _getTemplate = function(templateElmtId) {
var templateElmt = document.getElementById(templateElmtId);
if (templateElmt) {
var template = templateElmt.innerHTML.trim();
} else {
throw new Error("Nimbly::_getTemplate() could not find the template with element ID: '"+templateElmtId+"'.");
}
return template;
};
var isValidComponent = function(component) {
if (typeof component.render !== "function" || typeof component.init !== "function" || typeof component.isReady !== "function") {
return false;
}
return true;
};
/* Constructor:
Initializes an object instance of T4M component base class.
*/
var constructor = function(className, defaults, data, options) {
var self = this;
// if the user didn't an object for state data, then instantiate an empty plain object
if (typeof(data) == "undefined") var data = {};
// if the user didn't provide any options, then instantiate an empty options object so we don't error out below
if (typeof(options) == "undefined") var options = {};
// we keep a reference to the original 'data' object (so that changes to the data are relayed back to
// whatever initialized this component), but at the same time we merge in 'defaults' (which might contain
// more fields than are contained in 'data') but *without* overwriting anything in 'data'.
var dataSuperSet = {};
$.extend(true, dataSuperSet, defaults.data, data);
$.extend(true, data, dataSuperSet);
// merge the default options with custom options passed into the component constructor. we do not want to keep a reference
// to anything on defaults or options, so we can perform a deep copy instead of a shallow merge
this.options = $.extend(true, {}, defaults, options, {"data":data});
/* Property: this._data
Object, holds all of the data required to render the component. However, it should *never* be modified directly. All changes to this._data
should be made through this.data below.
*/
this._data = data;
/* Property: this.className
String, the name of the class that initialized the base class. We store this value for debugging and logging purposes.
*/
this.className = className;
/* Property: this._baseClassInstance
Integer, counts the number of times the base class has been initialized. Useful for debugging, identifying unique instances.
*/
this._baseClassInstance = baseClassInstance++;
/* Property: this.jqDom
jQuery-referenced DocumentFragment of the page generated by this class.
*/
this.jqDom = null;
this.domNode = null;
/* Property: this.initialized
Boolean, set to true when the this.init() method has completed and the component is ready to render.
*/
this.initialized = false;
/* Property: this.initList
Array, list of "fetch" method names that should be invoked in order to initialize the components. Typically ajax calls.
*/
this.initList = this.options.initList || [];
/* Property: this._pendingInit
Boolean, set to true when this.init() is still actively processing.
*/
this._pendingInit = false;
/* Property: this._pendingFetchCount
Integer, a count of the number of unresolved and still in-progress fetch promises.
*/
this._pendingFetchCount = 0;
/* Property: this._initRendered
Boolean, set to true when the component has fully rendered. This property is important for determining whether
or not a child component needs to be re-rendered (refreshed) when a parent component is refreshed.
*/
this._initRendered = false;
/* Property: this._delayRefresh
Boolean, set to true when there are one or more data requests in progress and we should not refresh the UI until they are all complete.
Helps us avoid a situation where a UI refresh is processed immediately before a fetch method completes which would generate a UI refresh of its own.
*/
this._delayRefresh = false;
/* Property: this._renderRunning
Boolean, set to true when the component's _render() method is running. This boolean is used to catch this.data mutations while the component is rendering.
Such mutations are liable to result in an infinite loop via uiBindings and are disallowed.
*/
this._renderRunning = false;
/* Property: this._registeredDuringRender
Boolean, set to false by default, set to true only if this component is registered as a child component to another parent component AND it was
registered as a child during render process (i.e., _render method) of that parent component. See issue #26 for a full explanation.
*/
this._registeredDuringRender = false;
/* Property: this._insertedIntoParent
Boolean, if this component has been registered as a child and was registered during the render process of its parent (i.e., this._registeredDuringRender === true)
then this property is set to true when this component has been inserted into the DOM of its parent (not necessairly the permanent DOM,
but also including the temporary DOM generated during re-renders). See issue #26 for a full explanation.
*/
this._insertedIntoParent = false;
/* Property: this._renderjQuery
Boolean, set to true if the component `_render` method returns jQuery encapsulated HTMLElement, set to false (the default) if the component renders
plain HTMLElements.
*/
this._renderjQuery = this.options.renderjQuery || false;
this._cleanUpChildren = null;
/* Property: this.childComponents
Object, if the rendering of this component requires other components (children), then those child components should
be registered on the parent component and tracked on this property. Tracking the child components here enables this
base class to clean up and delete any orphaned components after a ._refresh() occurs.
*/
this.childComponents = {"default":[]};
/* Property: this._tagName
String, if a component is registered as a child of a parent component, it must be given a tag name so that Nimbly knows
where to insert it in the template of the parent component. This property gets set when the parent component invokes `registerChild`
and passes in this child component.
*/
this._tagName = null;
/* Property: this._refreshList
Array or boolean, used to store CSS selectors for the portions of the component that must be updated given the recent data change. If set
to true (boolean), then that implies the entire component needs to be refreshed.
*/
this._refreshList = [];
/* Property: this.templates
Hash, where the key is the name of the template and the value is a string containing the template. The hash contains each template used by the component.
The template element identifiers are passed in via options.templates and below we will populate this.templates with the content of the template.
*/
this.templates = {};
// if the component defined the templates as an array, then that means the component has identifier their templates
// using element IDs of <script> tags -- we now need to go retrieve the contents of those templates
if (this.options.templates instanceof Array) {
// if no templates were provided, throw an error because we can't continue without something to render.
if (this.options.templates.length == 0) {
throw new Error("Nimbly::constructor cannot continue -- no templates provided.");
// else loop over each template element identifier and add the content of the template to this.templates
} else {
for (var i = 0; i < this.options.templates.length; i++) {
// _getTemplate will throw an error if the template doesn't exist
this.templates[this.options.templates[i]] = (_getTemplate(this.options.templates[i]));
}
}
// else the templates were passed in as a hash using name value pairs with template literals containing the template content
} else {
this.templates = this.options.templates;
}
/* Property: this.loadingTemplate
String, template content OR an element identifier of the template used to display a loading spinner or loading message. The loadingTemplate
is utilized only if the .render() method is invoked before the component has been initialized. It allows the component to return
a rendered DomNode that will be subsequently updated as soon as initialization completes.
*/
if (typeof(this.options.loadingTemplate) === "string") {
// throw an error if the loading template is an empty string
if (this.options.loadingTemplate.length === 0) {
throw new Error("Nimbly::constructor cannot continue -- the loadingTemplate cannot be an empty string.");
}
// if the user supplied an element identifier, then go fetch the content of the
if (document.getElementById(this.options.loadingTemplate)) {
this.loadingTemplate = _getTemplate(this.options.loadingTemplate);
} else {
this.loadingTemplate = this.options.loadingTemplate;
}
} else {
this.loadingTemplate = "<div class=\"default_nimbly_loading_template\"></div>";
}
/* Property: this._uiBindings
Object, dictates what part of the rendered component should be updated when a given data change occurs. Each property on this object
corresponds to a property on this.data.
Example: this._uiBindings =
"participant_list":[".t4m-jq-cm-convo-participant-container",".t4m-jq-cm-create-convo-send-btn"]
,"person_id":true
};
In the above example, a change to this.data.participant_list will trigger an update of elements with classes ".t4m-jq-cm-convo-participant-container"
and ".t4m-jq-cm-create-convo-send-btn" while an update to "person_id" will trigger a full refresh of the component.
*/
this._uiBindings = this.options.uiBindings || {};
/* Property: this._dataBindings
Object, dictates which 'fetch' methods should be executed when a change occurs to a given value on this.data.
In the example below, a change to this.data.person_id will trigger the method this._fetchPersonList. The fetch methods are defined by the
child component. The "delayRefresh":true tells the component that we should not refresh the component until the fetch method completes and
the new data has been retrieved. It will also trigger a modal loading cover/spinner preventing any user input while the new data is being retrieved.
,"dataBindings":{
"person_id":{"delayRefresh":true // if person_id changes, invoke this._fetchPersonList, true means that will blur component while data is fetched
,"methods":["_fetchPersonList"]
}
}
*/
this._dataBindings = this.options.dataBindings || {};
/* Property: this.data
ES6 Proxy for this._data
The this._data parameter is where all the data for the component is stored. It holds all the data required for the component to render itself.
However, this._data is only accessed via the property this.data which is an ES6 Proxy created by the ObservableSlim library. this.data allows us to
monitor for changes to our data and trigger UI updates as necessary. Whenever this.data is modified, the handler function below is invoked with an
array of the changes that were made.
For example, this modification:
this.data.blah = 42;
Would invokve the handler function with the following single 'changes' argument:
[{"type":"add","target":{"blah":42},"property":"blah","newValue":42,"currentPath":"testing.blah"}]
Note for IE11 users: all properties must be defined at the time of initialization else their changes will not be observed.
*/
this.data = ObservableSlim.create(this._data, false, function(changes) {
// if the component is attempting to modify this.data from inside the ._render method, then we need to throw an error.
// doing so is a bad practice -- it's liable to result in an infinite loop
if (self._renderRunning === true) {
throw new Error(self.className + "._render() is attempting to modify this.data. Mutating this.data while rendering is disallowed because it's likely to generate infinite loops via uiBindings.");
}
// we don't process any changes until the component has marked itself as initialized, this prevents
// a problem where the instantiation of the base class and passing in default this.data values triggers a change
// and refresh before anything has even loaded
if (self.initialized == true) {
// by default any qualified fetch requests will not cause a refresh delay
var delayRefresh = false;
// fetchList is used to store the names of the methods we must invoke to retrieve new data. the updates done by the _fetch* methods
// will typically then trigger ui refreshes after the ajax request returns
var fetchList = {};
// loop over every change that was just made to this.data and see if it qualifies against any data bindings.
// if it does match any data bindings, then we will need to update the appropriate portions of the rendered component.
var i = changes.length;
while (i--) {
// loop over each ui binding and see if any of the changes qualify to trigger a refresh
for (var uiBinding in self._uiBindings) {
// check if the user passed in a regular expression
var regExpBinding = false;
if (uiBinding.charAt(0) === "/" && uiBinding.charAt(uiBinding.length-1) === "/") {
// if it is a regular expression, then test it against the current path
var regExpBinding = (new RegExp(uiBinding.substring(1, uiBinding.length-1))).test(changes[i].currentPath);
}
// if we're not already refreshing the entire component and the recent change was made to a data property
// that we've bound, then we need to go update the appropriate portion of the UI.
if (self._refreshList !== true && (uiBinding == changes[i].currentPath || regExpBinding)) {
// if the data binding is simply set to 'true', then that means the entire component must be refreshed.
if (self._uiBindings[uiBinding] === true) {
self._refreshList = true;
// else add the CSS selectors from the data binding to the full list of CSS selectors that we'll be refreshing
} else {
self._refreshList = self._refreshList.concat(self._uiBindings[uiBinding]);
}
}
}
// loop over each data binding and see if any of the changes qualify to trigger a data request
for (var dataBinding in self._dataBindings) {
// check if the user passed in a regular expression
var regExpBinding = false;
if (dataBinding.charAt(0) === "/" && dataBinding.charAt(dataBinding.length-1) === "/") {
// if it is a regular expression, then test it against the current path
var regExpBinding = (new RegExp(dataBinding.substring(1,dataBinding.length-1))).test(changes[i].currentPath);
}
// if the recent change was made to a data property that we've bound, then we need to go update the appropriate portion of the UI.
if (dataBinding == changes[i].currentPath || regExpBinding) {
// check if this data binding requires us to delay refreshing the page
if (self._dataBindings[dataBinding].delayRefresh == true) delayRefresh = true;
// append to the fetchList array which fetch methods we will need to invoke
// fetchList = fetchList.concat(self._dataBindings[dataBinding].methods);
var a = self._dataBindings[dataBinding].methods.length;
while (a--) {
if (typeof fetchList[self._dataBindings[dataBinding].methods[a]] === "undefined") {
fetchList[self._dataBindings[dataBinding].methods[a]] = [];
}
fetchList[self._dataBindings[dataBinding].methods[a]].push(changes[i]);
}
}
}
}
// fire off methods to retrieve more data (if needed) and refresh the component (if needed)
self._fetch(delayRefresh, fetchList);
// if we have a list of changes to process or if the whole component needs to be refreshed, then queue up the refresh
if (self._refreshList === true || self._refreshList.length > 0) {
self.pendingRefresh = true;
queueRefresh({
"instanceId": self._baseClassInstance
,"refresh":function() { self._refresh();}
});
}
};
});
// Unless we've been told to delay the initialization of the component, fire off initialization immediately
var delayInit = this.options.delayInit || false;
if (delayInit == false) this.init();
};
/* Method: this.observe
Allows external entities to observe changes that occur on the this.data property. This method is defined
in the constructor rather than prototype because it must have access to "var data" which is only available in the constructor
and is unique to each instantiation of the base class.
This method is useful when one class has instantiated several others and it must monitor for any data changes that occur to those classes.
Parameters:
fnChanges - a function that is invoked with with a single argument whenever 'var data' is modified. The single argument will have
the following format:
[{"type":"add","target":{"blah":42},"property":"blah","newValue":42,"currentPath":"testing.blah"}]
Returns:
Nothing.
*/
constructor.prototype.observe = function(fnChanges) {
return ObservableSlim.create(this._data, true, fnChanges);
};
/* Method: this.init
Runs any initialization procedures required before the component renders for the first time. Typically this means retrieving data from external APIs.
*/
constructor.prototype.init = function() {
var self = this;
// run a quick sanity check, verify that the dataBindings defined by the component refer to actual methods on the component
// if they don't exist, then we want to throw a warning notifying the developer of the potential misconfiguration
for (var dataBinding in this._dataBindings) {
for (var b = 0; b < this._dataBindings[dataBinding].methods.length; b++) {
if (typeof this[this._dataBindings[dataBinding].methods[b]] !== "function") {
throw new Error("Nimbly::init cannot continue. Please review the dataBindings on class "+self.className+". The method "+this._dataBindings[dataBinding].methods[b]+" does not exist or is not a function.");
}
}
}
// this is where we'll store 'active promises'. active promises must be resolved before the component renders for the first time
var listActivePromises = [];
// this is where we'll store 'passive promises'. passive promises do not need to be resolved before the component renders for the first time
var listPassivePromises = [];
// loop over each fetch method required for initialization
for (var i = 0; i < this.initList.length; i++) {
// if the initList item has specified a conditional, then we need to evaluate it
// if it returns true, then we proceed to create the new promise. if it returns false, then we can
// skip it and go to the next initList item
if (typeof this.initList[i].condition === "function") {
if (this.initList[i].condition.call(self) == false) continue;
}
// create a promise for the fetch method required for initialization
var promise = new Promise((function(i) {
return function(resolve, reject) {
self[self.initList[i].method](resolve,reject);
};
})(i)).catch((function(i) {
return function(error) {
console.error(error);
throw new Error("Nimbly::init cannot continue, "+self.className+"."+self.initList[i].method+"() failed to resolve. Error: " + error);
}
})(i));
// if this initialization method should prevent the component from rendering, then add it to the array of active promises
if (this.initList[i].preventRender == true) {
listActivePromises.push(promise);
// else add it to the array of passive promises
} else {
listPassivePromises.push(promise);
}
}
// if there are promises that must resolve before the component renders, then we need to start them and mark the initialization as pending
if (listActivePromises.length > 0) {
// we have one or more in-progress promises, so increment the count
this._pendingFetchCount++;
this._pendingInit = true;
// Create a Promise all that resolves when all of the promises resolve
var initListPromise = Promise.all(listActivePromises).then(function() {
initListPromise.done = true;
self.initialized = true;
self._pendingInit = false;
self._refreshList = true;
self.pendingRefresh = true;
queueRefresh({
"instanceId": self._baseClassInstance
,"refresh":function() { self._refresh();}
});
// if the child class supplied a custom follow up initialization method, then invoke it
if (typeof(self._init) == "function") self._init();
// the promises have all been fulfilled so we decrement the outstanding promise count
self._pendingFetchCount--;
}).catch(function(failedPromise) {initListPromise.done = true; console.error(failedPromise);});
setTimeout(function() {
if (initListPromise.done !== true) {
console.warn(self.className + " initList methods have not resolved after 15 seconds. Please ensure that your initList methods properly resolve or reject.");
}
}, 15000);
// else there's no data that we need to initialize so we can mark the component as initialized
} else {
this.initialized = true;
// if the child class supplied a custom follow up initialization method, then invoke it
if (typeof(self._init) == "function") self._init();
}
if (listPassivePromises.length > 0) {
this._pendingFetchCount++;
var initListPassivePromises = Promise.all(listPassivePromises).then(function() {
initListPassivePromises.done = true;
self._pendingFetchCount--;
}).catch(function(failedPromise) {initListPassivePromises.done = true; console.error(failedPromise);});
setTimeout(function() {
if (initListPassivePromises.done !== true) {
console.warn(self.className + " initList methods have not resolved after 15 seconds. Please ensure that your initList methods properly resolve or reject.");
}
}, 15000);
}
};
/* Method: this._fetch
This method is invoked when we need to retrieve additional data from remote sources. This method is typically after
a change to this.data that qualifies on a this.dataBinding item.
This is a private method (denoted by the underscore _), not intended to be executed externally.
Parameters:
delayRefresh - Boolean, set to true when these fetch requests should complete before the component is refreshed. Set to false if the component
is allowed to refresh while the fetch methods are in-progress.
fetchList - Array of strings, a list of the fetch methods that should be executed.
*/
constructor.prototype._fetch = function(delayRefresh, fetchList) {
var self = this;
// will this fetch require a load mask?
var loadMask = false;
// if these fetches should finish before any UI refresh and there are changes present, then we need to
// mark this._delayRefresh to true to prevent any refreshes from kicking off *and* produce a load mask (if the
// component has even supplied a load mask function)
if (delayRefresh == true && Object.keys(fetchList).length > 0) {
this._delayRefresh = delayRefresh;
// if the component has defined a load mask method to display during refresh delays, then we run the method to display the load mask
if (typeof this.showLoadMask === "function") this.showLoadMask();
loadMask = true;
}
if (Object.keys(fetchList).length > 0) {
this._pendingFetchCount++;
var listPromises = [];
// loop over each fetch method passed into this method
for (var fetchMethod in fetchList) {
// Create a promise for the data fetch method
var fetchPromise = new Promise((function(fetchMethod) {
return function(resolve, reject) {
if (typeof(self[fetchMethod]) == "function") {
self[fetchMethod](resolve,reject,fetchList[fetchMethod]);
} else {
throw new Error("Nimbly::_fetch cannot continue, the method "+self.className+"."+fetchMethod+"() does not exist or is not a function.");
}
}
})(fetchMethod)).catch((function(fetchMethod) {
return function(error) {
console.error(error);
throw new Error("An error occured in the "+self.className+"."+fetchMethod+"() method.");
}
})(fetchMethod));
// add the promise to the full list of promises
listPromises.push(fetchPromise);
};
// Create a Promise all that resolves when all of the fetch promises resolve
var dataBindingPromise = Promise.all(listPromises).then(function() {
dataBindingPromise.done = true;
// the fetches have all completed and the component is now safe to refresh UI, so we can turn off delayRefresh
self._delayRefresh = false;
// if we created a load mask earlier on, then we now need to remove it
if (loadMask == true && typeof self.hideLoadMask === "function") self.hideLoadMask();
// if there are pending UI updates (possibly triggered by these fetches), then kick off the refresh process
if (self._refreshList == true || self._refreshList.length > 0) {
queueRefresh({"instanceId": self._baseClassInstance,"refresh":function() { self._refresh();}});
}
self._pendingFetchCount--;
}).catch(function(failedPromise) {dataBindingPromise.done = true; console.error(failedPromise);});
setTimeout(function() {
if (dataBindingPromise.done !== true) {
console.warn(self.className + " dataBinding methods have not resolved after 15 seconds. Please ensure that your dataBinding methods properly resolve or reject.");
}
}, 15000);
}
};
/* Method: this.render
Renders and returns the component. The render method will first verify that the component has been initialized, if it has not been initialized, then
it will invoke this.init(). If the initialization is already in progress, then it will return a temporary 'loading' display. If
the component has already been initialized, then the standard render method this._render() is invoked.
Returns:
jQuery-referenced DocumentFragment, the component ready to be inserted into the DOM.
*/
constructor.prototype.render = function() {
var self = this;
// if the component hasn't been initialized and there's no initialization in-progress,
// then we need to initialize it before attempting a render
if (this.initialized == false && this._pendingInit == false) this.init();
// if the initialization is in progress, then render the 'loading' display
if (this.initialized == false && this._pendingInit == true) {
var jqDom = this._renderLoadingFromComp();
// else the component is initialized and ready for the standard render
} else {
// if the component does not have any pending changes and it has already been fully rendered once
// then we don't need to re-render this component, we can just return what has already been rendered
if (this._refreshList instanceof Array && this._refreshList.length == 0 && this._initRendered == true) {
var jqDom = this.jqDom;
// else the component does have pending changes or has not been fully rendered yet -- so we must invoke the normal .render() method.
} else {
var jqDom = this._renderFromComp();
// insert (if any) child components that have been registered to this component
var insertedChildren = this._insertChildren(jqDom);
var i = insertedChildren.length;
while (i--) {
insertedChildren[i].comp.jqDom = insertedChildren[i].elmt;
insertedChildren[i].comp.domNode = insertedChildren[i].elmt[0];
};
// invoke the lifecycle method informing the component rendering has just finished
if (typeof this._renderFinalize === "function") this._renderFinalize(jqDom);
// _renderFinalize was renamed to _afterRender on 10/21/2018 -- _renderFinalize is deprecated and will eventually be removed
if (typeof this._afterRender === "function") this._afterRender(jqDom);
// if this is the first time the component has been rendered, then add this component to the insertion monitoring list
if (this._initRendered === false) {
self._monitorInsertion(this);
}
// the component has now been fully rendered, so mark the initial render boolean as true
this._initRendered = true;
}
}
// now that we've rendered the component, reset the refresh list back to an empty array, otherwise it'll be left to true
// and the next state mutation on the component (or parent component) will trigger a full refresh of this component
this._refreshList = [];
this.jqDom = jqDom;
this.domNode = jqDom[0];
// By default, the render method should return a native DOM Node. But if this._renderjQuery is set to true, then the component
// is using jQuery for their rendering processes and we want to return a DOMNode referenced by jQuery.
if (this._renderjQuery === false) {
return this.domNode;
} else {
return this.jqDom;
}
};
constructor.prototype._renderWithChildren = function() {
// if the component hasn't been initialized and there's no initialization in-progress,
// then we need to initialize it before attempting a render
if (this.initialized == false && this._pendingInit == false) this.init();
// if the initialization is in progress, then render the 'loading' display
if (this.initialized == false && this._pendingInit == true) {
var jqDom = this._renderLoadingFromComp();
// else the component is initialized and ready for the standard render
} else {
var jqDom = this._renderFromComp();
// insert (if any) child components that have been registered to this component
var insertedChildren = this._insertChildren(jqDom);
// invoke the lifecycle method informing the component rendering has just finished
if (typeof this._renderFinalize === "function") this._renderFinalize(jqDom);
// _renderFinalize was renamed to _afterRender on 10/21/2018 -- _renderFinalize is deprecated and will eventually be removed
if (typeof this._afterRender === "function") this._afterRender(jqDom);
}
return {
"elmt": jqDom
,"insertedChildren":insertedChildren
};
};
/* Function this._renderFromComp
This is a simple wrapper function for invoking the _render method on child components and determining if the rendered component
returned is valid (i.e., a plain HTMLElement or jQuery-referenced DOM element.
Returns:
jQuery-referenced DOM element
*/
constructor.prototype._renderFromComp = function() {
this._renderRunning = true;
var jqDom = this._render();
this._renderRunning = false;
this._validateRender(jqDom);
this._cleanOrphans();
return $(jqDom);
};
constructor.prototype._renderLoadingFromComp = function() {
// if the component has defined a loading render method, then we use that first
if (typeof this._renderLoading === "function") {
var jqDom = this._renderLoading();
this._validateRender(jqDom);
} else {
var jqDom = $(this.loadingTemplate);
}
return $(jqDom);
};
/* Method: this._monitorInsertion
This method is invoked to add a component to the array of components (monitoredInsertions) that are monitored for insertion into the DOM.
When the components are inserted into the DOM, the framework will invoke their `_afterInDocument` lifecycle method.
*/
constructor.prototype._monitorInsertion = function(component) {
var self = this;
// only monitor the component for insertion into the DOM if it has the _afterInDocument lifecycle method defined and if it
// hasn't already been added to the monitoredInsertion queue and isn't already in the document
if (typeof component._afterInDocument === "function"
&& monitoredInsertion.indexOf(component) === -1
&& (component.jqDom === null || !document.body.contains(component.jqDom[0]))
) {
monitoredInsertion.push(component);
}
// check if this component has any child components (or nested child components) with the _afterInDocument lifecycle method defined
component.eachChildComponent(function(childComponent) {
self._monitorInsertion(childComponent);
}, true);
};
constructor.prototype._validateRender = function(jqDom) {
// if the component ._render and ._renderLoading methods are supposed to return a jQuery-referenced HTMLElement and it's not jquery-referened then throw an error
if (this._renderjQuery === true && (!(jqDom instanceof $))) {
throw new Error(this.className + ".render() cannot continue. "+this.className+"._render() did not return a jQuery-encapsulated HTMLElement but your component settings indicate that it should have (options.renderjQuery === true).");
// else if component's ._render and ._renderLoading methods are supposed to return plain HTMLElements and it didn't return a plain HTMLElement, then we need to throw an error.
} else if (this._renderjQuery === false && (jqDom instanceof $ || (!(jqDom instanceof HTMLElement)))) {
throw new Error(this.className + ".render() cannot continue. "+this.className+"._render() did not return an HTMLElement. Use this.parseHTML to render your component. Otherwise, if you intend to render a jQuery-encapsulated HTMLElement, then remember to set options.renderjQuery to true.");
}
// verify that the render method returned exactly one top-level element
if ($(jqDom).length !== 1) {
throw new Error(this.className + ".render() cannot continue. "+this.className+"._render() must return a single HTMLElement or jQuery-referenced DOM element -- instead it returned "+jqDom.length+" elements. Ensure that your component template is wrapped (encapsulated) by a single element.");
}
};
/* Method: this._render()
This render method on the base class should be over-written by the child class. This is where the
component (not the temporary 'loading' display) is rendered and returned.
The primary difference between this.render() and this._render() is that this.render() will 1. determine if the component is initalized
, 2. render a loading page if necessary and 3. update the this.jqDom property. this._render() does not handle any of that. It simply
renders the full component as it would if the component is fully initialized. It does not update this.jqDom. This distinction becomes
important in the this._refresh() method where sometimes we don't want to overwrite the entire this.jqDom but instead refresh portions of it.
Finally, this.render() is a public method while this._render() is a private method -- this._render() should not be invoked externally.
Returns:
jQuery-referenced DocumentFragment, the component ready to be inserted into the DOM.
*/
constructor.prototype._render = function() {
// If the child class has not overwritten this method, then we just assume that it's a component
// with one static HTML template
// use Object.keys to grab the first template
var jqDom = $(this.templates[Object.keys(this.templates)[0]]);
return jqDom;
};
/* Function: this.parseHTML
Utility method intended to assist components with the process of converting HTML into an HTMLElement that the components
can then bind event handlers to.
Parameters:
strHTML - a string of HTML that should be converted into an HTMLElement. Should only have one top-level element.
Returns:
HTMLElement
*/
var parser = new DOMParser();
constructor.prototype.parseHTML = function(strHTML) {
return parser.parseFromString(strHTML, "text/html").body.firstChild;
};
/* Function: this.getTemplateComponents
Utility method that will fetch a breakdown of all the child components defined in a given template.
Parameters:
templateName - required, string, the name of the template that should be evaluated. The template should already be defined (or
registered at runtime via this.addTemplate) on the component (i.e., present on this.templates).
Returns:
Array of objects -- array contains a breakdown of all child components defined in the template as well as any attribute values
defined in the template.
*/
constructor.prototype.getTemplateComponents = function(templateName) {
// throw an error is template name isn't valid
if (typeof(templateName) !== "string" || templateName.length == 0) throw new Error("Nimbly::getTemplateComponents() cannot continue. templateName must be a string.");
// throw an error if the template doesn't exist
if (typeof(this.templates[templateName]) === "undefined") throw new Error("Nimbly::getTemplateComponents() cannot continue. Template '"+templateName+"' does not exist.");
// parse the template so we can iterate over each dom node
var templateDom = this.parseHTML(this.templates[templateName]);
var childComponents = [];
// recursive function that we'll use to iterate over each node in the template
var walkTheDom = function(node, func) {
func(node);
node = node.firstChild;
while (node) {
walkTheDom(node, func);
node = node.nextSibling;
}
};
// kick off the dom walk, for each node we evaluate whether or not it's a standard HTML tag
// if a node isn't a standard html element, then we assume it's a child component
// TO DO: identify repeatable sections and the child components that belong to repeatable sections
walkTheDom(templateDom, function(node) {
// if the node is a non-standard element OR it's a table, tbody, select, etc containing the "is"
// attribute), then we assume it's a child component
// TO DO: limit the checks for "is" attributes to the specific elements (table, tbody, select, etc)
if ((
typeof(node.tagName) !== "undefined"
// remove hyphens from the tag name, resolves a bug in Chrome where if the tagname contains a
// hyphen, then it is not an instance of HTMLUnknownElement, but instead a plain HTMLElement
&& document.createElement(node.tagName.replace(/\W/g, '')) instanceof HTMLUnknownElement
)
||
(
typeof node["attributes"] !== "undefined"
&& typeof node.attributes["is"] === "object"
))
{
// if this is a special element that we've defined with the "is" attribute (table, tbody, select, etc elements have special treatment by
// the browser and cannot be replaced with generic tags)
if (typeof node.attributes["is"] === "object") {
var childComponent = {"tagName":node.attributes["is"].value.toUpperCase()};
} else {
var childComponent = {"tagName":node.tagName};
}
childComponent.attributes = {};
// make a copy of all the attributes defined on the node
for (var i = 0; i < node.attributes.length; i++) {
childComponent.attributes[node.attributes[i].name] = node.attributes[i].value;
}
childComponents.push(childComponent);
}
});
return childComponents;
};
/* Function: this.addTemplate
Templates are typically defined statically in the component config. This method allows us to add more templates
during runtime.
Parameters:
templateName - string, required, a descriptive alphanumeric for the template, dashes and underscores allowed (e.g., my_template_name).
templateHtml - string, required, the HTML content of the template.
Returns:
Nothing.
*/
constructor.prototype.addTemplate = function(templateName, templateHtml) {
// throw an error if the user attempts to pass in an invalid template
if (typeof(templateHtml) !== "string") throw new Error("Nimbly::addTemplate() cannot continue. templateHtml must be a string.");
// throw an error is template name isn't valid
if (typeof(templateName) !== "string" || templateName.length == 0) throw new Error("Nimbly::addTemplate() cannot continue. templateName must be a string.");
// log a warning if the user is about to overwrite an existing template
if (typeof(this.templates[templateName]) !== "undefined") console.warn("Nimbly::addTemplate() is about to overwrite template '" + templateName + "'.");
this.templates[templateName] = templateHtml;
};
/* Method: this._insertChildren
This method will iterate over all child components that have been registered to this component, search
for a corresponding tag name (or tag name within a repeatable section) and replace that custom tag name
with a rendered child component.
Parameters:
jqDom - jQuery-referenced DOM element of the component.
*/
constructor.prototype._insertChildren = function(jqDom) {
var insertedChildren = [];
// loop over each repeatable section thats been registered on this component
for (var sectionName in this.childComponents) {
// if the section name is 'default' then that's not a repeatable section (default is the deafult holder for child components in non-repeatable sections), so skip and continue
if (sectionName === "default") continue;
// this array will hold the rendered content of each iteration of this repeatable section
var sectionContent = [];
// the sectoonName should match up with precisely one custom HTML tag returned by the initial render of the component
var repeatSection = jqDom.find(sectionName);
if (repeatSection.length === 0) {
repeatSection = jqDom.find("table[is='"+sectionName+"'], tbody[is='"+sectionName+"'], select[is='"+sectionName+"'], ul[is='"+sectionName+"'], ol[is='"+sectionName+"']");
}
// if we found one tag matching the repeatable section, then we can proceed to populate that tag with iterations of child components
if (repeatSection.length === 1) {
// loop over each set of child components registered to this repeatable sectionName
// each set of components (sectionItemComponents below) represents one iteration of the repeatable section
var sectionComponents = this.childComponents[sectionName];