mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 12:05:37 +00:00
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:
parent
72985b6f42
commit
e01b0aa3d0
@ -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'));
|
||||
return $this->getResponseNegotiator()->respond($request);
|
||||
}
|
||||
|
||||
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'));
|
||||
/**
|
||||
* 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 $content;
|
||||
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'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -33,28 +33,29 @@ 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) {
|
||||
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);
|
||||
}
|
||||
$(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(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;
|
||||
} else {
|
||||
msg = error;
|
||||
}
|
||||
statusMessage(msg, 'bad');
|
||||
}
|
||||
});
|
||||
$(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');
|
||||
});
|
||||
|
||||
/**
|
||||
* Main LeftAndMain interface with some control panel and an edit form.
|
||||
@ -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
|
||||
|
69
control/PjaxResponseNegotiator.php
Normal file
69
control/PjaxResponseNegotiator.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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,
|
||||
|
22
tests/control/PjaxResponseNegotiatorTest.php
Normal file
22
tests/control/PjaxResponseNegotiatorTest.php
Normal 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));
|
||||
}
|
||||
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user