While converting some tests to class WebTestBase in Drupal 8, I came across an attempt to test a jQuery call for which the response was an object encoded in JSON.
Here is the solution I came up with for automated checking of this functionality. It does not use the jQuery code itself but mimics such a call to the server.
(1) The normal jQuery / Ajax code that sets up the call.
var params = "target_oid=" + 7 + "&target_enable=" + 1;
$.ajax({
'url': drupalSettings.path.basePath + 'ajax/optimizely',
'type': 'POST',
'dataType': 'json',
'data': params,
'success': function(data) {
// Check data.status, update the DOM, ...
},
// Other properties ...
});
(2) The server-side code that responds.
$target_oid = $_POST['target_oid'];
$target_enable = $_POST['target_enable'];
// Process the request ...
$options = array('status' => 'Updated', 'message' => 'Good job');
return new JsonResponse($options);
(3) The code in a WebTestBase test method to mimic the call and check the results.
class OptimizelyDefaultProjectTest extends WebTestBase {
public function testListingAjax() {
$params = array('target_oid' => 7, 'target_enable' => 1);
$response = $this->drupalPost('ajax/optimizely',
'application/json',
$params);
$resp_obj = json_decode($response);
$this->assertEqual($resp_obj->status, 'Updated', ...);
$this->assertEqual($resp_obj->message, 'Good job', ...);
}
}
N.B. WebTestBase::drupalPost() in Drupal 8 is not the same as DrupalWebTestCase::drupalPost() in Drupal 7.
An approach with better coverage would be to load the form and simulate the user action that triggers the Ajax call, which, in this case, is clicking on a checkbox. That would test not only the server code but also the jQuery code on the client.
But I will save that problem for another day. 8-D
Update: see http://optimizely-to-drupal-8.blogspot.com/2015/01/beta-4-drupalmatchpath-and.html
Sources:
protected function WebTestBase::drupalPost
https://api.drupal.org/api/drupal/core!modules!simpletest!src!WebTestBase.php/function/WebTestBase%3A%3AdrupalPost/8
json_decode
http://php.net/manual/en/function.json-decode.php
Jul 30, 2014
Jul 28, 2014
Use assertPattern() for testing
While converting automated tests to class WebTestBase in Drupal 8, several of the assertions were failing. This was happening in spite of the fact that none of the theme templates, element ids, or other relevant code had changed.
These assertions had been implemented with assertRaw(), which does literal string matching against the raw HTML of the response pages. It turns out that in D8, some form elements may be generated differently even though the final rendering is pretty much the same.
As a slightly simplified example, here's a tag that is output by the Drupal 7 version of the module.
<input disabled="disabled" type="text" id="edit-optimizely-project-code" name="optimizely_project_code" value="Undefined" size="30" />
But in D8, the corresponding tag is
<input aria-describedby="edit-optimizely-project-code--description" disabled="disabled" type="text" id="edit-optimizely-project-code" name="optimizely_project_code" value="Undefined" size="30" aria-required="true" />
Obviously, these two strings don't match.
The intent of the assertion is to check that the particular input tag exists, that it is disabled, and that it has a value of "Undefined". The other attributes are irrelevant.
So I re-wrote the assertion using assertPattern() instead, which takes a regular expression as its first parameter.
$this->assertPattern(
':<input .*? disabled="disabled" .*? id="edit-optimizely-project-code" .*? value="Undefined" .*? />:', ...);
This is not perfect. For one thing, it still relies on the three attributes being in a certain order. It also has some extra space characters that could break the match, but I'm leaving them here to make the regex more readable.
However, I believe implementing with assertPattern() makes the test more robust in the face of future changes as well as making the code better self-documenting.
Sources:
Assertions
https://www.drupal.org/node/265828
abstract class WebTestBase
https://api.drupal.org/api/drupal/core!modules!simpletest!src!WebTestBase.php/class/WebTestBase/8
These assertions had been implemented with assertRaw(), which does literal string matching against the raw HTML of the response pages. It turns out that in D8, some form elements may be generated differently even though the final rendering is pretty much the same.
As a slightly simplified example, here's a tag that is output by the Drupal 7 version of the module.
<input disabled="disabled" type="text" id="edit-optimizely-project-code" name="optimizely_project_code" value="Undefined" size="30" />
But in D8, the corresponding tag is
<input aria-describedby="edit-optimizely-project-code--description" disabled="disabled" type="text" id="edit-optimizely-project-code" name="optimizely_project_code" value="Undefined" size="30" aria-required="true" />
Obviously, these two strings don't match.
The intent of the assertion is to check that the particular input tag exists, that it is disabled, and that it has a value of "Undefined". The other attributes are irrelevant.
So I re-wrote the assertion using assertPattern() instead, which takes a regular expression as its first parameter.
$this->assertPattern(
':<input .*? disabled="disabled" .*? id="edit-optimizely-project-code" .*? value="Undefined" .*? />:', ...);
This is not perfect. For one thing, it still relies on the three attributes being in a certain order. It also has some extra space characters that could break the match, but I'm leaving them here to make the regex more readable.
However, I believe implementing with assertPattern() makes the test more robust in the face of future changes as well as making the code better self-documenting.
Sources:
Assertions
https://www.drupal.org/node/265828
abstract class WebTestBase
https://api.drupal.org/api/drupal/core!modules!simpletest!src!WebTestBase.php/class/WebTestBase/8
Jul 26, 2014
Implementing an Ajax call on form checkboxes
On the Project Listing page of the Optimizely module, each project has a checkbox that the user can use to enable or disable the project. The handling of that user action is implemented by use of an Ajax call back to the server to validate the action and to update the database if the action is accepted. The server then returns the appropriate response.
The D7 code from hook_menu() is,
$items['ajax/optimizely'] = array(
'title' => 'Optimizely Administer AJAX',
'page callback' => 'optimizely_ajax_enable',
'access arguments' => array('administer optimizely'),
'file' => 'optimizely.admin.inc',
'file path' => drupal_get_path('module', 'optimizely'),
'type' => MENU_CALLBACK,
);
To do the conversion to Drupal 8, first, there was the need to define a route in the optimizely.routing.yml file to replace the above entry in hook_menu(). This route is similar to the ones that were defined for menu pages but there are critical differences.
The most important difference is that instead of the _form or the _content property, the _controller property is used. Whatever is returned by the indicated method is then returned as is without further processing. In this case it is a Json response to the Ajax call.
The other difference is that the _format property specifies that the request must be in Json.
ajax.enable:
path: /ajax/optimizely
defaults:
_controller: \Drupal\optimizely\AjaxEnable::enableDisable
_title: Optimizely Administer AJAX
requirements:
_permission: administer optimizely
_format: json
Second, implementing the method to be called. The conversion of the code to Drupal 8 required replacing calls to drupal_json_output() with instantiating instances of class JsonResponse. The arguments remain the same.
namespace Drupal\optimizely;
use Symfony\Component\HttpFoundation\JsonResponse;
class AjaxEnable {
public static function enableDisable() {
// Code to validate the user's action,
// update the database, etc.
if (. . . ) {
$options = array('status' => 'updated',
'oid' => $target_oid,
'target_enable' => $target_enable,
'message' => $message);
return new JsonResponse($options);
}
else {
$options = array('status' => 'rejected',
'oid' => $target_oid,
'issue_path' => $target_path,
'message' => $message);
return new JsonResponse($options);
}
Finally, there was an edit that had to be made to the jQuery code on the client side. Drupal.settings is now drupalSettings, and the following line,
'url': Drupal.settings.basePath + 'ajax/optimizely',
was replaced with,
'url': drupalSettings.path.basePath + 'ajax/optimizely',
Update: see http://optimizely-to-drupal-8.blogspot.com/2015/01/beta-4-drupalmatchpath-and.html
Sources:
Routing system in Drupal 8
https://www.drupal.org/node/2122071
Structure of routes
https://www.drupal.org/node/2092643
drupal_json_output is removed in favor of Symfony\Component\HttpFoundation\JsonResponse
https://www.drupal.org/node/1665684
Ajax framework
https://api.drupal.org/api/drupal/core%21core.api.php/group/ajax/8.0.x
This is a good explanation of an Ajax framework that is provided in D8.
replace all occurence of Drupal.settings with drupalSettings
https://www.drupal.org/node/1793648
The D7 code from hook_menu() is,
$items['ajax/optimizely'] = array(
'title' => 'Optimizely Administer AJAX',
'page callback' => 'optimizely_ajax_enable',
'access arguments' => array('administer optimizely'),
'file' => 'optimizely.admin.inc',
'file path' => drupal_get_path('module', 'optimizely'),
'type' => MENU_CALLBACK,
);
To do the conversion to Drupal 8, first, there was the need to define a route in the optimizely.routing.yml file to replace the above entry in hook_menu(). This route is similar to the ones that were defined for menu pages but there are critical differences.
The most important difference is that instead of the _form or the _content property, the _controller property is used. Whatever is returned by the indicated method is then returned as is without further processing. In this case it is a Json response to the Ajax call.
The other difference is that the _format property specifies that the request must be in Json.
ajax.enable:
path: /ajax/optimizely
defaults:
_controller: \Drupal\optimizely\AjaxEnable::enableDisable
_title: Optimizely Administer AJAX
requirements:
_permission: administer optimizely
_format: json
Second, implementing the method to be called. The conversion of the code to Drupal 8 required replacing calls to drupal_json_output() with instantiating instances of class JsonResponse. The arguments remain the same.
namespace Drupal\optimizely;
use Symfony\Component\HttpFoundation\JsonResponse;
class AjaxEnable {
public static function enableDisable() {
// Code to validate the user's action,
// update the database, etc.
if (. . . ) {
$options = array('status' => 'updated',
'oid' => $target_oid,
'target_enable' => $target_enable,
'message' => $message);
return new JsonResponse($options);
}
else {
$options = array('status' => 'rejected',
'oid' => $target_oid,
'issue_path' => $target_path,
'message' => $message);
return new JsonResponse($options);
}
Finally, there was an edit that had to be made to the jQuery code on the client side. Drupal.settings is now drupalSettings, and the following line,
'url': Drupal.settings.basePath + 'ajax/optimizely',
was replaced with,
'url': drupalSettings.path.basePath + 'ajax/optimizely',
Update: see http://optimizely-to-drupal-8.blogspot.com/2015/01/beta-4-drupalmatchpath-and.html
Sources:
Routing system in Drupal 8
https://www.drupal.org/node/2122071
Structure of routes
https://www.drupal.org/node/2092643
drupal_json_output is removed in favor of Symfony\Component\HttpFoundation\JsonResponse
https://www.drupal.org/node/1665684
Ajax framework
https://api.drupal.org/api/drupal/core%21core.api.php/group/ajax/8.0.x
This is a good explanation of an Ajax framework that is provided in D8.
replace all occurence of Drupal.settings with drupalSettings
https://www.drupal.org/node/1793648
Jul 21, 2014
Ajax request fails with 404 Not Found
When I started to convert a jQuery Ajax call that is used in the Drupal 7 Optimizely module, I found that it did not work in my local server environment, which consists of Apache 2.2 under Windows 7. The call was failing with a 404 Not Found error.
Using the Firebug extension for Firefox, I was able to see that the url for the Ajax request was missing the site root portion of the path. Specifically, the correct url is http://localhost/opti/ajax/optimizely where opti is the site root, but what was requested was http://localhost/ajax/optimizely . On this local system I have a number of different sites in subdirectories under the web server root.
I poked around in Apache to no avail, adding a RewriteBase directive in the site's .htaccess file.
I edited Drupal's settings.php to assign a value to $base_url, also with no effect.
Eventually, I stumbled on a casual side comment in the article cited below. It sounded like the same problem I was having, and it suggested the prepending of Drupal.settings.basePath in the url.
That solved the problem, as in the second line of code below.
$.ajax({
'url': Drupal.settings.basePath + 'ajax/optimizely',
'type': 'POST',
'dataType': 'json',
'data': post,
// Other code ...
where the original line was,
'url': '/ajax/optimizely',
I confirmed this issue and this particular fix on a fresh installation of Drupal 7.29 and the Optimizely module 2.14.
However, the original javascript code has been working elsewhere. I was concerned that this change would break the functionality in other server environments. So I tested on a Linux Mint 16 system with Apache 2.4. The ajax call also works there, which gives me some confidence that the fix is broadly usable without introducing a regression.
N.B. Drupal.settings.basePath provides a path that has a trailing slash character. There was one place in the code where it was necessary not to have a beginning slash in the string literal 'ajax/optimizely' even though the resulting doubled slash worked fine in other cases.
N.B. For this ajax call to work with Apache, the mod_rewrite module must be enabled. I had to manually do this since the module was not enabled by default when I installed Apache 2.4.
Source:
Ajax in Drupal using jQuery
https://www.drupal.org/node/305747
Using the Firebug extension for Firefox, I was able to see that the url for the Ajax request was missing the site root portion of the path. Specifically, the correct url is http://localhost/opti/ajax/optimizely where opti is the site root, but what was requested was http://localhost/ajax/optimizely . On this local system I have a number of different sites in subdirectories under the web server root.
I poked around in Apache to no avail, adding a RewriteBase directive in the site's .htaccess file.
I edited Drupal's settings.php to assign a value to $base_url, also with no effect.
Eventually, I stumbled on a casual side comment in the article cited below. It sounded like the same problem I was having, and it suggested the prepending of Drupal.settings.basePath in the url.
That solved the problem, as in the second line of code below.
$.ajax({
'url': Drupal.settings.basePath + 'ajax/optimizely',
'type': 'POST',
'dataType': 'json',
'data': post,
// Other code ...
where the original line was,
'url': '/ajax/optimizely',
I confirmed this issue and this particular fix on a fresh installation of Drupal 7.29 and the Optimizely module 2.14.
However, the original javascript code has been working elsewhere. I was concerned that this change would break the functionality in other server environments. So I tested on a Linux Mint 16 system with Apache 2.4. The ajax call also works there, which gives me some confidence that the fix is broadly usable without introducing a regression.
N.B. Drupal.settings.basePath provides a path that has a trailing slash character. There was one place in the code where it was necessary not to have a beginning slash in the string literal 'ajax/optimizely' even though the resulting doubled slash worked fine in other cases.
N.B. For this ajax call to work with Apache, the mod_rewrite module must be enabled. I had to manually do this since the module was not enabled by default when I installed Apache 2.4.
Source:
Ajax in Drupal using jQuery
https://www.drupal.org/node/305747
Jul 14, 2014
Building a delete confirmation form with a wildcard in its path
When the user clicks on a link to delete an Optimizely project, a confirmation form is returned with buttons to Delete or to Cancel.
The url for this delete confirmation form has a parameter as the last part of the path, which is the unique integer id for the project. So it's structurally very similar to the update form that was documented in the previous post http://optimizely-to-drupal-8.blogspot.com/2014/06/take-two-building-form-using-route-path.html
Unlike the update form, I was able to implement the delete confirmation in a simpler way. It was not necessary to define an intermediate method to accept the project id as a parameter and then pass the id along as part of a further call.
(1) Defined a route in the optimizely.routing.yml file:
optimizely.delete.oid:
path: /admin/config/system/optimizely/delete/{oid}
defaults:
_form: \Drupal\optimizely\DeleteForm
_title: Optimizely Delete Project
requirements:
_permission: administer optimizely
Note the use of the {oid} wildcard in the path, which represents the project id.
For this route I was able to use the _form property rather than the _content property. For reasons not apparent to me, because class DeleteForm is derived from ConfirmFormBase rather than FormBase, passing the value of {oid} is done as I would hope. See buildForm() below and its optional third parameter.
(2) Defined class DeleteForm (with comments removed here; other abbreviations also made).
namespace Drupal\optimizely;
use Drupal\Core\Form\ConfirmFormBase;
class DeleteForm extends ConfirmFormBase {
private $oid = NULL;
public function getFormId() {
return 'optimizely-delete-page-confirm';
}
public function getQuestion() {
return $this->t('Delete'); // Actually, a heading.
}
public function getDescription() {
return $this->t('Are you sure ...');
}
public function getConfirmText() {
return $this->t('Delete');
}
public function getCancelRoute() {
return array('route_name' => 'optimizely.listing');
}
public function buildForm(array $form, array &$form_state,
$oid = NULL) {
$this->oid = $oid;
return parent::buildForm($form, $form_state);
}
public function submitForm(array &$form, array &$form_state) {
// Use $this->oid to update the database, etc. ...
// Return to project listing page
$form_state['redirect_route']['route_name'] =
'optimizely.listing';
}
}
Some of these methods have default implementations. For example, getConfirmText() defaults to returning 'Confirm' for the confirmation button.
There is also a default implementation of buildForm() but in this scenario I implemented it in order to receive the value of the project id as a param and to save the id in an instance variable. Then the method calls buildForm() of the parent class to do the actual form building. Note that that third parameter must have a default value declared, otherwise you get a syntax error because the signature of the method will not match that in FormInterface.
At the end of submitForm() the reference parameter $form_state is altered in order to redirect to a particular path after processing the form. For a little more discussion about redirection, see this other post on drupal_goto() http://optimizely-to-drupal-8.blogspot.com/2014/06/function-drupalgoto-has-been-removed.html
Updates for Drupal 8, alpha 13 and 14: The name of method getCancelRoute() has been changed to getCancelUrl(), and it has a different type of return value. The signatures for methods buildForm() and submitForm() have changed to use FormStateInterface. Redirection is implemented differently.
See http://optimizely-to-drupal-8.blogspot.com/2014/08/drupal-8-alpha11-alpha13.html and http://optimizely-to-drupal-8.blogspot.com/2014/08/drupal-8-alpha-13-alpha-14.html
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
public function getCancelUrl() {
return new Url('optimizely.listing');
}
public function buildForm(array $form,
FormStateInterface $form_state,
$oid = NULL) { ... }
public function submitForm(array &$form,
FormStateInterface $form_state) {
// ...
// Return to project listing page
$form_state->setRedirect('optimizely.setting');
}
Sources:
Drupal 8: Forms, OOP style, August 12, 2013
http://effulgentsia.drupalgardens.com/content/drupal-8-forms-oop-style
Form API in Drupal 8
https://www.drupal.org/node/2117411
abstract class ConfirmFormBase
https://api.drupal.org/api/drupal/core!lib!Drupal!Core!Form!ConfirmFormBase.php/class/ConfirmFormBase/8
The url for this delete confirmation form has a parameter as the last part of the path, which is the unique integer id for the project. So it's structurally very similar to the update form that was documented in the previous post http://optimizely-to-drupal-8.blogspot.com/2014/06/take-two-building-form-using-route-path.html
Unlike the update form, I was able to implement the delete confirmation in a simpler way. It was not necessary to define an intermediate method to accept the project id as a parameter and then pass the id along as part of a further call.
(1) Defined a route in the optimizely.routing.yml file:
optimizely.delete.oid:
path: /admin/config/system/optimizely/delete/{oid}
defaults:
_form: \Drupal\optimizely\DeleteForm
_title: Optimizely Delete Project
requirements:
_permission: administer optimizely
Note the use of the {oid} wildcard in the path, which represents the project id.
For this route I was able to use the _form property rather than the _content property. For reasons not apparent to me, because class DeleteForm is derived from ConfirmFormBase rather than FormBase, passing the value of {oid} is done as I would hope. See buildForm() below and its optional third parameter.
(2) Defined class DeleteForm (with comments removed here; other abbreviations also made).
namespace Drupal\optimizely;
use Drupal\Core\Form\ConfirmFormBase;
class DeleteForm extends ConfirmFormBase {
private $oid = NULL;
public function getFormId() {
return 'optimizely-delete-page-confirm';
}
public function getQuestion() {
return $this->t('Delete'); // Actually, a heading.
}
public function getDescription() {
return $this->t('Are you sure ...');
}
public function getConfirmText() {
return $this->t('Delete');
}
public function getCancelRoute() {
return array('route_name' => 'optimizely.listing');
}
public function buildForm(array $form, array &$form_state,
$oid = NULL) {
$this->oid = $oid;
return parent::buildForm($form, $form_state);
}
public function submitForm(array &$form, array &$form_state) {
// Use $this->oid to update the database, etc. ...
// Return to project listing page
$form_state['redirect_route']['route_name'] =
'optimizely.listing';
}
}
Some of these methods have default implementations. For example, getConfirmText() defaults to returning 'Confirm' for the confirmation button.
There is also a default implementation of buildForm() but in this scenario I implemented it in order to receive the value of the project id as a param and to save the id in an instance variable. Then the method calls buildForm() of the parent class to do the actual form building. Note that that third parameter must have a default value declared, otherwise you get a syntax error because the signature of the method will not match that in FormInterface.
At the end of submitForm() the reference parameter $form_state is altered in order to redirect to a particular path after processing the form. For a little more discussion about redirection, see this other post on drupal_goto() http://optimizely-to-drupal-8.blogspot.com/2014/06/function-drupalgoto-has-been-removed.html
Updates for Drupal 8, alpha 13 and 14: The name of method getCancelRoute() has been changed to getCancelUrl(), and it has a different type of return value. The signatures for methods buildForm() and submitForm() have changed to use FormStateInterface. Redirection is implemented differently.
See http://optimizely-to-drupal-8.blogspot.com/2014/08/drupal-8-alpha11-alpha13.html and http://optimizely-to-drupal-8.blogspot.com/2014/08/drupal-8-alpha-13-alpha-14.html
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
public function getCancelUrl() {
return new Url('optimizely.listing');
}
public function buildForm(array $form,
FormStateInterface $form_state,
$oid = NULL) { ... }
public function submitForm(array &$form,
FormStateInterface $form_state) {
// ...
// Return to project listing page
$form_state->setRedirect('optimizely.setting');
}
Sources:
Drupal 8: Forms, OOP style, August 12, 2013
http://effulgentsia.drupalgardens.com/content/drupal-8-forms-oop-style
Form API in Drupal 8
https://www.drupal.org/node/2117411
abstract class ConfirmFormBase
https://api.drupal.org/api/drupal/core!lib!Drupal!Core!Form!ConfirmFormBase.php/class/ConfirmFormBase/8
Subscribe to:
Posts (Atom)