ENHANCEMENT PjaxResponseNegotiator for more structured partial ajax refreshes, applied in CMS and GridField. Also fixes issues with history.pushState() and pseudo-redirects on form submissions (e.g. from page/add to page/edit/show/<new-record-id>)

This commit is contained in:
Sam Minnee 2012-03-24 15:19:02 +13:00 committed by Ingo Schommer
parent 72985b6f42
commit e01b0aa3d0
8 changed files with 197 additions and 109 deletions

View File

@ -105,6 +105,11 @@ class LeftAndMain extends Controller implements PermissionProvider {
'themedcss' => array(),
);
/**
* @var PJAXResponseNegotiator
*/
protected $responseNegotiator;
/**
* @param Member $member
* @return boolean
@ -328,20 +333,29 @@ class LeftAndMain extends Controller implements PermissionProvider {
$response = parent::handleRequest($request, $model);
if(!$response->getHeader('X-Controller')) $response->addHeader('X-Controller', $this->class);
if(!$response->getHeader('X-Title')) $response->addHeader('X-Title', $title);
if(!$response->getHeader('X-ControllerURL')) {
$url = $request->getURL();
if($getVars = $request->getVars()) {
if(isset($getVars['url'])) unset($getVars['url']);
$url = Controller::join_links($url, $getVars ? '?' . http_build_query($getVars) : '');
}
$response->addHeader('X-ControllerURL', $url);
}
return $response;
}
/**
* Overloaded redirection logic to trigger a fake redirect on ajax requests.
* While this violates HTTP principles, its the only way to work around the
* fact that browsers handle HTTP redirects opaquely, no intervention via JS is possible.
* In isolation, that's not a problem - but combined with history.pushState()
* it means we would request the same redirection URL twice if we want to update the URL as well.
* See LeftAndMain.js for the required jQuery ajaxComplete handlers.
*/
function redirect($url, $code=302) {
if($this->request->isAjax()) {
$this->response->addHeader('X-ControllerURL', $url);
return ''; // Actual response will be re-requested by client
} else {
parent::redirect($url, $code);
}
}
function index($request) {
return ($request->isAjax()) ? $this->show($request) : $this->getViewer('index')->process($this);
return $this->getResponseNegotiator()->respond($request);
}
@ -391,20 +405,30 @@ class LeftAndMain extends Controller implements PermissionProvider {
public function show($request) {
// TODO Necessary for TableListField URLs to work properly
if($request->param('ID')) $this->setCurrentPageID($request->param('ID'));
if($this->isAjax()) {
if($request->getVar('cms-view-form')) {
$form = $this->getEditForm();
$content = $form->forTemplate();
} else {
// Rendering is handled by template, which will call EditForm() eventually
$content = $this->renderWith($this->getTemplatesWithSuffix('_Content'));
}
} else {
$content = $this->renderWith($this->getViewer('show'));
return $this->getResponseNegotiator()->respond($request);
}
return $content;
/**
* Caution: Volatile API.
*
* @return PJAXResponseNegotiator
*/
protected function getResponseNegotiator() {
if(!$this->responseNegotiator) {
$controller = $this;
$this->responseNegotiator = new PJAXResponseNegotiator(array(
'CurrentForm' => function() use(&$controller) {
return $controller->getEditForm()->forTemplate();
},
'Content' => function() use(&$controller) {
return $controller->renderWith($controller->getTemplatesWithSuffix('_Content'));
},
'default' => function() use(&$controller) {
return $controller->renderWith($controller->getViewer('show'));
}
));
}
return $this->responseNegotiator;
}
//------------------------------------------------------------------------------------------//
@ -680,13 +704,10 @@ class LeftAndMain extends Controller implements PermissionProvider {
$form->saveInto($record, true);
$record->write();
$this->extend('onAfterSave', $record);
$this->setCurrentPageID($record->ID);
$this->response->addHeader('X-Status', _t('LeftAndMain.SAVEDUP'));
// write process might've changed the record, so we reload before returning
$form = $this->getEditForm($record->ID);
return $form->forTemplate();
return $this->getResponseNegotiator()->respond($request);
}
public function delete($data, $form) {
@ -698,11 +719,11 @@ class LeftAndMain extends Controller implements PermissionProvider {
$record->delete();
if($this->isAjax()) {
return $this->EmptyForm()->forTemplate();
} else {
$this->redirectBack();
}
$this->response->addHeader('X-Status', _t('LeftAndMain.SAVEDUP'));
return $this->getResponseNegotiator()->respond(
$request,
array('currentform' => array($this, 'EmptyForm'))
);
}
/**

View File

@ -96,7 +96,12 @@
// Tree updates are triggered by Form_EditForm load events
button.removeClass('loading');
},
{type: 'POST', data: data}
{
type: 'POST',
data: data,
// Refresh the whole area to avoid reloading just the form, without the tree around it
headers: {'X-Pjax': 'Content'}
}
);
this.setNewPages(newPages);

View File

@ -55,16 +55,18 @@
this.trigger('loadform', {form: form, url: url});
return jQuery.ajax(jQuery.extend({
url: url,
var opts = jQuery.extend({}, {
// Ensure that form view is loaded (rather than whole "Content" template)
data: {'cms-view-form': 1},
headers: {"X-Pjax" : "CurrentForm"},
url: url,
complete: function(xmlhttp, status) {
self.loadForm_responseHandler(form, xmlhttp.responseText, status, xmlhttp);
if(callback) callback.apply(self, arguments);
},
dataType: 'html'
}, ajaxOptions));
}, ajaxOptions);
return jQuery.ajax(opts);
},
/**
@ -148,6 +150,7 @@
formData.push({name: 'BackURL', value:History.getPageUrl()});
jQuery.ajax(jQuery.extend({
headers: {"X-Pjax" : "CurrentForm"},
url: form.attr('action'),
data: formData,
type: 'POST',
@ -289,7 +292,6 @@
if($.path.isExternal($(node).find('a:first'))) url = url = $.path.makeUrlAbsolute(url, $('base').attr('href'));
// Reload only edit form if it exists (side-by-side view of tree and edit view), otherwise reload whole panel
if(container.find('.cms-edit-form').length) {
url += '?cms-view-form=1';
container.entwine('ss').loadPanel(url, null, {selector: '.cms-edit-form'});
} else {
container.entwine('ss').loadPanel(url);

View File

@ -33,27 +33,28 @@ jQuery.noConflict();
$(window).bind('resize', positionLoadingSpinner).trigger('resize');
// global ajax handlers
$.ajaxSetup({
complete: function(xhr) {
// Simulates a redirect on an ajax response - just exchange the URL without re-requesting it.
// Causes non-pushState browser to re-request the URL, so ignore for those.
if(window.History.enabled && !History.emulated.pushState) {
$(document).ajaxComplete(function(e, xhr, settings) {
// Simulates a redirect on an ajax response.
if(window.History.enabled) {
var url = xhr.getResponseHeader('X-ControllerURL');
// Normalize trailing slashes in URL to work around routing weirdnesses in SS_HTTPRequest.
var isSame = (url && History.getPageUrl().replace(/\/+$/, '') == url.replace(/\/+$/, ''));
if(isSame) {
window.History.replaceState({}, '', url);
if(url && !isSame) {
var opts = {
pjax: settings.headers ? settings.headers['X-Pjax'] : null,
selector: settings.headers ? settings.headers['X-Pjax-Selector'] : null
};
window.History.pushState(opts, '', url);
}
}
},
error: function(xmlhttp, status, error) {
if(xmlhttp.status < 200 || xmlhttp.status > 399) {
var msg = (xmlhttp.getResponseHeader('X-Status')) ? xmlhttp.getResponseHeader('X-Status') : xmlhttp.statusText;
});
$(document).ajaxError(function(e, xhr, settings, error) {
if(xhr.status < 200 || xhr.status > 399) {
var msg = (xhr.getResponseHeader('X-Status')) ? xhr.getResponseHeader('X-Status') : xhr.statusText;
} else {
msg = error;
}
statusMessage(msg, 'bad');
}
});
/**
@ -147,8 +148,8 @@ jQuery.noConflict();
loadPanel: function(url, title, data) {
if(!data) data = {};
if(!title) title = "";
var selector = data.selector || '.cms-content', contentEl = $(selector);
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)'));
@ -209,8 +210,21 @@ jQuery.noConflict();
state: state, element: contentEl
});
var headers = {};
if(state.data.pjax) {
headers['X-Pjax'] = state.data.pjax;
} else if(contentEl[0] != null && contentEl.is('form')) {
// Replace a form
headers["X-Pjax"] = 'CurrentForm';
} else {
// Replace full RHS content area
headers["X-Pjax"] = 'Content';
}
headers['X-Pjax-Selector'] = selector;
contentEl.addClass('loading');
var xhr = $.ajax({
headers: headers,
url: state.url,
success: function(data, status, xhr) {
// Update title

View File

@ -0,0 +1,69 @@
<?php
/**
* Handle the X-Pjax header that AJAX responses may provide, returning the
* fragment, or, in the case of non-AJAX form submissions, redirecting back to the submitter.
*
* X-Pjax ensures that users won't end up seeing the unstyled form HTML in their browser
* If a JS error prevents the Ajax overriding of form submissions from happening.
* It also provides better non-JS operation.
*
* Caution: This API is volatile, and might eventually be replaced by a generic
* action helper system for controllers.
*/
class PjaxResponseNegotiator {
/**
* @var Array See {@link respond()}
*/
protected $callbacks = array(
// TODO Using deprecated functionality, but don't want to duplicate Controller->redirectBack()
'default' => array('Director', 'redirectBack'),
);
/**
* @param RequestHandler $controller
* @param Array $callbacks
*/
function __construct($callbacks = array()) {
$this->callbacks = $callbacks;
}
/**
* Out of the box, the handler "CurrentForm" value, which will return the rendered form.
* Non-Ajax calls will redirect back.
*
* @param SS_HTTPRequest $request
* @param array $extraCallbacks List of anonymous functions or callables returning either a string
* or SS_HTTPResponse, keyed by their fragment identifier. The 'default' key can
* be used as a fallback for non-ajax responses.
* @return SS_HTTPResponse
*/
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)
);
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);
}
} 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']);
}
}
/**
* @param String $fragment
* @param Callable $callback
*/
public function setCallback($fragment, $callback) {
$this->callbacks[$fragment] = $callback;
}
}

View File

@ -347,51 +347,6 @@ class RequestHandler extends ViewableData {
return $this->request->isAjax();
}
/**
* Handle the X-Get-Fragment header that AJAX responses may provide, returning the
* fragment, or, in the case of non-AJAX form submissions, redirecting back to the submitter.
*
* X-Get-Fragment ensures that users won't end up seeing the unstyled form HTML in their browser
* If a JS error prevents the Ajax overriding of form submissions from happening. It also provides
* better non-JS operation.
*
* Out of the box, the handler "CurrentForm" value, which will return the rendered form. Non-Ajax
* calls will redirect back.
*
* To extend its responses, pass a map to the $options argument. Each key is the value of X-Get-Fragment
* that will work, and the value is a PHP 'callable' value that will return the response for that
* value.
*
* If you specify $options['default'], this will be used as the non-ajax response.
*
* Note that if you use handleFragmentResponse, then any Ajax requests will have to include X-Get-Fragment
* or an error will be thrown.
*/
function handleFragmentResponse($form, $options = array()) {
// Prepare the default options and combine with the others
$lOptions = array(
'currentform' => array($form, 'forTemplate'),
'default' => array('Director', 'redirectBack'),
);
if($options) foreach($options as $k => $v) {
$lOptions[strtolower($k)] = $v;
}
if($fragment = $this->request->getHeader('X-Get-Fragment')) {
$fragment = strtolower($fragment);
if(isset($lOptions[$fragment])) {
return call_user_func($lOptions[$fragment]);
} else {
throw new SS_HTTPResponse_Exception("X-Get-Fragment = '$fragment' not supported for this URL.", 400);
}
} else {
if($this->isAjax()) throw new SS_HTTPResponse_Exception("Ajax requests to this URL require an X-Get-Fragment header.", 400);
return call_user_func($lOptions['default']);
}
}
/**
* Returns the SS_HTTPRequest object that this controller is using.
* Returns a placeholder {@link NullHTTPRequest} object unless

View File

@ -23,7 +23,7 @@
form.addClass('loading');
$.ajax($.extend({}, {
headers: {"X-Get-Fragment" : 'CurrentField'},
headers: {"X-Pjax" : 'CurrentField'},
type: "POST",
url: this.data('url'),
dataType: 'html',
@ -217,7 +217,7 @@
var suggestionUrl = $(searchField).attr('data-search-url').substr(1,$(searchField).attr('data-search-url').length-2);
$.ajax({
headers: {
"X-Get-Fragment" : 'Partial'
"X-Pjax" : 'Partial'
},
type: "GET",
url: suggestionUrl+'/'+request.term,

View File

@ -0,0 +1,22 @@
<?php
class PjaxResponseNegotiatorTest extends SapphireTest {
function testDefaultCallbacks() {
$negotiator = new PjaxResponseNegotiator(array(
'default' => function() {return 'default response';},
));
$request = new SS_HTTPRequest('GET', '/'); // not setting pjax header
$this->assertEquals('default response', $negotiator->respond($request));
}
function testSelectsFragmentByHeader() {
$negotiator = new PjaxResponseNegotiator(array(
'default' => function() {return 'default response';},
'myfragment' => function() {return 'myfragment response';},
));
$request = new SS_HTTPRequest('GET', '/');
$request->addHeader('X-Pjax', 'myfragment');
$this->assertEquals('myfragment response', $negotiator->respond($request));
}
}