UNFINISHED Processing multiple PJAX responses on CMS JavaScript, introducing data-pjax-fragment attribute to identify reloadable template parts

This commit is contained in:
Ingo Schommer 2012-04-18 22:11:40 +02:00
parent 473eda43cb
commit 5178954311
11 changed files with 142 additions and 71 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

@ -86,8 +86,7 @@
// sending back different `X-Pjax` headers and content
jQuery.ajax(jQuery.extend({
headers: {
"X-Pjax" : "CurrentForm",
'X-Pjax-Selector': '.cms-edit-form'
"X-Pjax" : "CurrentForm,Breadcrumbs"
},
url: form.attr('action'),
data: formData,

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);
}
}
@ -205,30 +202,24 @@ 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 selector = state.data.selector || '.cms-content', contentEl = $(selector);
var self = this, h = window.History, state = h.getState(),
fragments = state.data.pjax || 'Content', headers = {},
reduceFn = function(fragment) {return '[data-pjax-fragment="' + fragment + '"]';},
contentEls = $($.map(fragments.split(','), reduceFn).join(','));
this.trigger('beforestatechange', {
state: state, element: contentEl
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,
@ -240,46 +231,60 @@ jQuery.noConflict();
// 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';
// Remove loading indication from old content els (regardless of which are replaced)
contentEls.removeClass('loading');
var newFragments = {};
if(xhr.getResponseHeader('Content-Type') == 'text/json') {
newFragments = data;
} else {
// Fall back to replacing the first fragment only if HTML is returned
newFragments[fragments.split(',').pop()] = data;
}
$.each(newFragments, function(newFragment, html) {
var contentEl = $('[data-pjax-fragment=' + newFragment + ']'), newContentEl = $(html);
// 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
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');
});
// 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});
},
error: function(xhr, status, e) {
@ -290,6 +295,7 @@ jQuery.noConflict();
this.setCurrentXHR(xhr);
},
/**
* Function: refresh
*

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

@ -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' )
),