Merge branch 'pjax-multiple'

This commit is contained in:
Ingo Schommer 2012-06-05 14:01:18 +02:00
commit 12f2e1e176
15 changed files with 333 additions and 284 deletions

View File

@ -352,7 +352,6 @@ class LeftAndMain extends Controller implements PermissionProvider {
if($this->request->isAjax()) {
$this->response->addHeader('X-ControllerURL', $url);
if($header = $this->request->getHeader('X-Pjax')) $this->response->addHeader('X-Pjax', $header);
if($header = $this->request->getHeader('X-Pjax-Selector')) $this->response->addHeader('X-Pjax-Selector', $header);
return ''; // Actual response will be re-requested by client
} else {
parent::redirect($url, $code);
@ -437,6 +436,9 @@ class LeftAndMain extends Controller implements PermissionProvider {
'Content' => function() use(&$controller) {
return $controller->renderWith($controller->getTemplatesWithSuffix('_Content'));
},
'Breadcrumbs' => function() use (&$controller) {
return $controller->renderWith('CMSBreadcrumbs');
},
'default' => function() use(&$controller) {
return $controller->renderWith($controller->getViewer('show'));
}
@ -951,6 +953,7 @@ class LeftAndMain extends Controller implements PermissionProvider {
$form->addExtraClass('cms-edit-form');
$form->loadDataFrom($record);
$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
$form->setAttribute('data-pjax-fragment', 'CurrentForm');
// Set this if you want to split up tabs into a separate header row
// if($form->Fields()->hasTabset()) $form->Fields()->findOrMakeTab('Root')->setTemplate('CMSTabSet');
@ -1015,6 +1018,7 @@ class LeftAndMain extends Controller implements PermissionProvider {
$form->addExtraClass('cms-edit-form');
$form->addExtraClass('root-form');
$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
$form->setAttribute('data-pjax-fragment', 'CurrentForm');
return $form;
}

View File

@ -151,6 +151,7 @@ abstract class ModelAdmin extends LeftAndMain {
$form->addExtraClass('cms-edit-form cms-panel-padded center');
$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
$form->setFormAction(Controller::join_links($this->Link($this->modelClass), 'EditForm'));
$form->setAttribute('data-pjax-fragment', 'CurrentForm');
$this->extend('updateEditForm', $form);

View File

@ -160,6 +160,7 @@ class SecurityAdmin extends LeftAndMain implements PermissionProvider {
$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
if($form->Fields()->hasTabset()) $form->Fields()->findOrMakeTab('Root')->setTemplate('CMSTabSet');
$form->addExtraClass('center ss-tabset cms-tabset ' . $this->BaseCSSClasses());
$form->setAttribute('data-pjax-fragment', 'CurrentForm');
$this->extend('updateEditForm', $form);

View File

@ -91,7 +91,7 @@
data.push({name:button.attr('name'),value:button.val()});
// TODO Should be set by hiddenfield already
$('.cms-content').submitForm(
$('.cms-container').submitForm(
this,
button,
function() {

View File

@ -29,169 +29,6 @@
this.add(this.find('.cms-tabset')).redrawTabs();
this.layout();
},
/**
* Function: ajaxSubmit
*
* Parameters:
* {DOMElement} button - The pressed button (optional)
* {Function} callback - Called in complete() handler of jQuery.ajax()
* {Object} ajaxOptions - Object literal to merge into $.ajax() call
* {boolean} loadResponse - Render response through _loadResponse() (Default: true)
*
* Returns:
* (boolean)
*/
submitForm: function(form, button, callback, ajaxOptions, loadResponse) {
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('beforesave');
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;
}
// save tab selections in order to reconstruct them later
var selectedTabs = [];
form.find('.cms-tabset').each(function(i, el) {
if($(el).attr('id')) selectedTabs.push({id:$(el).attr('id'), selected:$(el).tabs('option', 'selected')});
});
// 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()});
// 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",
'X-Pjax-Selector': '.cms-edit-form'
},
url: form.attr('action'),
data: formData,
type: 'POST',
complete: function(xmlhttp, status) {
$(button).removeClass('loading');
// TODO This should be using the plugin API
form.removeClass('changed');
if(callback) callback(xmlhttp, status);
// pass along original form data to enable old/new comparisons
if(loadResponse !== false) {
self.submitForm_responseHandler(form, xmlhttp.responseText, status, xmlhttp, formData);
}
// Re-init tabs (in case the form tag itself is a tabset)
if(self.hasClass('cms-tabset')) self.removeClass('cms-tabset').addClass('cms-tabset');
// re-select previously saved tabs
$.each(selectedTabs, function(i, selectedTab) {
form.find('#' + selectedTab.id).tabs('select', selectedTab.selected);
});
// Redraw the layout
$('.cms-container').redraw();
},
dataType: 'html'
}, ajaxOptions));
return false;
},
/**
* Function: _loadResponse
*
* Parameters:
* {String} data - Either HTML for straight insertion, or eval'ed JavaScript.
* If passed as HTML, it is assumed that everying inside the <form> tag is replaced,
* but the old <form> tag itself stays intact.
* {String} status
* {XMLHTTPRequest} xmlhttp - ..
* {Array} origData - The original submitted data, useful to do comparisons of changed
* values in new form output, e.g. to detect a URLSegment being changed on the serverside.
* Array in jQuery serializeArray() notation.
*/
submitForm_responseHandler: function(oldForm, data, status, xmlhttp, origData) {
if(status == 'success') {
if(!data) return;
var form, newContent = $(data);
// HACK If response contains toplevel panel rather than a form, replace it instead.
// For example, a page view shows tree + edit form. Deleting this page redirects to
// the "pages" overview, which doesn't have a separate tree panel.
if(newContent.is('.cms-content')) {
$('.cms-content').replaceWith(newContent);
} else {
form = this.replaceForm(oldForm, newContent);
}
if(typeof(Behaviour) != 'undefined') Behaviour.apply(); // refreshes ComplexTableField
this.trigger('reloadeditform', {form: form, origData: origData, xmlhttp: xmlhttp});
}
},
/**
* @return {jQuery} New form element
*/
replaceForm: function(form, html) {
if(html) {
var parent = form.parent(), id = form.attr('id');
form.replaceWith(html);
// Try to get the new form by ID (assuming they're identical), otherwise fall back to the first form in the parent
return id ? $('#' + id) : parent.children('form:first');
} else {
this.removeForm(form);
return null;
}
},
/**
* Function: removeForm
*
* Remove everying inside the <form> tag
* with a custom HTML fragment. Useful e.g. for deleting a page in the CMS.
* Checks for unsaved changes before removing the form
*
* Parameters:
* {String} placeholderHtml - Short note why the form has been removed, displayed in <p> tags.
* Falls back to the default RemoveText() option (Optional)
*/
removeForm: function(form, placeholderHtml) {
if(!placeholderHtml) placeholderHtml = this.getPlaceholderHtml();
// Alert when unsaved changes are present
if(!form.confirmUnsavedChanges()) return;
this.trigger('removeform');
this.html(placeholderHtml);
// TODO This should be using the plugin API
this.removeClass('changed');
}
});
@ -245,17 +82,6 @@
this._super();
}
});
$('.cms-content.loading,.cms-edit-form.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();
}
});
});
})(jQuery);

View File

@ -29,7 +29,6 @@
* Events:
* ajaxsubmit - Form is about to be submitted through ajax
* validate - Contains validation result
* removeform - A form is about to be removed from the DOM
* load - Form is about to be loaded through ajax
*/
$('.cms-edit-form').entwine(/** @lends ss.Form_EditForm */{
@ -158,7 +157,7 @@
// which means the browser auto-selects the first available form button.
// This might be an unrelated button of the form field,
// or a destructive action (if "save" is not available, or not on first position).
if(button) this.closest('.cms-content').submitForm(this, button);
if(button) this.closest('.cms-container').submitForm(this, button);
return false;
},

View File

@ -40,10 +40,7 @@ jQuery.noConflict();
// 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'],
selector: xhr.getResponseHeader('X-Pjax-Selector') ? xhr.getResponseHeader('X-Pjax-Selector') : settings.headers['X-Pjax-Selector']
};
opts = {pjax: xhr.getResponseHeader('X-Pjax') ? xhr.getResponseHeader('X-Pjax') : settings.headers['X-Pjax']};
window.History.pushState(opts, '', url);
}
}
@ -159,11 +156,10 @@ jQuery.noConflict();
loadPanel: function(url, title, data) {
if(!data) data = {};
if(!title) title = "";
if(!data.selector) data.selector = '.cms-content';
var contentEl = $(data.selector);
// Check change tracking (can't use events as we need a way to cancel the current state change)
var trackedEls = contentEl.find(':data(changetracker)').add(contentEl.filter(':data(changetracker)'));
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;
@ -183,6 +179,98 @@ jQuery.noConflict();
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('beforesave');
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;
}
// save tab selections in order to reconstruct them later
var selectedTabs = [];
form.find('.cms-tabset').each(function(i, el) {
if($(el).attr('id')) selectedTabs.push({id:$(el).attr('id'), selected:$(el).tabs('option', 'selected')});
});
// 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()});
// 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(xmlhttp, status);
var newContentEls = self.handleAjaxResponse(data, status, xhr);
if(!newContentEls) return;
var newForm = newContentEls.filter('form');
// Re-init tabs (in case the form tag itself is a tabset)
if(newForm.hasClass('cms-tabset')) newForm.removeClass('cms-tabset').addClass('cms-tabset');
// re-select previously saved tabs
$.each(selectedTabs, function(i, selectedTab) {
newForm.find('#' + selectedTab.id).tabs('select', selectedTab.selected);
});
// Redraw the layout
$('.cms-container').redraw();
form.trigger('reloadeditform', {form: newForm, formData: formData, xmlhttp: xhr});
},
dataType: 'json'
}, ajaxOptions));
return false;
},
/**
* Handles ajax loading of new panels through the window.History object.
@ -207,91 +295,129 @@ jQuery.noConflict();
* if the URL is loaded without ajax.
*/
handleStateChange: function() {
var self = this, h = window.History, state = h.getState();
// 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 = {},
contentEls = this._findFragments(fragments.split(','));
var selector = state.data.selector || '.cms-content', contentEl = $(selector);
this.trigger('beforestatechange', {
state: state, element: contentEl
});
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.
var headers = {};
if(state.data.pjax) {
headers['X-Pjax'] = state.data.pjax;
} else {
// Standard Pjax behaviour is to replace right content area
headers["X-Pjax"] = 'Content';
}
headers['X-Pjax-Selector'] = selector;
headers['X-Pjax'] = fragments;
contentEl.addClass('loading');
contentEls.addClass('loading');
var xhr = $.ajax({
headers: headers,
url: state.url,
success: function(data, status, xhr) {
// 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;
// Update panels
var newContentEl = $(data);
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
newContentEl.addClass('loading');
var origStyle = contentEl.attr('style');
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)
self.redraw();
newContentEl.css('visibility', 'visible');
newContentEl.removeClass('loading');
self.trigger('afterstatechange', {data: data, status: status, xhr: xhr, element: newContentEl});
complete: function() {
// Remove loading indication from old content els (regardless of which are replaced)
contentEls.removeClass('loading');
},
error: function(xhr, status, e) {
contentEl.removeClass('loading');
errorMessage(e);
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;
// 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
newFragments['Content'] = 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
newContentEls.add(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');
});
this.redraw();
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
*
@ -314,6 +440,22 @@ jQuery.noConflict();
}
});
/**
* 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.
* Also sets the clicked button on a form submission, making it available through
@ -346,16 +488,13 @@ jQuery.noConflict();
* 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.
* Same goes for breadcrumbs in the CMS.
* The PJAX target can be specified via a 'data-pjax-target' attribute.
*/
$('.cms .cms-panel-link').entwine({
onclick: function(e) {
var href = this.attr('href'),
url = (href && !href.match(/^#/)) ? href : this.data('href'),
data = {
selector: this.data('targetPanel'),
pjax: this.data('pjax')
};
data = {pjax: this.data('pjaxTarget')};
$('.cms-container').loadPanel(url, null, data);
e.preventDefault();
@ -681,7 +820,7 @@ jQuery.noConflict();
this.rewriteHashlinks();
var id = this.attr('id'), cookieId = 'ui-tabs-' + id,
selectedTab = this.find('ul:first .ui-state-selected');
selectedTab = this.find('ul:first .ui-tabs-selected');
// Fix for wrong cookie storage of deselected tabs
if($.cookie && id && $.cookie(cookieId) == -1) $.cookie(cookieId, 0);

View File

@ -1,4 +1,4 @@
<div class="breadcrumbs-wrapper">
<div class="breadcrumbs-wrapper" data-pjax-fragment="Breadcrumbs">
<% loop Breadcrumbs %>
<% if Last %>
<span class="cms-panel-link crumb">$Title.XML</span>

View File

@ -1,4 +1,4 @@
<div class="cms-content center $BaseCSSClasses" data-layout-type="border">
<div class="cms-content center $BaseCSSClasses" data-layout-type="border" data-pjax-fragment="Content">
$Tools

View File

@ -1,4 +1,4 @@
<div class="cms-content center $BaseCSSClasses" data-layout-type="border">
<div class="cms-content center $BaseCSSClasses" data-layout-type="border" data-pjax-fragment="Content">
<div class="cms-content-header north">
<div>

View File

@ -40,23 +40,26 @@ class PjaxResponseNegotiator {
*/
public function respond(SS_HTTPRequest $request, $extraCallbacks = array()) {
// Prepare the default options and combine with the others
$callbacks = array_merge(
array_change_key_case($this->callbacks, CASE_LOWER),
array_change_key_case($extraCallbacks, CASE_LOWER)
);
$callbacks = array_merge($this->callbacks, $extraCallbacks);
$response = new SS_HTTPResponse();
if($fragment = $request->getHeader('X-Pjax')) {
$fragment = strtolower($fragment);
if(isset($callbacks[$fragment])) {
return call_user_func($callbacks[$fragment]);
} else {
throw new SS_HTTPResponse_Exception("X-Pjax = '$fragment' not supported for this URL.", 400);
$responseParts = array();
if($fragmentStr = $request->getHeader('X-Pjax')) {
$fragments = explode(',', $fragmentStr);
foreach($fragments as $fragment) {
if(isset($callbacks[$fragment])) {
$responseParts[$fragment] = call_user_func($callbacks[$fragment]);
} else {
throw new SS_HTTPResponse_Exception("X-Pjax = '$fragment' not supported for this URL.", 400);
}
}
$response->setBody(Convert::raw2json($responseParts));
$response->addHeader('Content-Type', 'text/json');
} else {
if($request->isAjax()) throw new SS_HTTPResponse_Exception("Ajax requests to this URL require an X-Pjax header.", 400);
return call_user_func($callbacks['default']);
$response->setBody(call_user_func($callbacks['default']));
}
return $response;
}
/**

View File

@ -145,7 +145,7 @@ Selectors used in these files should mirrow the "scope" set by its filename,
so don't place a rule applying to all form buttons inside `ModelAdmin.js`.
The CMS relies heavily on Ajax-loading of interfaces, so each interface and the JavaScript
driving it have to assume its underlying DOM structure is appended via Ajax callback
driving it have to assume its underlying DOM structure is appended via an Ajax callback
rather than being available when the browser window first loads.
jQuery.entwine is effectively an advanced version of [jQuery.live](http://api.jquery.com/live/)
and [jQuery.delegate](http://api.jquery.com/delegate/), so takes care of dynamic event binding.
@ -167,9 +167,6 @@ a CMS developer needs to fire a navigation event rather than invoking the Ajax c
The main point of contact here is `$('.cms-container').loadPanel(<url>, <title>, <data>)`
in `LeftAndMain.js`. The `data` object can contain additional state which is required
in case the same navigation event is fired again (e.g. when the user pressed the back button).
Most commonly, the (optional) `data.selector` property declares which DOM element to replace
with the newly loaded HTML (it defaults to `.cms-content`). This is handy to only replace
e.g. an edit form, but leave the search panel in the same "content area" unchanged.
No callbacks are allowed in this style of Ajax loading, as all state needs
to be "repeatable". Any logic required to be exected after the Ajax call
@ -177,16 +174,79 @@ should be placed in jQuery.entinwe `onmatch()` rules which apply to the newly cr
See `$('.cms-container').handleStateChange()` in `LeftAndMain.js` for details.
Alternatively, form-related Ajax calls can be invoked through their own wrappers,
which don't cause history events and hence allow callbacks: `$('.cms-content').submitForm()`.
which don't cause history events and hence allow callbacks: `$('.cms-container').submitForm()`.
## PJAX: Partial template replacement through Ajax
Many user interactions can change more than one area in the CMS.
For example, editing a page title in the CMS form changes it in the page tree
as well as the breadcrumbs. In order to avoid unnecessary processing,
we often want to update these sections independently from their neighbouring content.
In order for this to work, the CMS templates declare certain sections as "PJAX fragments"
through a `data-pjax-fragment` attribute. These names correlate to specific
rendering logic in the PHP controllers, through the `[api:PjaxResponseNegotiator]` class.
Within the PHP logic, the `[api:PjaxResponseNegotiator]` class determines which view is rendered.
Through a custom `X-Pjax` HTTP header, the client can declare which view he's expecting,
through identifiers like `CurrentForm` or `Content` (see `[api:LeftAndMain->getResponseNegotiator()]`).
These identifiers are passed to `loadPanel()` via the `pjax` data option.
The HTTP response is a JSON object literal, with template replacements keyed by their Pjax fragment.
Through PHP callbacks, we ensure that only the required template parts are actually executed and rendered.
When the same URL is loaded without Ajax (and hence without `X-Pjax` headers),
it should behave like a normal full page template, but using the same controller logic.
Example: Create a bare-bones CMS subclass which shows breadcrumbs (a built-in method),
as well as info on the current record. A single link updates both sections independently
in a single Ajax request.
:::php
// mysite/code/MyAdmin.php
class MyAdmin extends LeftAndMain {
static $url_segment = 'myadmin';
public function getResponseNegotiator() {
$negotiator = parent::getResponseNegotiator();
$controller = $this;
// Register a new callback
$negotiator->setCallback('MyRecordInfo', function() use(&$controller) {
return $controller->MyRecordInfo();
});
return $negotiator;
}
public function MyRecordInfo() {
return $this->renderWith('MyRecordInfo');
}
}
:::js
// MyAdmin.ss
<% include CMSBreadcrumbs %>
<div>Static content (not affected by update)</div>
<% include MyRecordInfo %>
<a href="admin/myadmin" class="cms-panel-link" data-pjax-target="MyRecordInfo,Breadcrumbs">
Update record info
</a>
:::ss
// MyRecordInfo.ss
<div data-pjax-fragment="MyRecordInfo">
Current Record: $currentPage.Title
</div>
A click on the link will cause the following (abbreviated) ajax HTTP request:
GET /admin/myadmin HTTP/1.1
X-Pjax:Content
X-Requested-With:XMLHttpRequest
... and result in the following response:
{"MyRecordInfo": "<div...", "CMSBreadcrumbs": "<div..."}
Keep in mind that the returned view isn't always decided upon when the Ajax request
is fired, so the server might decide to change it based on its own logic,
sending back different `X-Pjax` headers and content.
## Ajax Redirects
Sometimes, a server response represents a new URL state, e.g. when submitting an "add record" form,
@ -226,8 +286,7 @@ Built-in headers are:
Some links should do more than load a new page in the browser window.
To avoid repetition, we've written some helpers for various use cases:
* Load into a panel: `<a href="..." class="cms-panel-link" data-target-panel=".cms-content">`
* Load via ajax, and show response status message: `<a href="..." class="cms-link-ajax">`
* Load into a PJAX panel: `<a href="..." class="cms-panel-link" data-pjax-target="Content">`
* Load URL as an iframe into a popup/dialog: `<a href="..." class="ss-ui-dialog-link">`
## Buttons

View File

@ -326,6 +326,7 @@ class GridFieldDetailForm_ItemRequest extends RequestHandler {
// TODO Allow customization, e.g. to display an edit form alongside a search form from the CMS controller
$form->setTemplate('LeftAndMain_EditForm');
$form->addExtraClass('cms-content cms-edit-form center ss-tabset');
$form->setAttribute('data-pjax-fragment', 'CurrentForm Content');
if($form->Fields()->hasTabset()) $form->Fields()->findOrMakeTab('Root')->setTemplate('CMSTabSet');
// TODO Link back to controller action (and edited root record) rather than index,
// which requires more URL knowledge than the current link to this field gives us.

View File

@ -33,7 +33,7 @@ class GridFieldLevelup implements GridField_HTMLProvider{
//$controller = $gridField->getForm()->Controller();
$forTemplate = new ArrayData(array(
'UpLink' => sprintf(
'<a class="cms-panel-link list-parent-link" href="?ParentID=%d&view=list" data-target-panel="#Form_ListViewForm" data-pjax="ListViewForm">%s</a>',
'<a class="cms-panel-link list-parent-link" href="?ParentID=%d&view=list" data-pjax-target="ListViewForm,Breadcrumbs">%s</a>',
$parentID,
_t('GridField.LEVELUP', 'Level up' )
),

View File

@ -19,4 +19,20 @@ class PjaxResponseNegotiatorTest extends SapphireTest {
$this->assertEquals('myfragment response', $negotiator->respond($request));
}
function testMultipleFragments() {
$negotiator = new PjaxResponseNegotiator(array(
'default' => function() {return 'default response';},
'myfragment' => function() {return 'myfragment response';},
'otherfragment' => function() {return 'otherfragment response';},
));
$request = new SS_HTTPRequest('GET', '/');
$request->addHeader('X-Pjax', 'myfragment,otherfragment');
$request->addHeader('Accept', 'text/json');
$json = json_decode($negotiator->respond($request));
$this->assertObjectHasAttribute('myfragment', $json);
$this->assertEquals('myfragment response', $json->myfragment);
$this->assertObjectHasAttribute('otherfragment', $json);
$this->assertEquals('otherfragment response', $json->otherfragment);
}
}