-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathstarcounter-include.html
591 lines (559 loc) · 29.1 KB
/
starcounter-include.html
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
<!--
`starcounter-include` -
Custom Element (w/ Polymer's TemplateBinding)
with predefined template content, which should be used to include partials.
It's just <imported-template> wrapped within Shadow Root for `declarative-shadow-dom` bindable by layout editor.
Uses Shadow DOM given from DB as `Starcounter.MergedPartial.{_compositionProvider_}.Composition`.
version: 5.4.0
-->
<!-- Import dependencies -->
<link rel="import" href="../imported-template/imported-template.html">
<script>
(function() {
function warnAboutDeprecatedPartial(element) {
console.warn('`partial` attribute and property are deprecated from `starcounter-include` in favour of (viewModel property or view-model attribute), they will soon be no longer be supported', element);
}
const BLOCKING_LINKS_SELECTOR = 'link[rel="stylesheet"]';
const DEFAULT_SHADOW_DOM_PRESENTATION_SELECTOR = 'template[is="declarative-shadow-dom"][presentation=default],template[is="declarative-shadow-dom"]:not([presentation])';
const isWebkit = navigator.vendor && navigator.vendor.indexOf("Apple") > -1;
// still needed for .selectNode
const isSafari = isWebkit && navigator.userAgent && !navigator.userAgent.match("CriOS");
const fallBackComposition = '<style>:host{display:block;}</style><slot></slot>';
/**
* Returns a rejectable promise to load (or fail to load) all blocking
* link elements in given scope.
* @param {Node} scope scope to be queried for the elements
* @return {Promise|null} `.reject()`able promise that all links will be loaded,
* or `null` if there are none
*/
function blockingLinksLoaded(scope){
const links = scope.querySelectorAll(BLOCKING_LINKS_SELECTOR);
if(links.length === 0){
return null;
}
let rejectDefer;
const promise = new Promise((resolveAll, rejectAll) => {
Promise.all(Array.from(links).map((link)=>{
return new Promise((resolve, reject)=>{
link.addEventListener('load', resolve);
link.addEventListener('error', resolve);
});
})).then(resolveAll);
rejectDefer = rejectAll;
});
promise.reject = rejectDefer;
return promise;
}
class StarcounterInclude extends HTMLElement {
static get observedAttributes() {
return ['partial', 'view-model', 'partialId', 'loading-presentation', 'loading-content'];
}
/**
* Create shadowRoot, define property setters, set initial partial.
*/
constructor() {
super();
this.defaultComposition = null;
var partialId = this.partialId;
var viewModel = this.viewModel || this.partial || undefined;
if(this.partial) {
warnAboutDeprecatedPartial(this);
}
checkForNonNamespaced(viewModel, this);
this.attachShadow({mode: "open"});
// stamp fallback composition
// like this.stampComposition() without an event
this.shadowRoot.innerHTML = fallBackComposition;
// define a setter for partial attribute,
// so it could be change by VanillaJs without Polymer Template Binding
Object.defineProperties(this, {
partial: {
set: function(newValue) {
warnAboutDeprecatedPartial(this);
this.viewModel = newValue;
},
get: function() {
return this.viewModel;
}
},
viewModel: {
set: function(newValue) {
viewModel = newValue;
checkForNonNamespaced(newValue, this);
if(!this.template){
// stamp imported tempalte attach href, and eventually model
this.stampImportedTemplate();
} else {
// just update href and model ONLY if there is no href change
if(!this._updateHref()){
this.template.model = viewModel;
}
}
// update composition, use custom one until content is fetched to avoid FOUC
this.updateComposition();
},
get: function() {
return viewModel;
}
},
partialId: {
set: function(newValue) {
// do nothing if value is the same or null is being changed to undefined
if (newValue != partialId) {
partialId = newValue || null;
if (!partialId) {
this.removeAttribute("partial-id");
} else {
this.setAttribute("partial-id", partialId);
}
}
},
get: function() {
return partialId;
}
}
});
}
/**
* Stamp `imported-template` into Light DOM,
* attach `stamping` listener to gather Shadow DOM layout compositions
* (declarative-shadow-dom)
*/
stampImportedTemplate(){
if(!this.template){
const starcounterInclude = this;
this.defaultComposition = null;
const importedTemplate = document.createElement('imported-template');
importedTemplate.addEventListener('stamping', function fetchCompositions(event) {
var mergedComposition = null;
const fragment = event.detail;
const templates = fragment.querySelectorAll(DEFAULT_SHADOW_DOM_PRESENTATION_SELECTOR);
if(templates.length){
mergedComposition = document.createDocumentFragment();
for(const individualComposition of templates) {
individualComposition.setAttribute('presentation', 'default');
mergedComposition.appendChild(individualComposition.content.cloneNode(true));
};
}
starcounterInclude.defaultComposition = mergedComposition;
if (this.model !== starcounterInclude.viewModel) {
// update entire model
this.model = starcounterInclude.viewModel;
}
// put correct composition
starcounterInclude.updateComposition();
});
const href = buildURL(this.viewModel, this.mergedHtmlPrefix, this.defaultHtml);
href && (importedTemplate.href = href);
this.appendChild(importedTemplate);
this.template = importedTemplate;
}
}
connectedCallback(){
this.stampImportedTemplate();
}
}
var StarcounterIncludePrototype = StarcounterInclude.prototype;
StarcounterIncludePrototype.viewModel = null;
StarcounterIncludePrototype.partialId = null;
// StarcounterIncludePrototype.href = null;
StarcounterIncludePrototype.mergedHtmlPrefix = '/sc/htmlmerger?';
StarcounterIncludePrototype.defaultHtml = '';
/**
* @see Starcounter/starcounter-include - partialAttributeToProperty It's just a copy
* @TODO(tomalec): check if needed in Polymer 2
*/
function partialAttributeToProperty(element, attrVal) {
var attrVal = element.getAttribute("view-model") || element.getAttribute("partial");
if(!attrVal){
// no value
return undefined;
} else if (attrVal.match(/\{\{.*\}\}|\[\[.*\]\]/)) {
return null;
} else {
return JSON.parse(attrVal);
}
}
/**
* Set partial property if partial or view-model attribute was changed
*/
StarcounterIncludePrototype.attributeChangedCallback = function(name, oldVal, newVal) {
switch(name){
case "view-model":
this.viewModel = partialAttributeToProperty(this, newVal);
break;
case "partial":
if(!oldVal /* to warn only once */) {
warnAboutDeprecatedPartial(this);
}
this.viewModel = partialAttributeToProperty(this, newVal);
break;
// Not to overwrite existing compositions, change BlendingEditor,
// partialToStandaloneHTMLProvider, and due to lack of
// custom-elements default styles
// https://github.com/w3c/webcomponents/issues/468
// , we need to make single rule inline
case "loading-presentation":
this.style.visibility = (this.hasAttribute('loading-presentation')) ?
'hidden' : 'visible';
}
};
/**
* Builds merged HTML URL.
* If the URL is different than previous importedTemplate.href,
* clears the template and #defaultComposition, then updates it.
* TODO: Check if merged HTML URL should be changed,
* Check if needed: Does nothing if there is no template
* @returns {Boolean} was the href changed?
*/
StarcounterIncludePrototype._updateHref = function() {
// do nothing if there is no tempalte yet
if(!this.template){
return;
}
var href = buildURL(this.viewModel, this.mergedHtmlPrefix, this.defaultHtml);
if (href !== this.template.href) {
if (this.template.href) {
this.defaultComposition = null;
this.template.clear();
}
// set the new value, unify falsy to null
this.template.href = href || null;
return true;
} else {
return false;
}
};
/**
* Stamps composition if needed
* @param {String|DocumentFragment} compositionRef reference to the evaluated composition
* @param {Function} callb Function that returns a composition to render
*/
StarcounterIncludePrototype._renderCompositionChange = function(compositionRef, callb) {
if (this._forceLayoutChange || this._lastCompositionRef !== compositionRef) {
let composition = callb();
if (composition !== false) { //it might be undefined and that's a valid value
this._forceLayoutChange = false;
this.stampComposition(composition);
this._lastCompositionRef = compositionRef;
}
}
}
function appendComposition(fragment, template) {
if (template.nodeName && template.nodeName == 'TEMPLATE' && template.getAttribute('is') === 'declarative-shadow-dom') {
fragment.appendChild(template.content.cloneNode(true));
}
}
/**
* @deprecated Use `starcounterInclude.shadowRoot` or `viewModel.{compositionProvider}.Composition`
* @alias #updateComposition
*/
StarcounterIncludePrototype._compositionChanged = function(compositionString) {
console.warn('_compositionChanged, was renamed to updateComposition. Most probably you don\'t even need to use it. In most of the cases `starcounterInclude.shadowRoot` or changing the `viewModel.{compositionProvider}.Composition` should do the right thing.');
return this.updateComposition(compositionString);
};
/**
* Handles change of the composition.
* Temporary composition can be given explicitely in the function argument
* If temporary composition not given, fetches custom composition from provider.
* If custom composition not given, fetches parent composition from the child `template`.
* If parent composition not given, fetches default composition from the imported template.
* If default composition not given, uses fallback composition.
* Warning: the temporary composition might come from https://github.com/Starcounter/starcounter-layout-html-editor/blob/17b21f729facd9a8dcd4241fb5c48cb71de11af5/starcounter-layout-html-editor.html#L253
* compositionString given as `""` is considered `null`
* TODO: support empty composition
* @see .stampComposition
* @param {String} compositionString stringified HTML for new Shadow Root
*/
StarcounterIncludePrototype.updateComposition = function(compositionString) {
// render temporary composition if given
if (compositionString) {
this.setAttribute('composition', 'temporary');
return this._renderCompositionChange(compositionString, () => this.stringToDocumentFragment(compositionString));
}
// this is a request from composition editor to reset to default composition
const forceDefault = (compositionString === ""); // should be changed to compositionString === null
// look for custom compostion provider
const compositionProvider = this.viewModel && this.viewModel[this.compositionProvider];
let compositionProviderComposition = undefined;
if (compositionProvider) {
this.partialId = compositionProvider.PartialId;
compositionProviderComposition = compositionProvider.Composition;
// Store whatever is given in the view model - it's the stored composition/layout
this._storedComposition = compositionProviderComposition; //should always be string
} else {
this.partialId = null;
}
// If there is a custom composition render it
// Empty composition ("") is treat as none (`null`)
if (compositionProviderComposition && !forceDefault) {
this.setAttribute('composition', 'custom');
return this._renderCompositionChange(compositionProviderComposition, () => {
// convert string from view-model, to actual document fragment
const customComposition = this.stringToDocumentFragment(compositionProviderComposition);
// append default compositions for views not covered by this composition
if (compositionProvider.ViewUris) {
const keys = Object.keys(this.viewModel);
for(let key of keys) {
const scoped = this.viewModel[key];
if (scoped && scoped.Html && compositionProvider.ViewUris.indexOf(scoped.Html) === -1) {
if (this.defaultComposition) {
this.template.scopedNodes.forEach((nodes) => {
if(nodes.scope === key) {
nodes.forEach((node) => appendComposition(customComposition, node));
}
});
}
else {
// BUG,TODO (tomalec): looks like a bug
// This means that if there *is* a custom composition, but it does not cover all views,
// and it happend that none of the views had a default composition,
// we would discard custom composition and use default -> fallback
return false;
}
}
}
}
return customComposition;
});
}
// Find parent composition to clone (if have one)
const parentCompositionTemplate = Array.from(this.children)
.find((element)=>{
return element.matches
&& element.matches('template[is="declarative-shadow-dom"][presentation="parent"],template[is="declarative-shadow-dom"]:not([presentation])')
});
const parentComposition = parentCompositionTemplate && parentCompositionTemplate.content;
if (parentComposition) {
parentCompositionTemplate.setAttribute('presentation', 'parent');
this.setAttribute('composition', 'parent');
return this._renderCompositionChange(parentComposition, () => parentComposition.cloneNode(true));
}
if (this.defaultComposition) {
this.setAttribute('composition', 'default');
return this._renderCompositionChange(this.defaultComposition, () => this.defaultComposition.cloneNode(true));
}
// render fallback if there is nothing better
this.setAttribute('composition', 'fallback');
return this._renderCompositionChange("fallback", () => undefined);
};
// to fool Polymer into thinking `starcounter-include` is a polymer element thus forwarding notifications to it.
StarcounterIncludePrototype.__dataHasAccessor = {partial: true, viewModel: true};
StarcounterIncludePrototype.__isPropertyEffectsClient = true;
// Polymer doesn't set props on its own components, rather, it calls this function
StarcounterIncludePrototype._setPendingProperty = function (path, value) {
if(path === 'partial') {
warnAboutDeprecatedPartial(this);
}
this[path] = value;
}
/**
* Forward Polymer notification downwards from `<dom-bind>`
* to <imported-template>
* @param {String} path Polymer notification path
* @param {Mixed} value New value
*/
StarcounterIncludePrototype._setPendingPropertyOrPath = function (path, value) {
var sameModelAlreadyLoaded = this.template.model === this.viewModel;
const newPath = path.replace("partial.", "model.")
.replace("viewModel.", "model.");
// If that's the same model, we still may need to bump
if(sameModelAlreadyLoaded){
// yey we still support both names, lets strip one or another.
const subPath = path.startsWith('partial.') && path.substr(8) ||
path.startsWith('viewModel.') && path.substr(10);
// check indexOf to optimize match performance
const dotPos = subPath.indexOf('.');
// Update html and composition if needed
if (
// it's a change of entire sub-partial
dotPos === -1
){
// update href (when changed it will re-stamp and set the model by itself)
if(!this._updateHref()){
// Notify about model change
if (this.template._setPendingPropertyOrPath) {
this.template._setPendingPropertyOrPath(newPath, value);
}
// There is no chance that composition changed by changing just Apps model
// TODO: unless it's Composition Provider add a test for a change for just model.{compositionProvider}
}
return ;
}
else if (
// it's sub-partial's .Html
// subPath.matches(/[^\.]\.Html/)
subPath.endsWith('.Html') &&
dotPos === subPath.lastIndexOf('.')
){
this._updateHref();
return;
} else if (
// Just the composition was changed
// and it's different than already stored one
subPath === this.compositionProvider + '.Composition' &&
value != this._storedComposition
) {
this.updateComposition();
return;
}
// Notify about model change
if (this.template._setPendingPropertyOrPath) {
this.template._setPendingPropertyOrPath(newPath, value);
}
} else {
// this.partialChanged(this.partial);
// Completely new partial, reload everything
// TODO: check if we can even get to this point
if(!this._updateHref()){
this.template.model = this.viewModel;
}
this.updateComposition();
}
};
/**
* Retrieves URL of HTML file to be loaded from given partial.
* For merged partials/namespaced JSONs without
* `.Html` property on root level it builds one out of
* `.Html` properties from nested objects
* {prefix}{key1}={partial.key1.Html}&{key2}={partial.key2.Html}&{key3}={defaultURL}
* Parameters are URI encoded, for scopes without `.Html` property _defaultURL_ is used
* @param {Object} partial partial object
* @param {String} prefix prefix for merged partials
* @param {String} defaultURL Html to be used for nodes that does not have one
* @return {String} [description]
*/
function buildURL(partial, prefix, defaultURL) {
if(partial){
if(partial.Html !== undefined){
return partial.Html;
} else {
var htmls = [];
for (var key in partial){
if (partial.hasOwnProperty(key)) {
// quick fix for https://github.com/Starcounter/starcounter-include/issues/37
// just to unblock https://github.com/Starcounter/level1/issues/4061
if (key !== '_ver#s' && key !== '_ver#c$') {
htmls.push(
encodeURIComponent(key) +
'=' +
(partial[key].Html && encodeURIComponent(partial[key].Html) || defaultURL)
);
}
}
}
// Workaround for https://github.com/Starcounter/Starcounter/issues/3072
// as described in https://github.com/Starcounter/starcounter-include/issues/12
return htmls.length && (prefix + htmls.join('&')) || undefined;
}
} else {
return undefined;
}
};
// stringToDocumentFragment(strHTML) from http://stackoverflow.com/a/25214113/868184
/**
* Creates DocumentFragment from a string.
* @param {string} htmlStr string to parse
* @param {HTMLElement} node node to select a range, need for Safari workaround
* @return {DocumentFragment} parsed string
*/
StarcounterIncludePrototype.stringToDocumentFragment = function (htmlStr) {
var range = document.createRange();
// Safari does not support `createContextualFragment` without selecting a node.
if (isSafari) {
range.selectNode(this);
}
return range.createContextualFragment(htmlStr);
}
/**
* Composition provider key.
* The place which the element will check for custom compositions.
* Could be overwritten per instance or globally if you change the prototype.
* @type {String}
*/
StarcounterIncludePrototype.compositionProvider = 'CompositionProvider_0';
/**
* Check if given viewModel is non-namespace (contains `.Html` property)
* and throw a warning to the console with a hint how to fix that.
* @param {Object} viewModel view-model to check
* @param {HTMLElement} element starcounter-include to point to
*/
function checkForNonNamespaced(viewModel, element){
if(viewModel && typeof viewModel.Html !== 'undefined'){
console.warn(`The view ${viewModel.Html} is enclosed in a red-dotted line, because it is incorrectly provided to <starcounter-include> without an app namespace. Your options are to either:
- Make it blendable by using Self.GET on server-side, or
- Use <imported-template> instead of <starcounter-include> and remove declarative-shadow-dom from the nested view
Read more at: https://starcounter.io/starcounter-include-non-namespaced-partial-view-models/`, viewModel, element);
element.style.outline = "4px red dotted";
element.style.outlineOffset = "-4px";
element.wasWarned = true;
}
else if(element.wasWarned) {
element.style.outline = null;
element.style.outlineOffset = null;
element.wasWarned = false;
}
};
/**
* Stamps new shadow root (overwrites existing),
* @fires stamped
* @param {DocumentFragment} givenComposition
*/
StarcounterIncludePrototype.stampComposition = function(givenComposition){
const shadowRoot = this.shadowRoot;
this.blockingShadowLinksLoaded && this.blockingShadowLinksLoaded.reject('composition-changed');
if (givenComposition !== undefined) {
// reset SD
shadowRoot.innerHTML = '';
// wait for blocking links
const blockingShadowLinksLoaded = blockingLinksLoaded(givenComposition);
this.blockingShadowLinksLoaded = blockingShadowLinksLoaded;
if(blockingShadowLinksLoaded){
// We have some styles that need to be loaded
this.setAttribute('loading-presentation', '');
blockingShadowLinksLoaded.then(()=>{
this.removeAttribute('loading-presentation');
this.dispatchEvent(new CustomEvent('presentation-loaded'));
}, (reason) => {
// forward uncaught rejection
// if reason is different than `composition-changed`
if(reason !== 'composition-changed'){
return Promise.reject(e);
}
});
} else {
// The new presentation is styled synchronously, no need to wait.
// Remove the attribute if there is one from previous, unresolved styles
this.removeAttribute('loading-presentation');
this.dispatchEvent(new CustomEvent('presentation-loaded'));
}
shadowRoot.appendChild(givenComposition);
// polyfill `polyfill-next-selector` if needed
typeof WebComponents !== 'undefined' && WebComponents.ShadowCSS &&
WebComponents.ShadowCSS.replaceTextInStyles(
WebComponents.ShadowCSS.findStyles(shadowRoot),
WebComponents.ShadowCSS.insertDirectives
);
} else if(shadowRoot){
shadowRoot.innerHTML = fallBackComposition;
}
this.dispatchEvent(new CustomEvent("stamped"));
}
StarcounterIncludePrototype.clear = function (){
console.error('clear is not yet defined!');
}
// Desired use of upcoming Web Platform feature,
// so we would not have to implement all `loading-*` attributes and
// fallback composition logic.
// customElements.define('starcounter-include', StarcounterInclude,
// { style: new CSSStyleSheet(`
// :element([loading-presentation]){
// visibility:hidden !important;
// }
// :element{
// display:block;
// }`)}
// );
customElements.define('starcounter-include', StarcounterInclude);
})();
</script>