Skip to content

Commit

Permalink
UNFINISHED Processing multiple PJAX responses on CMS JavaScript, intr…
Browse files Browse the repository at this point in the history
…oducing data-pjax-fragment attribute to identify reloadable template parts
  • Loading branch information
chillu committed May 30, 2012
1 parent 473eda4 commit 5178954
Show file tree
Hide file tree
Showing 11 changed files with 141 additions and 70 deletions.
6 changes: 5 additions & 1 deletion admin/code/LeftAndMain.php
Expand Up @@ -352,7 +352,6 @@ function redirect($url, $code=302) {
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);
Expand Down Expand Up @@ -437,6 +436,9 @@ public function getResponseNegotiator() {
'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'));
}
Expand Down Expand Up @@ -951,6 +953,7 @@ public function getEditForm($id = null, $fields = null) {
$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');
Expand Down Expand Up @@ -1015,6 +1018,7 @@ function EmptyForm() {
$form->addExtraClass('cms-edit-form');
$form->addExtraClass('root-form');
$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
$form->setAttribute('data-pjax-fragment', 'CurrentForm');

return $form;
}
Expand Down
1 change: 1 addition & 0 deletions admin/code/ModelAdmin.php
Expand Up @@ -151,6 +151,7 @@ function getEditForm($id = null, $fields = null) {
$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);

Expand Down
1 change: 1 addition & 0 deletions admin/code/SecurityAdmin.php
Expand Up @@ -160,6 +160,7 @@ public function getEditForm($id = null, $fields = null) {
$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);

Expand Down
3 changes: 1 addition & 2 deletions admin/javascript/LeftAndMain.Content.js
Expand Up @@ -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,
Expand Down
116 changes: 61 additions & 55 deletions admin/javascript/LeftAndMain.js
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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,
Expand All @@ -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';
}

// 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);
// Remove loading indication from old content els (regardless of which are replaced)
contentEls.removeClass('loading');

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

self.trigger('afterstatechange', {data: data, status: status, xhr: xhr, element: newContentEl});
},
error: function(xhr, status, e) {
Expand All @@ -290,6 +295,7 @@ jQuery.noConflict();

this.setCurrentXHR(xhr);
},

/**
* Function: refresh
*
Expand Down
2 changes: 1 addition & 1 deletion admin/templates/CMSBreadcrumbs.ss
@@ -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>
Expand Down
2 changes: 1 addition & 1 deletion admin/templates/Includes/LeftAndMain_Content.ss
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion admin/templates/Includes/ModelAdmin_Content.ss
@@ -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>
Expand Down
75 changes: 67 additions & 8 deletions docs/en/reference/cms-architecture.md
Expand Up @@ -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.
Expand All @@ -167,26 +167,86 @@ 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
should be placed in jQuery.entinwe `onmatch()` rules which apply to the newly created DOM structures.
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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions forms/gridfield/GridFieldDetailForm.php
Expand Up @@ -326,6 +326,7 @@ function ItemEditForm() {
// 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.
Expand Down
2 changes: 1 addition & 1 deletion forms/gridfield/GridFieldLevelup.php
Expand Up @@ -33,7 +33,7 @@ public function getHTMLFragments($gridField) {
//$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' )
),
Expand Down

0 comments on commit 5178954

Please sign in to comment.