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

View File

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

View File

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

View File

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

View File

@ -40,10 +40,7 @@ jQuery.noConflict();
// Normalize trailing slashes in URL to work around routing weirdnesses in SS_HTTPRequest. // Normalize trailing slashes in URL to work around routing weirdnesses in SS_HTTPRequest.
var isSame = (url && History.getPageUrl().replace(/\/+$/, '') == url.replace(/\/+$/, '')); var isSame = (url && History.getPageUrl().replace(/\/+$/, '') == url.replace(/\/+$/, ''));
if(url && !isSame) { if(url && !isSame) {
opts = { opts = {pjax: xhr.getResponseHeader('X-Pjax') ? xhr.getResponseHeader('X-Pjax') : settings.headers['X-Pjax']};
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']
};
window.History.pushState(opts, '', url); window.History.pushState(opts, '', url);
} }
} }
@ -205,30 +202,24 @@ jQuery.noConflict();
* if the URL is loaded without ajax. * if the URL is loaded without ajax.
*/ */
handleStateChange: function() { handleStateChange: function() {
var self = this, h = window.History, state = h.getState();
// Don't allow parallel loading to avoid edge cases // Don't allow parallel loading to avoid edge cases
if(this.getCurrentXHR()) this.getCurrentXHR().abort(); 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', { this.trigger('beforestatechange', {
state: state, element: contentEl state: state, element: contentEls
}); });
// Set Pjax headers, which can declare a preference for the returned view. // Set Pjax headers, which can declare a preference for the returned view.
// The actually returned view isn't always decided upon when the request // 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. // is fired, so the server might decide to change it based on its own logic.
var headers = {}; headers['X-Pjax'] = fragments;
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;
contentEl.addClass('loading'); contentEls.addClass('loading');
var xhr = $.ajax({ var xhr = $.ajax({
headers: headers, headers: headers,
url: state.url, url: state.url,
@ -241,44 +232,58 @@ jQuery.noConflict();
var title = xhr.getResponseHeader('X-Title'); var title = xhr.getResponseHeader('X-Title');
if(title) document.title = title; if(title) document.title = title;
// Update panels // Remove loading indication from old content els (regardless of which are replaced)
var newContentEl = $(data); contentEls.removeClass('loading');
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'; 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;
} }
// Set loading state and store element state $.each(newFragments, function(newFragment, html) {
newContentEl.addClass('loading'); var contentEl = $('[data-pjax-fragment=' + newFragment + ']'), newContentEl = $(html);
var origStyle = contentEl.attr('style');
var layoutClasses = ['east', 'west', 'center', 'north', 'south'];
var elemClasses = contentEl.attr('class');
var origLayoutClasses = []; // Update panels
if(elemClasses) { if(newContentEl.find('.cms-container').length) {
origLayoutClasses = $.grep( throw 'Content loaded via ajax is not allowed to contain tags matching the ".cms-container" selector to avoid infinite loops';
elemClasses.split(' '), }
function(val) { return ($.inArray(val, layoutClasses) >= 0);}
);
}
newContentEl // Set loading state and store element state
.removeClass(layoutClasses.join(' ')) newContentEl.addClass('loading');
.addClass(origLayoutClasses.join(' ')); var origStyle = contentEl.attr('style');
if(origStyle) newContentEl.attr('style', origStyle); var layoutClasses = ['east', 'west', 'center', 'north', 'south'];
newContentEl.css('visibility', 'hidden'); var elemClasses = contentEl.attr('class');
// Allow injection of inline styles, as they're not allowed in the document body. var origLayoutClasses = [];
// Not handling this through jQuery.ondemand to avoid parsing the DOM twice. if(elemClasses) {
var styles = newContentEl.find('style').detach(); origLayoutClasses = $.grep(
if(styles.length) $(document).find('head').append(styles); elemClasses.split(' '),
function(val) { return ($.inArray(val, layoutClasses) >= 0);}
);
}
// Replace panel completely (we need to override the "layout" attribute, so can't replace the child instead) newContentEl
contentEl.replaceWith(newContentEl); .removeClass(layoutClasses.join(' '))
.addClass(origLayoutClasses.join(' '));
if(origStyle) newContentEl.attr('style', origStyle);
newContentEl.css('visibility', 'hidden');
// Unset loading and restore element state (to avoid breaking existing panel visibility, e.g. with preview expanded) // Allow injection of inline styles, as they're not allowed in the document body.
self.redraw(); // Not handling this through jQuery.ondemand to avoid parsing the DOM twice.
newContentEl.css('visibility', 'visible'); var styles = newContentEl.find('style').detach();
newContentEl.removeClass('loading'); 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}); self.trigger('afterstatechange', {data: data, status: status, xhr: xhr, element: newContentEl});
}, },
@ -290,6 +295,7 @@ jQuery.noConflict();
this.setCurrentXHR(xhr); this.setCurrentXHR(xhr);
}, },
/** /**
* Function: refresh * Function: refresh
* *

View File

@ -1,4 +1,4 @@
<div class="breadcrumbs-wrapper"> <div class="breadcrumbs-wrapper" data-pjax-fragment="Breadcrumbs">
<% loop Breadcrumbs %> <% loop Breadcrumbs %>
<% if Last %> <% if Last %>
<span class="cms-panel-link crumb">$Title.XML</span> <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 $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 class="cms-content-header north">
<div> <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`. 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 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. 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/) 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. 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>)` 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 `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). 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 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 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. See `$('.cms-container').handleStateChange()` in `LeftAndMain.js` for details.
Alternatively, form-related Ajax calls can be invoked through their own wrappers, 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 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()]`). through identifiers like `CurrentForm` or `Content` (see `[api:LeftAndMain->getResponseNegotiator()]`).
These identifiers are passed to `loadPanel()` via the `pjax` data option. 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 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, is fired, so the server might decide to change it based on its own logic,
sending back different `X-Pjax` headers and content. sending back different `X-Pjax` headers and content.
## Ajax Redirects ## Ajax Redirects
Sometimes, a server response represents a new URL state, e.g. when submitting an "add record" form, 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. 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: 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 into a PJAX panel: `<a href="..." class="cms-panel-link" data-pjax-target="Content">`
* Load via ajax, and show response status message: `<a href="..." class="cms-link-ajax">`
* Load URL as an iframe into a popup/dialog: `<a href="..." class="ss-ui-dialog-link">` * Load URL as an iframe into a popup/dialog: `<a href="..." class="ss-ui-dialog-link">`
## Buttons ## 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 // TODO Allow customization, e.g. to display an edit form alongside a search form from the CMS controller
$form->setTemplate('LeftAndMain_EditForm'); $form->setTemplate('LeftAndMain_EditForm');
$form->addExtraClass('cms-content cms-edit-form center ss-tabset'); $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'); if($form->Fields()->hasTabset()) $form->Fields()->findOrMakeTab('Root')->setTemplate('CMSTabSet');
// TODO Link back to controller action (and edited root record) rather than index, // 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. // 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(); //$controller = $gridField->getForm()->Controller();
$forTemplate = new ArrayData(array( $forTemplate = new ArrayData(array(
'UpLink' => sprintf( '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, $parentID,
_t('GridField.LEVELUP', 'Level up' ) _t('GridField.LEVELUP', 'Level up' )
), ),