jQuery.noConflict(); /** * File: LeftAndMain.js */ (function($) { // setup jquery.entwine $.entwine.warningLevel = $.entwine.WARN_LEVEL_BESTPRACTISE; $.entwine('ss', function($) { /** * 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.outerWidth()){ el.chosen({ 'disable_search_threshold' : 20, 'allow_single_deselect': true }).addClass("has-chzn"); // Copy over title attribute if required if(el.attr('title')) el.siblings('.chzn-container').attr('title', el.attr('title')); } else { setTimeout(function() {applyChosen(el);},500); } }; $(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'), opts, requestHeaders = settings.headers; // Normalize trailing slashes in URL to work around routing weirdnesses in SS_HTTPRequest. var isSame = (url && History.getPageUrl().replace(/\/+$/, '') == url.replace(/\/+$/, '')); if(url && !isSame) { opts = {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, msgType = (xhr.status < 200 || xhr.status > 399) ? 'bad' : 'good', ignoredMessages = ['OK']; // 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); } }); /** * Main LeftAndMain interface with some control panel and an edit form. * * Events: * ajaxsubmit - ... * validate - ... * aftersubmitform - ... */ $('.cms-container').entwine({ CurrentXHR: null, StateChangeCount: 0, /** * Constructor: onmatch */ onadd: function() { var self = this; // Browser detection if($.browser.msie && parseInt($.browser.version, 10) < 7) { $('.ss-loading-screen').append( '
' + 'Your browser is not compatible with the CMS interface. Please use Internet Explorer 7+, Google Chrome 10+ or Mozilla Firefox 3.5+.' + '
' ).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._super(); }, fromWindow: { onstatechange: function(){ this.handleStateChange(); }, onresize: function(){ this.redraw(); } }, 'from .cms-panel': { ontoggle: function(){ this.redraw(); } }, 'from .cms-container': { onaftersubmitform: function(){ this.redraw(); } }, redraw: function() { if(window.debug) console.log('redraw', this.attr('class'), this.get(0)); // Move from inner to outer layouts. Some of the elements might not exist. // Not all edit forms are layouted, so qualify by their data value. this.layout({resize: false}); 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(); this.layout({resize: false}); }, /** * 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() */ loadPanel: function(url, title, data) { if(!data) data = {}; if(!title) title = ""; // Check change tracking (can't use events as we need a way to cancel the current state change) var contentEls = this._findFragments(data.pjax ? data.pjax.split(',') : ['Content']); var trackedEls = contentEls.find(':data(changetracker)').add(contentEls.filter(':data(changetracker)')); if(trackedEls.length) { var abort = false; trackedEls.each(function() { if(!$(this).confirmUnsavedChanges()) abort = true; }); if(abort) return; } // Save tab selections so we can restore them later this.saveTabState(); if(window.History.enabled) { // Active menu item is set based on X-Controller ajax header, // which matches one class on the menu window.History.pushState(data, title, url); } else { window.location = $.path.makeUrlAbsolute(url, $('base').attr('href')); } }, /** * 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 if(!form.validate()) { // 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. formData.push({name: 'BackURL', value:History.getPageUrl()}); // 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; }, /** * 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() { // Don't allow parallel loading to avoid edge cases if(this.getCurrentXHR()) this.getCurrentXHR().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 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; contentEls.addClass('loading'); var xhr = $.ajax({ headers: headers, url: state.url, complete: function() { // 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); self.trigger('afterstatechange', {data: data, status: status, xhr: xhr, element: els}); } }); this.setCurrentXHR(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. */ handleAjaxResponse: function(data, status, xhr) { var self = this, url, selectedTabs, guessFragment; // 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 = title; var newFragments = {}, newContentEls; if(xhr.getResponseHeader('Content-Type') == 'text/json') { newFragments = data; } else { // Fall back to replacing the content fragment if HTML is returned $data = $(data); // 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; } // 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 origVisible = contentEl.is(':visible'); var layoutClasses = ['east', 'west', 'center', 'north', 'south']; 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); newContentEl.css('visibility', 'hidden'); // 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); // Unset loading and restore element state (to avoid breaking existing panel visibility, e.g. with preview expanded) if(origVisible) newContentEl.css('visibility', 'visible'); }); // 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'); this.redraw(); this.restoreTabState(); 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 if($(el).data('ignoreTabState')) return; // allow opt-out selectedTabs.push({id:id, selected:$(el).tabs('option', 'selected')}); }); if(selectedTabs) window.sessionStorage.setItem('tabs-' + url, JSON.stringify(selectedTabs)); }, /** * Re-select previously saved tabs. * Requires HTML5 sessionStorage support. */ restoreTabState: function() { if(typeof(window.sessionStorage)=="undefined" || window.sessionStorage == null) return; var self = this, url = this._tabStateUrl(), data = window.sessionStorage.getItem('tabs-' + url), selectedTabs = data ? JSON.parse(data) : false; if(selectedTabs) { $.each(selectedTabs, function(i, selectedTab) { var el = self.find('#' + selectedTab.id); if(!el.data('tabs')) return; // don't act on uninit'ed controls el.tabs('select', selectedTab.selected); }); } }, /** * 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