Skip to content

Commit

Permalink
ENHANCEMENT PjaxResponseNegotiator for more structured partial ajax r…
Browse files Browse the repository at this point in the history
…efreshes, 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>)
  • Loading branch information
Sam Minnee authored and chillu committed Apr 5, 2012
1 parent 72985b6 commit e01b0aa
Show file tree
Hide file tree
Showing 8 changed files with 197 additions and 109 deletions.
89 changes: 55 additions & 34 deletions admin/code/LeftAndMain.php
Expand Up @@ -104,6 +104,11 @@ class LeftAndMain extends Controller implements PermissionProvider {
'css' => array(),
'themedcss' => array(),
);

/**
* @var PJAXResponseNegotiator
*/
protected $responseNegotiator;

/**
* @param Member $member
Expand Down Expand Up @@ -328,20 +333,29 @@ function handleRequest(SS_HTTPRequest $request, DataModel $model = null) {
$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);
}


Expand Down Expand Up @@ -391,20 +405,30 @@ static function menu_title_for_class($class) {
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);
}

/**
* 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;
}

//------------------------------------------------------------------------------------------//
Expand Down Expand Up @@ -680,13 +704,10 @@ public function save($data, $form) {
$form->saveInto($record, true);
$record->write();
$this->extend('onAfterSave', $record);

$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);
$this->setCurrentPageID($record->ID);

return $form->forTemplate();
$this->response->addHeader('X-Status', _t('LeftAndMain.SAVEDUP'));
return $this->getResponseNegotiator()->respond($request);
}

public function delete($data, $form) {
Expand All @@ -697,12 +718,12 @@ public function delete($data, $form) {
if(!$record || !$record->ID) throw new HTTPResponse_Exception("Bad record ID #" . (int)$data['ID'], 404);

$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'))
);
}

/**
Expand Down
9 changes: 7 additions & 2 deletions admin/javascript/LeftAndMain.AddForm.js
Expand Up @@ -87,7 +87,7 @@
var data = this.serializeArray();
data.push({name:'Suffix',value:newPages[parentID]++});
data.push({name:button.attr('name'),value:button.val()});

// TODO Should be set by hiddenfield already
jQuery('.cms-content').entwine('ss').loadForm(
this.attr('action'),
Expand All @@ -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);
Expand Down
12 changes: 7 additions & 5 deletions admin/javascript/LeftAndMain.Content.js
Expand Up @@ -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);
},

/**
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down
56 changes: 35 additions & 21 deletions admin/javascript/LeftAndMain.js
Expand Up @@ -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);
}
}
},
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;
$(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);
}
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.
Expand Down Expand Up @@ -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)'));
Expand Down Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions control/PjaxResponseNegotiator.php
@@ -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;
}
}
45 changes: 0 additions & 45 deletions control/RequestHandler.php
Expand Up @@ -347,51 +347,6 @@ function isAjax() {
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
Expand Down

0 comments on commit e01b0aa

Please sign in to comment.