silverstripe-framework/admin/javascript/LeftAndMain.js
Sam Minnee 3ee8f505b7 MINORE: Remove training whitespace.
The main benefit of this is so that authors who make use of
.editorconfig don't end up with whitespace changes in their PRs.

Spaces vs. tabs has been left alone, although that could do with a
tidy-up in SS4 after the switch to PSR-1/2.

The command used was this:

for match in '*.ss' '*.css' '*.scss' '*.html' '*.yml' '*.php' '*.js' '*.csv' '*.inc' '*.php5'; do
	find . -path ./thirdparty -not -prune -o -path ./admin/thirdparty -not -prune -o -type f -name "$match" -exec sed -E -i '' 's/[[:space:]]+$//' {} \+
	find . -path ./thirdparty -not -prune -o -path ./admin/thirdparty -not -prune -o -type f -name "$match" | xargs perl -pi -e 's/ +$//'
done
2016-01-07 10:15:54 +13:00

1517 lines
47 KiB
JavaScript

jQuery.noConflict();
/**
* File: LeftAndMain.js
*/
(function($) {
window.ss = window.ss || {};
var windowWidth, windowHeight;
/**
* @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);
}
};
};
$(window).bind('resize.leftandmain', function(e) {
// Entwine's 'fromWindow::onresize' does not trigger on IE8. Use synthetic event.
var cb = function() {$('.cms-container').trigger('windowresize');};
// Workaround to avoid IE8 infinite loops when elements are resized as a result of this event
if($.browser.msie && parseInt($.browser.version, 10) < 9) {
var newWindowWidth = $(window).width(), newWindowHeight = $(window).height();
if(newWindowWidth != windowWidth || newWindowHeight != windowHeight) {
windowWidth = newWindowWidth;
windowHeight = newWindowHeight;
cb();
}
} else {
cb();
}
});
// 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 = JSON.parse(event.data);
// Reject messages outside of the same origin
if($.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':
target.trigger(data.event, data.data);
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-chzn').chosen({
allow_single_deselect: true,
disable_search_threshold: 20
});
var title = el.prop('title');
if(title) {
el.siblings('.chzn-container').prop('title', title);
}
} 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).bind('resize', positionLoadingSpinner).trigger('resize');
// global ajax handlers
$(document).ajaxComplete(function(e, xhr, settings) {
// Simulates a redirect on an ajax response.
if(window.History.enabled) {
var url = xhr.getResponseHeader('X-ControllerURL'),
// TODO Replaces trailing slashes added by History after locale (e.g. admin/?locale=en/)
origUrl = History.getPageUrl().replace(/\/$/, ''),
destUrl = settings.url,
opts;
// Only redirect if controller url differs to the requested or current one
if(url !== null &&
(!isSameUrl(origUrl, url) || !isSameUrl(destUrl, url))
) {
opts = {
// Ensure that redirections are followed through by history API by handing it a unique ID
id: (new Date()).getTime() + String(Math.random()).replace(/\D/g,''),
pjax: xhr.getResponseHeader('X-Pjax')
? xhr.getResponseHeader('X-Pjax')
: settings.headers['X-Pjax']
};
window.History.pushState(opts, '', url);
}
}
// Handle custom status message headers
var msg = (xhr.getResponseHeader('X-Status')) ? xhr.getResponseHeader('X-Status') : xhr.statusText,
reathenticate = xhr.getResponseHeader('X-Reauthenticate'),
msgType = (xhr.status < 200 || xhr.status > 399) ? 'bad' : 'good',
ignoredMessages = ['OK'];
// Enable reauthenticate dialog if requested
if(reathenticate) {
$('.cms-container').showLoginDialog();
return;
}
// Show message (but ignore aborted requests)
if(xhr.status !== 0 && msg && $.inArray(msg, ignoredMessages)) {
// 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() {
var self = this;
// Browser detection
if($.browser.msie && parseInt($.browser.version, 10) < 8) {
$('.ss-loading-screen').append(
'<p class="ss-loading-incompat-warning"><span class="notice">' +
'Your browser is not compatible with the CMS interface. Please use Internet Explorer 8+, Google Chrome or Mozilla Firefox.' +
'</span></p>'
).css('z-index', $('.ss-loading-screen').css('z-index')+1);
$('.loading-animation').remove();
this._super();
return;
}
// Initialize layouts
this.redraw();
// Remove loading screen
$('.ss-loading-screen').hide();
$('body').removeClass('loading');
$(window).unbind('resize', positionLoadingSpinner);
this.restoreTabState();
this._super();
},
fromWindow: {
onstatechange: function(e){
this.handleStateChange(e);
}
},
'onwindowresize': function() {
this.redraw();
},
'from .cms-panel': {
ontoggle: function(){ this.redraw(); }
},
'from .cms-container': {
onaftersubmitform: function(){ this.redraw(); }
},
/**
* Ensure the user can see the requested section - restore the default view.
*/
'from .cms-menu-list li a': {
onclick: function(e) {
var href = $(e.target).attr('href');
if(e.which > 1 || href == this._tabStateUrl()) return;
this.splitViewMode();
}
},
/**
* 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();
},
/**
* 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));
// Reset the algorithm.
this.data('jlayout', jLayout.threeColumnCompressor(
{
menu: this.children('.cms-menu'),
content: this.children('.cms-content'),
preview: this.children('.cms-preview')
},
this.getLayoutOptions()
));
// Trigger layout algorithm once at the top. This also lays out children - we move from outside to
// inside, resizing to fit the parent.
this.layout();
// 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();
},
/**
* 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;
},
/**
* Proxy around History.pushState() which handles non-HTML5 fallbacks,
* as well as global change tracking. Change tracking needs to be synchronous rather than event/callback
* based because the user needs to be able to abort the action completely.
*
* See handleStateChange() for more details.
*
* Parameters:
* - {String} url
* - {String} title New window title
* - {Object} data Any additional data passed through to History.pushState()
* - {boolean} forceReload Forces the replacement of the current history state, even if the URL is the same, i.e. allows reloading.
*/
loadPanel: function(url, title, data, forceReload, forceReferer) {
if(!data) data = {};
if(!title) title = "";
if (!forceReferer) forceReferer = History.getState().url;
// Check for unsaved changes
if(!this.checkCanNavigate(data.pjax ? data.pjax.split(',') : ['Content'])) {
return;
}
// Save tab selections so we can restore them later
this.saveTabState();
if(window.History.enabled) {
$.extend(data, {__forceReferer: forceReferer});
// Active menu item is set based on X-Controller ajax header,
// which matches one class on the menu
if(forceReload) {
// Add a parameter to make sure the page gets reloaded even if the URL is the same.
$.extend(data, {__forceReload: Math.random()});
window.History.replaceState(data, title, url);
} else {
window.History.pushState(data, title, url);
}
} else {
window.location = $.path.makeUrlAbsolute(url, $('base').attr('href'));
}
},
/**
* Nice wrapper for reloading current history state.
*/
reloadCurrentPanel: function() {
this.loadPanel(window.History.getState().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('.Actions :submit[name=action_save]');
// default to first button if none given - simulates browser behaviour
if(!button) button = this.find('.Actions :submit:first');
form.trigger('beforesubmitform');
this.trigger('submitform', {form: form, button: button});
// set button to "submitting" state
$(button).addClass('loading');
// validate if required
var validationResult = form.validate();
if(typeof validationResult!=='undefined' && !validationResult) {
// TODO Automatically switch to the tab/position of the first error
statusMessage("Validation failed.", "bad");
$(button).removeClass('loading');
return false;
}
// 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.
// TODO Replaces trailing slashes added by History after locale (e.g. admin/?locale=en/)
formData.push({name: 'BackURL', value:History.getPageUrl().replace(/\/$/, '')});
// Save tab selections so we can restore them later
this.saveTabState();
// 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"},
url: form.attr('action'),
data: formData,
type: 'POST',
complete: function() {
$(button).removeClass('loading');
},
success: function(data, status, xhr) {
form.removeClass('changed'); // TODO This should be using the plugin API
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.History.pushState().
* Use loadPanel() as a pushState() 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 pushState(), 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 History.pushState()
* - 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() {
if(this.getPauseState()) {
return;
}
// Don't allow parallel loading to avoid edge cases
if(this.getStateChangeXHR()) this.getStateChangeXHR().abort();
var self = this, h = window.History, state = h.getState(),
fragments = state.data.pjax || 'Content', headers = {},
fragmentsArr = fragments.split(','),
contentEls = this._findFragments(fragmentsArr);
// For legacy IE versions (IE7 and IE8), reload without ajax
// as a crude way to fix memory leaks through whole window refreshes.
this.setStateChangeCount(this.getStateChangeCount() + 1);
var isLegacyIE = ($.browser.msie && parseInt($.browser.version, 10) < 9);
if(isLegacyIE && this.getStateChangeCount() > 20) {
document.location.href = state.url;
return;
}
if(!this.checkCanNavigate()) {
// If history is emulated (ie8 or below) disable attempting to restore
if(h.emulated.pushState) {
return;
}
var lastState = this.getLastState();
// Suppress panel loading while resetting state
this.setPauseState(true);
// Restore best last state
if(lastState) {
h.pushState(lastState.id, lastState.title, lastState.url);
} else {
h.back();
}
this.setPauseState(false);
// Abort loading of this panel
return;
}
this.setLastState(state);
// 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: state, 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;
// Set 'fake' referer - we call pushState() before making the AJAX request, so we have to
// set our own referer here
if (typeof state.data.__forceReferer !== 'undefined') {
// Ensure query string is properly encoded if present
var url = state.data.__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');
var xhr = $.ajax({
headers: headers,
url: state.url,
complete: function() {
self.setStateChangeXHR(null);
// Remove loading indication from old content els (regardless of which are replaced)
contentEls.removeClass('loading');
},
success: function(data, status, xhr) {
var els = self.handleAjaxResponse(data, status, xhr, state);
self.trigger('afterstatechange', {data: data, status: status, xhr: xhr, element: els, state: state});
}
});
this.setStateChangeXHR(xhr);
},
/**
* 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) {
var self = this, url, selectedTabs, guessFragment;
// Support a full reload
if(xhr.getResponseHeader('X-Reload') && xhr.getResponseHeader('X-ControllerURL')) {
var baseUrl = $('base').attr('href'),
rawURL = xhr.getResponseHeader('X-ControllerURL'),
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, ' '));
var newFragments = {}, newContentEls;
// If content type is text/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
var fragment = document.createDocumentFragment();
jQuery.clean( [ data ], document, fragment, [] );
$data = $(jQuery.merge( [], fragment.childNodes ));
// Try and guess the fragment if none is provided
// TODO: data-pjax-fragment might actually give us the fragment. For now we just check most common case
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 origParentLayoutApplied = (typeof origParent.data('jlayout')!=='undefined');
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);
// Force jlayout to rebuild internal hierarchy to point to the new elements.
// This is only necessary for elements that are at least 3 levels deep. 2nd level elements will
// be taken care of when we lay out the top level element (.cms-container).
if (!origParent.is('.cms-container') && origParentLayoutApplied) {
origParent.layout();
}
});
// 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.data.tabState !== 'undefined') ? state.data.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.
*/
saveTabState: function() {
if(typeof(window.sessionStorage)=="undefined" || window.sessionStorage === null) return;
var selectedTabs = [], url = this._tabStateUrl();
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('tabs')) 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:$(el).tabs('option', 'selected')});
});
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) {
var self = this, url = this._tabStateUrl(),
hasSessionStorage = (typeof(window.sessionStorage)!=="undefined" && window.sessionStorage),
sessionData = hasSessionStorage ? window.sessionStorage.getItem('tabs-' + url) : null,
sessionStates = sessionData ? JSON.parse(sessionData) : false;
this.find('.cms-tabset, .ss-tabset').each(function() {
var index, tabset = $(this), tabsetId = tabset.attr('id'), tab,
forcedTab = tabset.find('.ss-tabs-force-active');
if(!tabset.data('tabs')){
return; // don't act on uninit'ed controls
}
// The tabs may have changed, notify the widget that it should update its internal state.
tabset.tabs('refresh');
// Make sure the intended tab is selected.
if(forcedTab.length) {
index = forcedTab.index();
} else if(overrideStates && overrideStates[tabsetId]) {
tab = tabset.find(overrideStates[tabsetId].tabSelector);
if(tab.length){
index = tab.index();
}
} else if(sessionStates) {
$.each(sessionStates, function(i, sessionState) {
if(tabset.is('#' + sessionState.id)){
index = sessionState.selected;
}
});
}
if(index !== null){
tabset.tabs('option', 'active', index);
self.trigger('tabstaterestored');
}
});
},
/**
* Remove any previously saved state.
*
* Parameters:
* (String) url Optional (sanitized) URL to clear a specific state.
*/
clearTabState: function(url) {
if(typeof(window.sessionStorage)=="undefined") return;
var s = window.sessionStorage;
if(url) {
s.removeItem('tabs-' + url);
} else {
for(var i=0;i<s.length;i++) {
if(s.key(i).match(/^tabs-/)) s.removeItem(s.key(i));
}
}
},
/**
* Remove tab state for the current URL.
*/
clearCurrentTabState: function() {
this.clearTabState(this._tabStateUrl());
},
_tabStateUrl: function() {
return History.getState().url
.replace(/\?.*/, '')
.replace(/#.*/, '')
.replace($('base').attr('href'), '');
},
showLoginDialog: function() {
var tempid = $('body').data('member-tempid'),
dialog = $('.leftandmain-logindialog'),
url = 'CMSSecurity/login';
// Force regeneration of any existing dialog
if(dialog.length) dialog.remove();
// Join url params
url = $.path.addSearchParams(url, {
'tempid': tempid,
'BackURL': window.location.href
});
// Show a placeholder for instant feedback. Will be replaced with actual
// form dialog once its loaded.
dialog = $('<div class="leftandmain-logindialog"></div>');
dialog.attr('id', new Date().getTime());
dialog.data('url', url);
$('body').append(dialog);
}
});
// Login dialog page
$('.leftandmain-logindialog').entwine({
onmatch: function() {
this._super();
// Create jQuery dialog
this.ssdialog({
iframeUrl: this.data('url'),
dialogClass: "leftandmain-logindialog-dialog",
autoOpen: true,
minWidth: 500,
maxWidth: 500,
minHeight: 370,
maxHeight: 400,
closeOnEscape: false,
open: function() {
$('.ui-widget-overlay').addClass('leftandmain-logindialog-overlay');
},
close: function() {
$('.ui-widget-overlay').removeClass('leftandmain-logindialog-overlay');
}
});
},
onunmatch: function() {
this._super();
},
open: function() {
this.ssdialog('open');
},
close: function() {
this.ssdialog('close');
},
toggle: function(bool) {
if(this.is(':visible')) this.close();
else this.open();
},
/**
* Callback activated by CMSSecurity_success.ss
*/
reauthenticate: function(data) {
// Replace all SecurityID fields with the given value
if(typeof(data.SecurityID) !== 'undefined') {
$(':input[name=SecurityID]').val(data.SecurityID);
}
// Update TempID for current user
if(typeof(data.TempID) !== 'undefined') {
$('body').data('member-tempid', data.TempID);
}
this.close();
}
});
/**
* Add loading overlay to selected regions in the CMS automatically.
* Not applied to all "*.loading" elements to avoid secondary regions
* like the breadcrumbs showing unnecessary loading status.
*/
$('form.loading,.cms-content.loading,.cms-content-fields.loading,.cms-content-view.loading').entwine({
onmatch: function() {
this.append('<div class="cms-content-loading-overlay ui-widget-overlay-light"></div><div class="cms-content-loading-spinner"></div>');
this._super();
},
onunmatch: function() {
this.find('.cms-content-loading-overlay,.cms-content-loading-spinner').remove();
this._super();
}
});
/** Make all buttons "hoverable" with jQuery theming. */
$('.cms input[type="submit"], .cms button, .cms input[type="reset"], .cms .ss-ui-button').entwine({
onadd: function() {
this.addClass('ss-ui-button');
if(!this.data('button')) this.button();
this._super();
},
onremove: function() {
if(this.data('button')) this.button('destroy');
this._super();
}
});
/**
* Loads the link's 'href' attribute into a panel via ajax,
* as opposed to triggering a full page reload.
* Little helper to avoid repetition, and make it easy to
* "opt in" to panel loading, while by default links still exhibit their default behaviour.
* The PJAX target can be specified via a 'data-pjax-target' attribute.
*/
$('.cms .cms-panel-link').entwine({
onclick: function(e) {
if($(this).hasClass('external-link')) {
e.stopPropagation();
return;
}
var href = this.attr('href'),
url = (href && !href.match(/^#/)) ? href : this.data('href'),
data = {pjax: this.data('pjaxTarget')};
$('.cms-container').loadPanel(url, null, data);
e.preventDefault();
}
});
/**
* Does an ajax loads of the link's 'href' attribute via ajax and displays any FormResponse messages from the CMS.
* Little helper to avoid repetition, and make it easy to trigger actions via a link,
* without reloading the page, changing the URL, or loading in any new panel content.
*/
$('.cms .ss-ui-button-ajax').entwine({
onclick: function(e) {
$(this).removeClass('ui-button-text-only');
$(this).addClass('ss-ui-button-loading ui-button-text-icons');
var loading = $(this).find(".ss-ui-loading-icon");
if(loading.length < 1) {
loading = $("<span></span>").addClass('ss-ui-loading-icon ui-button-icon-primary ui-icon');
$(this).prepend(loading);
}
loading.show();
var href = this.attr('href'), url = href ? href : this.data('href');
jQuery.ajax({
url: url,
// Ensure that form view is loaded (rather than whole "Content" template)
complete: function(xmlhttp, status) {
var msg = (xmlhttp.getResponseHeader('X-Status')) ? xmlhttp.getResponseHeader('X-Status') : xmlhttp.responseText;
try {
if (typeof msg != "undefined" && msg !== null) eval(msg);
}
catch(e) {}
loading.hide();
$(".cms-container").refresh();
$(this).removeClass('ss-ui-button-loading ui-button-text-icons');
$(this).addClass('ui-button-text-only');
},
dataType: 'html'
});
e.preventDefault();
}
});
/**
* Trigger dialogs with iframe based on the links href attribute (see ssui-core.js).
*/
$('.cms .ss-ui-dialog-link').entwine({
UUID: null,
onmatch: function() {
this._super();
this.setUUID(new Date().getTime());
},
onunmatch: function() {
this._super();
},
onclick: function() {
this._super();
var self = this, id = 'ss-ui-dialog-' + this.getUUID();
var dialog = $('#' + id);
if(!dialog.length) {
dialog = $('<div class="ss-ui-dialog" id="' + id + '" />');
$('body').append(dialog);
}
var extraClass = this.data('popupclass')?this.data('popupclass'):'';
dialog.ssdialog({iframeUrl: this.attr('href'), autoOpen: true, dialogExtraClass: extraClass});
return false;
}
});
/**
* Add styling to all contained buttons, and create buttonsets if required.
*/
$('.cms-content .Actions').entwine({
onmatch: function() {
this.find('.ss-ui-button').click(function() {
var form = this.form;
// forms don't natively store the button they've been triggered with
if(form) {
form.clickedButton = this;
// Reset the clicked button shortly after the onsubmit handlers
// have fired on the form
setTimeout(function() {
form.clickedButton = null;
}, 10);
}
});
this.redraw();
this._super();
},
onunmatch: function() {
this._super();
},
redraw: function() {
if(window.debug) console.log('redraw', this.attr('class'), this.get(0));
// Remove whitespace to avoid gaps with inline elements
this.contents().filter(function() {
return (this.nodeType == 3 && !/\S/.test(this.nodeValue));
}).remove();
// Init buttons if required
this.find('.ss-ui-button').each(function() {
if(!$(this).data('button')) $(this).button();
});
// Mark up buttonsets
this.find('.ss-ui-buttonset').buttonset();
}
});
/**
* Duplicates functionality in DateField.js, but due to using entwine we can match
* the DOM element on creation, rather than onclick - which allows us to decorate
* the field with a calendar icon
*/
$('.cms .field.date input.text').entwine({
onmatch: function() {
var holder = $(this).parents('.field.date:first'), config = holder.data();
if(!config.showcalendar) {
this._super();
return;
}
config.showOn = 'button';
if(config.locale && $.datepicker.regional[config.locale]) {
config = $.extend(config, $.datepicker.regional[config.locale], {});
}
$(this).datepicker(config);
// // Unfortunately jQuery UI only allows configuration of icon images, not sprites
// this.next('button').button('option', 'icons', {primary : 'ui-icon-calendar'});
this._super();
},
onunmatch: function() {
this._super();
}
});
/**
* Styled dropdown select fields via chosen. Allows things like search and optgroup
* selection support. Rather than manually adding classes to selects we want
* styled, we style everything but the ones we tell it not to.
*
* For the CMS we also need to tell the parent div that it has a select so
* we can fix the height cropping.
*/
$('.cms .field.dropdown select, .cms .field select[multiple], .fieldholder-small select.dropdown').entwine({
onmatch: function() {
if(this.is('.no-chzn')) {
this._super();
return;
}
// Explicitly disable default placeholder if no custom one is defined
if(!this.data('placeholder')) this.data('placeholder', ' ');
// We could've gotten stale classes and DOM elements from deferred cache.
this.removeClass('has-chzn chzn-done');
this.siblings('.chzn-container').remove();
// Apply Chosen
applyChosen(this);
this._super();
},
onunmatch: function() {
this._super();
}
});
$(".cms-panel-layout").entwine({
redraw: function() {
if(window.debug) console.log('redraw', this.attr('class'), this.get(0));
}
});
/**
* Overload the default GridField behaviour (open a new URL in the browser)
* with the CMS-specific ajax loading.
*/
$('.cms .ss-gridfield').entwine({
showDetailView: function(url) {
// Include any GET parameters from the current URL, as the view state might depend on it.
// For example, a list prefiltered through external search criteria might be passed to GridField.
var params = window.location.search.replace(/^\?/, '');
if(params) url = $.path.addSearchParams(url, params);
$('.cms-container').loadPanel(url);
}
});
/**
* Generic search form in the CMS, often hooked up to a GridField results display.
*/
$('.cms-search-form').entwine({
onsubmit: function(e) {
// Remove empty elements and make the URL prettier
var nonEmptyInputs,
url;
nonEmptyInputs = this.find(':input:not(:submit)').filter(function() {
// Use fieldValue() from jQuery.form plugin rather than jQuery.val(),
// as it handles checkbox values more consistently
var vals = $.grep($(this).fieldValue(), function(val) { return (val);});
return (vals.length);
});
url = this.attr('action');
if(nonEmptyInputs.length) {
url = $.path.addSearchParams(url, nonEmptyInputs.serialize());
}
var container = this.closest('.cms-container');
container.find('.cms-edit-form').tabs('select',0); //always switch to the first tab (list view) when searching
container.loadPanel(url, "", {}, true);
return false;
}
});
/**
* Reset button handler. IE8 does not bubble reset events to
*/
$(".cms-search-form button[type=reset], .cms-search-form input[type=reset]").entwine({
onclick: function(e) {
e.preventDefault();
var form = $(this).parents('form');
form.clearForm();
form.find(".dropdown select").prop('selectedIndex', 0).trigger("liszt:updated"); // Reset chosen.js
form.submit();
}
});
/**
* Allows to lazy load a panel, by leaving it empty
* and declaring a URL to load its content via a 'url' HTML5 data attribute.
* The loaded HTML is cached, with cache key being the 'url' attribute.
* In order for this to work consistently, we assume that the responses are stateless.
* To avoid caching, add a 'deferred-no-cache' to the node.
*/
window._panelDeferredCache = {};
$('.cms-panel-deferred').entwine({
onadd: function() {
this._super();
this.redraw();
},
onremove: function() {
if(window.debug) console.log('saving', this.data('url'), this);
// Save the HTML state at the last possible moment.
// Don't store the DOM to avoid memory leaks.
if(!this.data('deferredNoCache')) window._panelDeferredCache[this.data('url')] = this.html();
this._super();
},
redraw: function() {
if(window.debug) console.log('redraw', this.attr('class'), this.get(0));
var self = this, url = this.data('url');
if(!url) throw 'Elements of class .cms-panel-deferred need a "data-url" attribute';
this._super();
// If the node is empty, try to either load it from cache or via ajax.
if(!this.children().length) {
if(!this.data('deferredNoCache') && typeof window._panelDeferredCache[url] !== 'undefined') {
this.html(window._panelDeferredCache[url]);
} else {
this.addClass('loading');
$.ajax({
url: url,
complete: function() {
self.removeClass('loading');
},
success: function(data, status, xhr) {
self.html(data);
}
});
}
}
}
});
/**
* Lightweight wrapper around jQuery UI tabs.
* Ensures that anchor links are set properly,
* and any nested tabs are scrolled if they have
* their height explicitly set. This is important
* for forms inside the CMS layout.
*/
$('.cms-tabset').entwine({
onadd: function() {
// Can't name redraw() as it clashes with other CMS entwine classes
this.redrawTabs();
this._super();
},
onremove: function() {
if (this.data('tabs')) this.tabs('destroy');
this._super();
},
redrawTabs: function() {
this.rewriteHashlinks();
var id = this.attr('id'), activeTab = this.find('ul:first .ui-tabs-active');
if(!this.data('uiTabs')) this.tabs({
active: (activeTab.index() != -1) ? activeTab.index() : 0,
beforeLoad: function(e, ui) {
// Disable automatic ajax loading of tabs without matching DOM elements,
// determining if the current URL differs from the tab URL is too error prone.
return false;
},
activate: function(e, ui) {
// Accessibility: Simulate click to trigger panel load when tab is focused
// by a keyboard navigation event rather than a click
if(ui.newTab) {
ui.newTab.find('.cms-panel-link').click();
}
// Usability: Hide actions for "readonly" tabs (which don't contain any editable fields)
var actions = $(this).closest('form').find('.Actions');
if($(ui.newTab).closest('li').hasClass('readonly')) {
actions.fadeOut();
} else {
actions.show();
}
}
});
},
/**
* Ensure hash links are prefixed with the current page URL,
* otherwise jQuery interprets them as being external.
*/
rewriteHashlinks: function() {
$(this).find('ul a').each(function() {
if (!$(this).attr('href')) return;
var matches = $(this).attr('href').match(/#.*/);
if(!matches) return;
$(this).attr('href', document.location.href.replace(/#.*/, '') + matches[0]);
});
}
});
/**
* CMS content filters
*/
$('#filters-button').entwine({
onmatch: function () {
this._super();
this.data('collapsed', true); // The current collapsed state of the element.
this.data('animating', false); // True if the element is currently animating.
},
onunmatch: function () {
this._super();
},
showHide: function () {
var self = this,
$filters = $('.cms-content-filters').first(),
collapsed = this.data('collapsed');
// Prevent the user from spamming the UI with animation requests.
if (this.data('animating')) {
return;
}
this.toggleClass('active');
this.data('animating', true);
// Slide the element down / up based on it's current collapsed state.
$filters[collapsed ? 'slideDown' : 'slideUp']({
complete: function () {
// Update the element's state.
self.data('collapsed', !collapsed);
self.data('animating', false);
}
});
},
onclick: function () {
this.showHide();
}
});
});
}(jQuery));
var statusMessage = function(text, type) {
text = jQuery('<div/>').text(text).html(); // Escape HTML entities in text
jQuery.noticeAdd({text: text, type: type, stayTime: 5000, inEffect: {left: '0', opacity: 'show'}});
};
var errorMessage = function(text) {
jQuery.noticeAdd({text: text, type: 'error', stayTime: 5000, inEffect: {left: '0', opacity: 'show'}});
};