jQuery.noConflict(); /** * File: LeftAndMain.js */ (function($) { var windowWidth, windowHeight; $(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 . */ 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 ); }; $(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); } }); /** * 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: 820, 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( '

' + 'Your browser is not compatible with the CMS interface. Please use Internet Explorer 8+, Google Chrome or Mozilla Firefox.' + '

' ).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(){ this.handleStateChange(); } }, '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(); }, /** * 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 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) { $.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; }, /** * 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.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 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=""). 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')) { document.location.href = $('base').attr('href').replace(/\/*$/, '') + '/' + xhr.getResponseHeader('X-ControllerURL'); 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('select', index); }); }, /** * 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'); 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('
'); 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 = $("").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 = $('
'); $('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]); }); } }); }); }(jQuery)); var statusMessage = function(text, type) { text = jQuery('
').text(text).html(); // Escape HTML entities in text jQuery.noticeAdd({text: text, type: type}); }; var errorMessage = function(text) { jQuery.noticeAdd({text: text, type: 'error'}); };