-
Notifications
You must be signed in to change notification settings - Fork 96
/
LeftAndMain.js
1589 lines (1364 loc) · 49.9 KB
/
LeftAndMain.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
/**
* File: LeftAndMain.js
*/
import $ from 'jquery';
import React from 'react';
import { createRoot } from 'react-dom/client';
import IframeDialog from 'components/IframeDialog/IframeDialog';
import Search from 'components/Search/Search';
import Loading from 'components/Loading/Loading';
import { schemaMerge } from 'lib/schemaFieldValues';
import { loadComponent } from 'lib/Injector';
import escapeRegExp from 'lodash.escaperegexp';
import '../legacy/ssui.core.js';
$.noConflict();
window.ss = window.ss || {};
/**
* @func debounce
* @param func {function} - The callback to invoke after `wait` milliseconds.
* @param wait {number} - Milliseconds to wait.
* @param immediate {boolean} - If true the callback will be invoked at the start rather than the end.
* @return {function}
* @desc Returns a function that will not be called until it hasn't been invoked for `wait` seconds.
*/
window.ss.debounce = function (func, wait, immediate) {
var timeout, context, args;
var later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
return function() {
var callNow = immediate && !timeout;
context = this;
args = arguments;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) {
func.apply(context, args);
}
};
};
/**
* The URL to use for saving and loading tab state
*/
window.ss.tabStateUrl = function() {
return window.ss.formatTabStateUrl(window.location.href);
};
/**
* Helper function to format URL that is used for saving and loading tab state
*
* @param url {string} URL to format
* @returns {*}
*/
window.ss.formatTabStateUrl = function(url) {
return url
.replace(/\?.*/, '')
.replace(/#.*/, '')
.replace(new RegExp(`^${escapeRegExp($('base').attr('href'))}/?`), '');
};
$(window).on('resize.leftandmain', function(e) {
$('.cms-container').trigger('windowresize');
});
// setup jquery.entwine
$.entwine.warningLevel = $.entwine.WARN_LEVEL_BESTPRACTISE;
$.entwine('ss', function($) {
/*
* Handle messages sent via nested iframes
* Messages should be raised via postMessage with an object with the 'type' parameter given.
* An optional 'target' and 'data' parameter can also be specified. If no target is specified
* events will be sent to the window instead.
* type should be one of:
* - 'event' - Will trigger the given event (specified by 'event') on the target
* - 'callback' - Will call the given method (specified by 'callback') on the target
*/
$(window).on("message", function(e) {
var target,
event = e.originalEvent,
data = null;
try
{
data = typeof event.data === 'object' ? event.data : JSON.parse(event.data);
}
catch(e)
{
// Invalid json received
}
// Reject invalid data or messages outside of the same origin
if(!data || $.path.parseUrl(window.location.href).domain !== $.path.parseUrl(event.origin).domain) return;
// Get target of this action
target = typeof(data.target) === 'undefined'
? $(window)
: $(data.target);
// Determine action
switch(data.type) {
case 'event':
let eventType = data.event;
let eventData = data.data;
if (!eventType) {
eventType = data.message.type;
}
if (!eventData) {
eventData = data.message.payload;
}
target.trigger(eventType, eventData);
break;
case 'callback':
target[data.callback].call(target, data.data);
break;
}
});
/**
* Position the loading spinner animation below the ss logo
*/
var positionLoadingSpinner = function() {
var offset = 120; // offset from the ss logo
var spinner = $('.ss-loading-screen .loading-animation');
var top = ($(window).height() - spinner.height()) / 2;
spinner.css('top', top + offset);
spinner.show();
};
// apply an select element only when it is ready, ie. when it is rendered into a template
// with css applied and got a width value.
var applyChosen = function(el) {
if(el.is(':visible')) {
el.addClass('has-chosen').chosen({
allow_single_deselect: true,
disable_search_threshold: 20,
display_disabled_options: true,
width: '100%'
});
} else {
setTimeout(function() {
// Make sure it's visible before applying the ui
el.show();
applyChosen(el);
}, 500);
}
};
/**
* Compare URLs, but normalize trailing slashes in
* URL to work around routing weirdnesses in SS_HTTPRequest.
* Also normalizes relative URLs by prefixing them with the <base>.
*/
var isSameUrl = function(url1, url2) {
var baseUrl = $('base').attr('href');
url1 = $.path.isAbsoluteUrl(url1) ? url1 : $.path.makeUrlAbsolute(url1, baseUrl),
url2 = $.path.isAbsoluteUrl(url2) ? url2 : $.path.makeUrlAbsolute(url2, baseUrl);
var url1parts = $.path.parseUrl(url1), url2parts = $.path.parseUrl(url2);
return (
url1parts.pathname.replace(/\/*$/, '') == url2parts.pathname.replace(/\/*$/, '') &&
url1parts.search == url2parts.search
);
};
var ajaxCompleteEvent = window.ss.debounce(function () {
$(window).trigger('ajaxComplete');
}, 1000, true);
$(window).on('resize', positionLoadingSpinner).trigger('resize');
// global ajax handlers
$(document).ajaxComplete(function(e, xhr, settings) {
// Simulates a redirect on an ajax response.
var origUrl = document.URL,
url = xhr.getResponseHeader('X-ControllerURL'),
destUrl = settings.url,
msg = xhr.getResponseHeader('X-Status') !== null ? xhr.getResponseHeader('X-Status') : xhr.statusText, // Handle custom status message headers
msgType = (xhr.status < 200 || xhr.status > 399) ? 'error' : 'success',
ignoredMessages = ['OK', 'success', 'load', 'HTTP/2.0 200'];
// Only redirect if controller url differs to the requested or current one
if (url !== null && (!isSameUrl(origUrl, url) || !isSameUrl(destUrl, url))) {
window.ss.router.show(url, {
id: (new Date()).getTime() + String(Math.random()).replace(/\D/g,''), // Ensure that redirections are followed through by history API by handing it a unique ID
pjax: xhr.getResponseHeader('X-Pjax') ? xhr.getResponseHeader('X-Pjax') : settings.headers['X-Pjax']
});
}
// Enable reauthenticate dialog if requested
if (xhr.getResponseHeader('X-Reauthenticate')) {
$('.cms-container').showLoginDialog();
return;
}
// Show message (but ignore aborted requests)
if (xhr.status !== 0 && msg && $.inArray(msg, ignoredMessages) === -1) {
// Decode into UTF-8, HTTP headers don't allow multibyte
statusMessage(decodeURIComponent(msg), msgType);
}
ajaxCompleteEvent(this);
});
/**
* Main LeftAndMain interface with some control panel and an edit form.
*
* Events:
* ajaxsubmit - ...
* validate - ...
* aftersubmitform - ...
*/
$('.cms-container').entwine({
/**
* Tracks current panel request.
*/
StateChangeXHR: null,
/**
* Tracks current fragment-only parallel PJAX requests.
*/
FragmentXHR: {},
StateChangeCount: 0,
/**
* Options for the threeColumnCompressor layout algorithm.
*
* See LeftAndMain.Layout.js for description of these options.
*/
LayoutOptions: {
minContentWidth: 940,
minPreviewWidth: 400,
mode: 'content'
},
/**
* Constructor: onmatch
*/
onadd: function() {
// Initialize layouts
this.redraw();
// Remove loading screen
$('.ss-loading-screen').hide();
$('body').removeClass('loading');
$(window).off('resize', positionLoadingSpinner);
this.restoreTabState();
this._super();
},
'onwindowresize': function() {
this.redraw();
},
'from .cms-panel': {
ontoggle: function(){ this.redraw(); }
},
'from .cms-container': {
onaftersubmitform: function(){ this.redraw(); }
},
/**
* Change the options of the threeColumnCompressor layout, and trigger layouting if needed.
* You can provide any or all options. The remaining options will not be changed.
*/
updateLayoutOptions: function(newSpec) {
var spec = this.getLayoutOptions();
var dirty = false;
for (var k in newSpec) {
if (spec[k] !== newSpec[k]) {
spec[k] = newSpec[k];
dirty = true;
}
}
if (dirty) this.redraw();
},
clearViewMode: function () {
this.removeClass('cms-container--split-mode');
this.removeClass('cms-container--preview-mode');
this.removeClass('cms-container--content-mode');
},
/**
* Enable the split view - with content on the left and preview on the right.
*/
splitViewMode: function() {
this.updateLayoutOptions({
mode: 'split'
});
},
/**
* Content only.
*/
contentViewMode: function() {
this.updateLayoutOptions({
mode: 'content'
});
},
/**
* Preview only.
*/
previewMode: function() {
this.updateLayoutOptions({
mode: 'preview'
});
},
RedrawSuppression: false,
redraw: function() {
if (this.getRedrawSuppression()) return;
if(window.debug) console.log('redraw', this.attr('class'), this.get(0));
// disable split mode if screen is too small
var changed = this.setProperMode();
// if changed, then the changing would trigger a redraw, so we don't want to redraw twice
if (!changed) {
// Redraw on all the children that need it
this.find('.cms-panel-layout').redraw();
this.find('.cms-content-fields[data-layout-type]').redraw();
this.find('.cms-edit-form[data-layout-type]').redraw();
this.find('.cms-preview').redraw();
this.find('.cms-content').redraw();
}
},
/**
* Changes the viewing mode if the screen is too small, disables split mode.
*
* @returns {boolean} changedMode - so redraw is not called twice
*/
setProperMode: function () {
var options = this.getLayoutOptions();
var mode = options.mode;
this.clearViewMode();
var content = this.find('.cms-content');
var preview = this.find('.cms-preview');
content.css({'min-width': 0});
preview.css({'min-width': 0});
if (content.width() + preview.width() >= options.minContentWidth + options.minPreviewWidth) {
content.css({'min-width': options.minContentWidth});
preview.css({'min-width': options.minPreviewWidth});
preview.trigger('enable');
} else {
preview.trigger('disable');
if (mode == 'split') {
// force change mode and leave it redraw after
preview.trigger('forcecontent');
return true;
}
}
this.addClass('cms-container--' + mode + '-mode');
return false;
},
/**
* Confirm whether the current user can navigate away from this page
*
* @param {array} selectors Optional list of selectors
* @returns {boolean} True if the navigation can proceed
*/
checkCanNavigate: function(selectors) {
// Check change tracking (can't use events as we need a way to cancel the current state change)
var contentEls = this._findFragments(selectors || ['Content']),
trackedEls = contentEls
.find(':data(changetracker)')
.add(contentEls.filter(':data(changetracker)')),
safe = true;
if(!trackedEls.length) {
return true;
}
trackedEls.each(function() {
// See LeftAndMain.EditForm.js
if(!$(this).confirmUnsavedChanges()) {
safe = false;
}
});
return safe;
},
/**
* @param {String} url
* @param {String} title New window title.
* @param {Object} data Any additional data passed through to `window.history.state`.
* @param {Boolean} forceReload Forces the replacement of the current history state, even if the URL is the same, i.e. allows reloading.
* @param {String} forceReferer
*/
loadPanel: function (url, title = '', data = {}, forceReload, forceReferer = document.URL) {
// Check for unsaved changes
if (!this.checkCanNavigate(data.pjax ? data.pjax.split(',') : ['Content'])) {
return;
}
// Clear tab state for current browser URL, and save state for new panel to load
this.clearTabState(window.ss.tabStateUrl());
this.saveTabState(window.ss.formatTabStateUrl(url), true);
data.__forceReferer = forceReferer;
if (forceReload) {
data.__forceReload = 1 + Math.random(); // Make sure the page reloads even if the URL is the same.
}
window.ss.router.show(url, data);
},
/**
* Nice wrapper for reloading current history state.
*/
reloadCurrentPanel: function() {
this.loadPanel(document.URL, null, null, true);
},
/**
* Function: submitForm
*
* Parameters:
* {DOMElement} form - The form to be submitted. Needs to be passed
* in to avoid entwine methods/context being removed through replacing the node itself.
* {DOMElement} button - The pressed button (optional)
* {Function} callback - Called in complete() handler of jQuery.ajax()
* {Object} ajaxOptions - Object literal to merge into $.ajax() call
*
* Returns:
* (boolean)
*/
submitForm: function(form, button, callback, ajaxOptions) {
var self = this;
// look for save button
if(!button) button = this.find('.btn-toolbar :submit[name=action_save]');
// default to first button if none given - simulates browser behaviour
if(!button) button = this.find('.btn-toolbar :submit:first');
var beforeSubmitFormEventData = {
// array of promises that must resolve({success:true}) before the form is submitted
// result of each promise must be an object of
// { success: <bool>, reason: <string> } where reason should be populated if success is false
promises: [],
// callbacks that are called on ajax success after submitted the form
onAjaxSuccessCallbacks: [],
};
form.trigger('beforesubmitform', beforeSubmitFormEventData);
Promise.all(beforeSubmitFormEventData.promises).then(function(results) {
let success = true;
const reasons = [];
for (const result of results) {
if (result['success'] === false) {
success = false;
reasons.push(result['reason']);
}
}
if (!success) {
let invalid = false;
for (const reason of reasons) {
if (reason === 'invalid') {
invalid = true;
break;
}
}
if (invalid) {
jQuery.noticeAdd({
text: window.ss.i18n._t('Admin.VALIDATIONERROR', 'Validation Error'),
type: 'error',
stayTime: 5000,
inEffect: {
left: '0',
opacity: 'show'
}
});
}
return false;
}
self.trigger('submitform', {form: form, button: button});
// set button to "submitting" state
$(button).addClass('btn--loading loading');
$(button).prop('disabled', true);
if($(button).is('button')) {
$(button).append($(
'<div class="btn__loading-icon">'+
'<span class="btn__circle btn__circle--1"></span>'+
'<span class="btn__circle btn__circle--2"></span>'+
'<span class="btn__circle btn__circle--3"></span>'+
'</div>'));
$(button).css($(button).outerWidth() + 'px');
}
// validate if required
var validationResult = form.validate();
var clearButton = function() {
$(button).removeClass('btn--loading loading');
$(button).prop('disabled', false);
$(button).find('.btn__loading-icon').remove();
$(button).css('width', 'auto');
$(button).text($(button).data('original-text'));
}
if(typeof validationResult!=='undefined' && !validationResult) {
statusMessage("Validation failed.", "bad");
clearButton();
}
// get all data from the form
var formData = form.serializeArray();
// add button action
formData.push({name: $(button).attr('name'), value:'1'});
// Artificial HTTP referer, IE doesn't submit them via ajax.
// Also rewrites anchors to their page counterparts, which is important
// as automatic browser ajax response redirects seem to discard the hash/fragment.
formData.push({ name: 'BackURL', value: document.URL.replace(/\/$/, '') });
// Save tab selections so we can restore them later
self.saveTabState(window.ss.tabStateUrl(), false);
// Standard Pjax behaviour is to replace the submitted form with new content.
// The returned view isn't always decided upon when the request
// is fired, so the server might decide to change it based on its own logic,
// sending back different `X-Pjax` headers and content
jQuery.ajax(jQuery.extend({
headers: {"X-Pjax" : "CurrentForm,Breadcrumbs,ValidationResult"},
url: form.attr('action'),
data: formData,
type: 'POST',
complete: function() {
clearButton()
},
success: function(data, status, xhr) {
beforeSubmitFormEventData.onAjaxSuccessCallbacks.forEach(fn => fn());
clearButton();
form.removeClass('changed');
if(callback) callback(data, status, xhr);
var newContentEls = self.handleAjaxResponse(data, status, xhr);
if(!newContentEls) return;
newContentEls.filter('form').trigger('aftersubmitform', {status: status, xhr: xhr, formData: formData});
}
}, ajaxOptions));
});
return false;
},
/**
* Last html5 history state
*/
LastState: null,
/**
* Flag to pause handleStateChange
*/
PauseState: false,
/**
* Handles ajax loading of new panels through the window.history object.
* To trigger loading, pass a new URL to window.ss.router.show().
* Use loadPanel() as a window.ss.router.show() wrapper as it provides some
* additional functionality like global changetracking and user aborts.
*
* Due to the nature of history management, no callbacks are allowed.
* Use the 'beforestatechange' and 'afterstatechange' events instead,
* or overwrite the beforeLoad() and afterLoad() methods on the
* DOM element you're loading the new content into.
* Although you can pass data into window.ss.router.show(url, data), it
* shouldn't contain DOM elements or callback closures.
*
* The passed URL should allow reconstructing important interface state
* without additional parameters, in the following use cases:
* - Explicit loading through window.ss.router.show()
* - Implicit loading through browser navigation event triggered by the user
* (forward or back).
* - Full window refresh without ajax
* For example, a ModelAdmin search event should contain the search terms
* as URL parameters, and the result display should automatically appear
* if the URL is loaded without ajax.
*/
handleStateChange: function (event, historyState = window.history.state) {
if (this.getPauseState()) {
return;
}
// Don't allow parallel loading to avoid edge cases
if (this.getStateChangeXHR()) {
this.getStateChangeXHR().abort();
}
var self = this,
fragments = historyState.pjax || 'Content',
headers = {},
fragmentsArr = fragments.split(','),
contentEls = this._findFragments(fragmentsArr);
this.setStateChangeCount(this.getStateChangeCount() + 1);
if (!this.checkCanNavigate()) {
// If the user can't navigate away, restore the last known good state
this.reverseStateChange();
// Abort loading of this panel
return;
}
// If any of the requested Pjax fragments don't exist in the current view,
// fetch the "Content" view instead, which is the "outermost" fragment
// that can be reloaded without reloading the whole window.
if (contentEls.length < fragmentsArr.length) {
fragments = 'Content', fragmentsArr = ['Content'];
contentEls = this._findFragments(fragmentsArr);
}
this.trigger('beforestatechange', { state: historyState, element: contentEls });
// Set Pjax headers, which can declare a preference for the returned view.
// The actually returned view isn't always decided upon when the request
// is fired, so the server might decide to change it based on its own logic.
headers['X-Pjax'] = fragments;
if (typeof historyState.__forceReferer !== 'undefined') {
// Ensure query string is properly encoded if present
let url = historyState.__forceReferer;
try {
// Prevent double-encoding by attempting to decode
url = decodeURI(url);
} catch(e) {
// URL not encoded, or was encoded incorrectly, so do nothing
} finally {
// Set our referer header to the encoded URL
headers['X-Backurl'] = encodeURI(url);
}
}
contentEls.addClass('loading');
let promise = $.ajax({
headers: headers,
url: historyState.path || document.URL,
})
.fail((xhr, status, error) => {
// Ignoring aborts: The server request failed, so prevent a mixed UI state by rolling back to previously
// succesfully loaded URL (consistent with currently loaded content).
if (xhr.readyState !== 0 && xhr.getResponseHeader('X-Reauthenticate') !== '1') {
this.reverseStateChange();
}
})
.done((data, status, xhr) => {
// Request succeeded, so retain this state for future calls to this.reverseStateChange().
this.setLastState(historyState);
var els = self.handleAjaxResponse(data, status, xhr, historyState);
self.trigger('afterstatechange', {data: data, status: status, xhr: xhr, element: els, state: historyState});
})
.always(() => {
self.setStateChangeXHR(null);
// Remove loading indication from old content els (regardless of which are replaced)
contentEls.removeClass('loading');
});
this.setStateChangeXHR(promise);
return promise;
},
/**
* Reverts the window.history state back to the last known good state. See this.handleStateChange().
*/
reverseStateChange: function() {
// Get last known good state
var lastState = this.getLastState();
// Suppress panel loading while resetting state
this.setPauseState(true);
// Decrement state change counter
this.setStateChangeCount(this.getStateChangeCount() - 1);
// Restore best last state
if (lastState && lastState.path) {
window.ss.router.show(lastState.path);
// Can unpause state now since it's synchronous.
this.setPauseState(false);
} else {
window.ss.router.back();
// Hack: Need to use setTimeout() since, unfortunately .back() above *also* uses setTimeout().
setTimeout(() => {
this.setPauseState(false);
});
}
},
/**
* ALternative to loadPanel/submitForm.
*
* Triggers a parallel-fetch of a PJAX fragment, which is a separate request to the
* state change requests. There could be any amount of these fetches going on in the background,
* and they don't register as a HTML5 history states.
*
* This is meant for updating a PJAX areas that are not complete panel/form reloads. These you'd
* normally do via submitForm or loadPanel which have a lot of automation built in.
*
* On receiving successful response, the framework will update the element tagged with appropriate
* data-pjax-fragment attribute (e.g. data-pjax-fragment="<pjax-fragment-name>"). Make sure this element
* is available.
*
* Example usage:
* $('.cms-container').loadFragment('admin/foobar/', 'FragmentName');
*
* @param url string Relative or absolute url of the controller.
* @param pjaxFragments string PJAX fragment(s), comma separated.
*/
loadFragment: function(url, pjaxFragments) {
var self = this,
xhr,
headers = {},
baseUrl = $('base').attr('href'),
fragmentXHR = this.getFragmentXHR();
// Make sure only one XHR for a specific fragment is currently in progress.
if(
typeof fragmentXHR[pjaxFragments]!=='undefined' &&
fragmentXHR[pjaxFragments]!==null
) {
fragmentXHR[pjaxFragments].abort();
fragmentXHR[pjaxFragments] = null;
}
url = $.path.isAbsoluteUrl(url) ? url : $.path.makeUrlAbsolute(url, baseUrl);
headers['X-Pjax'] = pjaxFragments;
xhr = $.ajax({
headers: headers,
url: url,
success: function(data, status, xhr) {
var elements = self.handleAjaxResponse(data, status, xhr, null);
// We are fully done now, make it possible for others to hook in here.
self.trigger('afterloadfragment', { data: data, status: status, xhr: xhr, elements: elements });
},
error: function(xhr, status, error) {
self.trigger('loadfragmenterror', { xhr: xhr, status: status, error: error });
},
complete: function() {
// Reset the current XHR in tracking object.
var fragmentXHR = self.getFragmentXHR();
if(
typeof fragmentXHR[pjaxFragments]!=='undefined' &&
fragmentXHR[pjaxFragments]!==null
) {
fragmentXHR[pjaxFragments] = null;
}
}
});
// Store the fragment request so we can abort later, should we get a duplicate request.
fragmentXHR[pjaxFragments] = xhr;
return xhr;
},
/**
* Handles ajax responses containing plain HTML, or mulitple
* PJAX fragments wrapped in JSON (see PjaxResponseNegotiator PHP class).
* Can be hooked into an ajax 'success' callback.
*
* Parameters:
* (Object) data
* (String) status
* (XMLHTTPRequest) xhr
* (Object) state The original history state which the request was initiated with
*/
handleAjaxResponse: function(data, status, xhr, state) {
let guessFragment, fragment, $data;
// Support a full reload
if(xhr.getResponseHeader('X-Reload') && xhr.getResponseHeader('X-ControllerURL')) {
const baseUrl = $('base').attr('href');
const rawURL = xhr.getResponseHeader('X-ControllerURL');
const url = $.path.isAbsoluteUrl(rawURL) ? rawURL : $.path.makeUrlAbsolute(rawURL, baseUrl);
document.location.href = url;
return;
}
// Pseudo-redirects via X-ControllerURL might return empty data, in which
// case we'll ignore the response
if(!data) return;
// Update title
var title = xhr.getResponseHeader('X-Title');
if(title) document.title = decodeURIComponent(title.replace(/\+/g, ' '));
let newFragments = {};
let newContentEls;
// If content type is application/json (ignoring charset and other parameters)
if(xhr.getResponseHeader('Content-Type').match(/^((text)|(application))\/json[ \t]*;?/i)) {
newFragments = data;
} else {
// Fall back to replacing the content fragment if HTML is returned
$data = $($.parseHTML(data, document, false));
// Try and guess the fragment if none is provided
guessFragment = 'Content';
if ($data.is('form') && !$data.is('[data-pjax-fragment~=Content]')) guessFragment = 'CurrentForm';
newFragments[guessFragment] = $data;
}
this.setRedrawSuppression(true);
try {
// Replace each fragment individually
$.each(newFragments, function (newFragment, html) {
var contentEl = $('[data-pjax-fragment]').filter(function () {
return $.inArray(newFragment, $(this).data('pjaxFragment').split(' ')) != -1;
}),
newContentEl = $(html);
// Add to result collection
if(newContentEls) newContentEls.add(newContentEl);
else newContentEls = newContentEl;
// Update panels
if(newContentEl.find('.cms-container').length) {
throw 'Content loaded via ajax is not allowed to contain tags matching the ".cms-container" selector to avoid infinite loops';
}
// Set loading state and store element state
var origStyle = contentEl.attr('style');
var origParent = contentEl.parent();
var layoutClasses = ['east', 'west', 'center', 'north', 'south', 'column-hidden'];
var elemClasses = contentEl.attr('class');
var origLayoutClasses = [];
if(elemClasses) {
origLayoutClasses = $.grep(
elemClasses.split(' '),
function(val) { return ($.inArray(val, layoutClasses) >= 0);}
);
}
newContentEl
.removeClass(layoutClasses.join(' '))
.addClass(origLayoutClasses.join(' '));
if(origStyle) newContentEl.attr('style', origStyle);
// Allow injection of inline styles, as they're not allowed in the document body.
// Not handling this through jQuery.ondemand to avoid parsing the DOM twice.
var styles = newContentEl.find('style').detach();
if(styles.length) $(document).find('head').append(styles);
// Replace panel completely (we need to override the "layout" attribute, so can't replace the child instead)
contentEl.replaceWith(newContentEl);
});
// Re-init tabs (in case the form tag itself is a tabset)
var newForm = newContentEls.filter('form');
if(newForm.hasClass('cms-tabset')) newForm.removeClass('cms-tabset').addClass('cms-tabset');
}
finally {
this.setRedrawSuppression(false);
}
this.redraw();
this.restoreTabState((state && typeof state.tabState !== 'undefined') ? state.tabState : null);
return newContentEls;
},
/**
*
*
* Parameters:
* - fragments {Array}
* Returns: jQuery collection
*/
_findFragments: function(fragments) {
return $('[data-pjax-fragment]').filter(function() {
// Allows for more than one fragment per node
var i, nodeFragments = $(this).data('pjaxFragment').split(' ');
for(i in fragments) {
if($.inArray(fragments[i], nodeFragments) != -1) return true;
}
return false;
});
},
/**
* Function: refresh
*
* Updates the container based on the current url
*
* Returns: void
*/
refresh: function() {
$(window).trigger('statechange');
$(this).redraw();
},
/**
* Save tab selections in order to reconstruct them later.
* Requires HTML5 sessionStorage support.
*
* Parameters:
* (String) url used for session storage key
* (Boolean) resetTab true force selected tab to first, else current active
*/
saveTabState: function(url, resetTab) {
if(typeof(window.sessionStorage)=="undefined" || window.sessionStorage === null) return;
if (url === undefined) {
const url = window.ss.tabStateUrl();
}
var selectedTabs = [];
this.find('.cms-tabset,.ss-tabset').each(function(i, el) {
var id = $(el).attr('id');
if(!id) return; // we need a unique reference
if(!$(el).data('uiTabs')) return; // don't act on uninit'ed controls
// Allow opt-out via data element or entwine property.
if($(el).data('ignoreTabState') || $(el).getIgnoreTabState()) return;
selectedTabs.push({id:id, selected:resetTab ? 0 : $(el).tabs('option', 'active')});
});
if(selectedTabs) {
var tabsUrl = 'tabs-' + url;
try {
window.sessionStorage.setItem(tabsUrl, JSON.stringify(selectedTabs));
} catch(err) {
if (err.code === DOMException.QUOTA_EXCEEDED_ERR && window.sessionStorage.length === 0) {
// If this fails we ignore the error as the only issue is that it
// does not remember the tab state.
// This is a Safari bug which happens when private browsing is enabled.
return;
} else {
throw err;
}
}
}
},
/**
* Re-select previously saved tabs.
* Requires HTML5 sessionStorage support.
*
* Parameters:
* (Object) Map of tab container selectors to tab selectors.
* Used to mark a specific tab as active regardless of the previously saved options.
*/
restoreTabState: function(overrideStates) {
const tabsets = this.find('.cms-tabset, .ss-tabset');
if (tabsets.length) {
tabsets.each(function() {
const tabset = $(this);
const tabsetId = tabset.attr('id');
const overrideState = (overrideStates && overrideStates[tabsetId]) ? overrideStates[tabsetId] : null;
tabset.restoreState(overrideState);
});