This post is the second in a series describing Caxy’s work architecting the DiscoverDesign site in Drupal 8 for the Chicago Architecture Foundation. This article discusses how we used AngularJS with Drupal 8's REST API to allow student projects to be edited without using Drupal's admin UI or forms. Additional posts can be found here:
A major goal for this project was to build an interface that allowed students to navigate and edit their projects with as few clicks as possible. In the previous Drupal 6 version of the site, each step was edited in a separate modal dialog form. Only one step was visible at a time, and the help text and content was hidden by default.
In our redesign, we wanted all the steps visible at the same time to facilitate skipping ahead and returning to previous steps without any barriers. Media uploaded to each project step are displayed as cards and the full version is displayed or edited inside a modal dialog.
We use AngularJS on DiscoverDesign to allow inline editing on student projects. The starting point for this was Drupal’s node view page, which displays the student project page. The same Twig template is used for both displaying and editing the project pages.
Tattoo module and custom directives
AngularJS is great for single page applications but if deliberate attention is not given to front-end performance, user experience will suffer. The time between page load and when the page becomes interactive can be long if the bootstrapped AngularJS application needs to make further HTTP requests for the data it needs before it is usable. In our case, this would have meant at least six additional HTTP requests or nearly 50 for a completed project with many uploaded files.
So we avoid the HTTP requests by embedding the HAL-JSON representations of Drupal nodes in the student project page <head>
element.
<script type="application/hal+json" data-drupal-selector="tattoo-entity" data-tattoo-entity="node:911">{...}</script>
These <script>
tags are added by our custom Tattoo module.
A custom AngularJS directive named ddDrupalEntity
(whose source code is included in this post)—an attribute named dd-drupal-entity
—is added to each rendered entity’s HTML wrapper and contains the absolute canonical URL for the entity. This URL is the the same as the self
URL for the HAL JSON representation of the entity provided by Drupal’s REST modules. We also added an editable
property to the node's Twig template (also included in this post) variables which controls whether AngularJS directives are exposed at all.
This code demonstrates how these new properties are added to the template to decorate a node's HTML representation.
/**
* Implement hook_preprocess_node.
*
* Indicates to Twig that a node is editable.
*
* @param $variables
* @throws EntityMalformedException
*/
function cafprojectedit_preprocess_node(&$variables) {
/** @var \Drupal\node\NodeInterface $entity */
$entity = $variables['node'];
$is_project_type = in_array($entity->bundle(), ['project', 'project_step', 'project_step_no_media']);
$variables['editable'] = $entity->access('update') && $is_project_type;
if ($entity->access('update') && $is_project_type) {
/** @var Url $url */
$url = $entity->toUrl('canonical', ['absolute' => true]);
$variables['attributes']['dd-drupal-entity'] = $url->toString();
}
}
Having all the entities in hand when the page bootstraps meant we only needed to support HTTP PATCH requests to allow editing of entities. With Tattoo module, we could avoid GET requests entirely. Our application's design does not require users to create (POST) entities or DELETE them.
In Drupal 8's HAL-JSON, entity relationships are all represented in one of two places in an entity's representation, _links
(which contains URLs for related entities) or _embedded
(which would contain the full entity). For our project, we cared only about the _links
object because _embedded
is by default missing entity references whereas _links
is reliably complete.
When the HTTP PATCH request is made, the relation for type
(entity type and bundle) always needs to be included. Since our application depends on lots of relationships to dependent entities (project steps) and those have their own dependent entities (media entities with three bundles) and these URLs are pretty treacherous (such as https://www.discoverdesign.org/rest/relation/node/project/field_project_step
), we created a service that simplifies the creation of those URLs and also parsing them to extract entity type and bundle information.
/* globals angular */
(function () {
'use strict';
angular
.module('discoverDesign.project')
.factory('EntityTypeService', DrupalEntityTypeService);
DrupalEntityTypeService.$inject = ['drupalSettings'];
function DrupalEntityTypeService(drupalSettings) {
var service = {};
var entityKeys = function (entity) {
var keys = entity._links.type.href.substr(drupalSettings.restLinkDomain.length + '/rest/type/'.length).split('/');
return {
entityTypeId: keys[0],
bundle: keys[1]
};
};
service.entityKeys = entityKeys;
var relationKey = function (entity, field) {
var keys = entityKeys(entity);
return drupalSettings.restLinkDomain + '/rest/relation/' + keys.entityTypeId + '/' + keys.bundle + '/' + field;
};
service.relationKey = relationKey;
return service;
}
})();
We avoided AngularJS controllers and used only directives. This required us to explicitly define the binding between our directives, scope and the DOM. While controllers would allow us to define scope properties that were accessible from child scopes, we needed to be more strict because we have Drupal entity relationships that are represented in the DOM as nested elements, and we always use the same property names on our scope service.
What we think is particularly clever about this design is that AngularJS never has to know much about the entity system of Drupal. HAL-JSON entities have a self
property that is the URL where HTTP PATCH requests should be made to edit that entity.
The directive requires a HTML attribute named editable-properties
that contains a list of Drupal entity properties and fields that should be saved with the entity. This will always contain type
(because it is required for Drupal 8 REST transactions even though it is not actually "editable") and whichever properties, fields, and relations are to be edited by the user. In our case, this would be variously title
, body
, and field_media
.
<article{{ attributes }} editable-properties="title,type">
<header class="step__header">
{% if editable %}
{% verbatim %}
<form class="dashed-form form--project-title">
<label for="project-title-text-input-{{ delta }}" class="form-textfield required" ng-repeat="(delta, title) in entity.title">
<i class="dot can-edit" aria-hidden="true" role="presentation"></i>
<span id="project-title-{{ delta }}" class="visually-hidden">Project Title <abbr title="required">*</abbr></span>
<input id="project-title-text-input-{{ delta }}" class="step__title title-2" name="project-title-text-input-{{ delta }}" aria-labelledby="project-title-{{ delta }}" type="text" title="Project Title" ng-model="title.value" ng-required="true" ng-disabled="disabled" />
</label>
<div class="has-buttons">
<button class="button--primary" ng-click="save()" ng-disabled="disabled">Save</button>
</div>
</form>
{% endverbatim %}
{% else %}
<h2 class="step__title title-1">{{ label }}</h2>
{% endif %}
</header>
</article>
Our design means that Drupal entities have a simple relationship to a DOM element. The directives can be nested and encapsulated so that they can save their own data without touching interior or exterior entities. There is no high-level AngularJS controller that manages saving entities. Because this is a single page application within a regular Drupal 8 site, we did not need to use any special authentication module because the user has a Drupal session cookie.
The directive below named ddDrupalEntity
extracts the entity data from the Tattoo service and handles saving it through the REST API. For properties that are actually entity relations (entity reference fields) the request data is configured correctly using the EntityTypeService
to construct URLs for the _embedded
property of the HAL-JSON representation.
/* global angular */
(function () {
'use strict';
angular
.module('discoverDesign.project')
.directive('ddDrupalEntity', DrupalEntityDirective);
DrupalEntityDirective.$inject = ['tattoo', '$http', '_', 'drupalSettings', 'EntityTypeService'];
function DrupalEntityDirective(tattoo, $http, _, drupalSettings, EntityTypeService) {
return {
restrict: 'A',
scope: true,
link: function link(scope, element, attrs) {
var editableProperties = attrs.hasOwnProperty('editableProperties') ? attrs.editableProperties.split(',') : [];
var entityUrl = attrs.ddDrupalEntity;
// If debug mode is disabled, put scope into the element data.
if (!element.data('$scope')) {
element.data('$scope', scope);
}
scope.alerts = [];
scope.entity = _.find(tattoo, function (entity) {
var exp = new RegExp('^' + escapeRegExp(entityUrl) + '(\\?_format\\=hal_json)?$');
return exp.test(entity._links.self.href);
});
scope.disabled = false;
scope.hasRelation = function (field) {
var key = EntityTypeService.relationKey(scope.entity, field);
return angular.isDefined(scope.entity._embedded[key]);
};
scope.getRelation = function (field) {
var key = EntityTypeService.relationKey(scope.entity, field);
return scope.hasRelation(field) ? scope.entity._embedded[key] : [];
};
// This handles the cases when a field doesn't exist but is editable.
// Tattoo module should support this case by encoding the default/empty values for entity fields.
editableProperties.forEach(function (property) {
if (!scope.entity.hasOwnProperty(property)) {
if (property === 'body') {
scope.entity[property] = [{value: '', format: 'markdown'}];
}
else if (property === 'field_media') {
scope.entity[property] = scope.hasRelation(property) ? scope.getRelation(property) : [];
}
else {
scope.entity[property] = [{value: ''}];
}
}
});
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
scope.save = function () {
scope.disabled = true;
var entity = _.pick(scope.entity, editableProperties);
entity._links = {
type: scope.entity._links.type
};
$http({
method: 'PATCH',
url: scope.entity._links.shortlink.href,
headers: {
'X-CSRF-Token': drupalSettings.csrf,
'Content-type': 'application/hal+json'
},
data: entity
}).then(function (response) {
scope.alerts = []; // clear out prior alerts.
scope.alerts.push({title: 'Success', body: 'Your work has been saved.', type: 'status'});
scope.disabled = false;
}, function (response) {
var alert = {title: 'Error', body: 'Something went wrong. Your work may not have been saved.', type: 'error'};
if (response.hasOwnProperty('data') && response.data.hasOwnProperty('error')) {
alert.body = response.data.error;
}
scope.alerts.push(alert);
});
};
}
};
}
})();
Drupal 8 REST requests require the CSRF token, which we attach to the Drupal JS settings object on requests where it could be used.
<?php
/**
* Implement hook_js_settings_build().
*
* Add CSRF token and this module's JS libraries to page.
*/
function cafprojectedit_js_settings_build(array &$settings, \Drupal\Core\Asset\AttachedAssetsInterface $assets) {
$routeMatch = \Drupal::routeMatch();
$request = \Drupal::request();
if ($routeMatch->getRouteName() === 'entity.node.canonical' && $request->attributes->get('node')->bundle() === 'project') {
if (AccessResultAllowed::allowedIfHasPermissions(\Drupal::currentUser(), [
'edit own project content',
'edit any project content'
], 'OR')) {
$settings[‘csrf'] = \Drupal::csrfToken()->get('rest');
}
}
}
AngularJS and jQuery
All the best practices and recommendations assert that you should avoid using jQuery and AngularJS to separately manipulate the DOM. The basic reason is that changes to the HTML DOM done outside AngularJS will not be automatically recognized in AngularJS's scope services. However, AngularJS can use jQuery in directives, which are intended to allow DOM manipulation.
We did not think this would be necessary until we started developing the feature where students can add or upload media to their projects where we ended up relying on Drupal's AJAX APIs for forms and jQuery UI dialogs. We found a few ways to make jQuery and AngularJS cooperate effectively.
Capturing the AngularJS injector
We bootstrap AngularJS manually so that we can capture the AngularJS injector — its service container — into the global Drupal object. Bootstrapping AngularJS manually means you do not use the ngApp
directive at all. Instead, you bootstrap AngularJS with the name of the module that represents the top of your application's dependency graph.
In our Drupal module, we set up the parameters of the call to angular.bootstrap()
.
<?php
/**
* Implement hook_js_settings_build().
*
* Add CSRF token and this module's JS libraries to page.
*/
function cafprojectedit_js_settings_build(array &$settings, \Drupal\Core\Asset\AttachedAssetsInterface $assets) {
$routeMatch = \Drupal::routeMatch();
$request = \Drupal::request();
if ($routeMatch->getRouteName() === 'entity.node.canonical' && $request->attributes->get('node')->bundle() === 'project') {
$settings[‘angular'] = [
'modules' => ['discoverDesign.project'],
'config' => ['strictDi' => true],
];
}
}
In vanilla JavaScript, we call angular.bootstrap()
and capture the return value in Drupal.injector
.
/* globals angular, Drupal, drupalSettings, domready */
(function (domready, Drupal, drupalSettings) {
'use strict';
angular.module('discoverDesign.project', [
'ngAria',
'hc.commonmark'
]).config(CompileProviderConfig);
CompileProviderConfig.$inject = ['$compileProvider'];
function CompileProviderConfig($compileProvider) {
$compileProvider.debugInfoEnabled(false);
}
// Adds the Angular container to the Drupal object so Angular services can
// be used by non-Angular code.
domready(function () {
Drupal.injector = angular.bootstrap(document, drupalSettings.angular.modules, drupalSettings.angular.config);
});
})(domready, Drupal, drupalSettings);
Having the injector means we can pull AngularJS services from the container and use them in any JavaScript code. While we could always write plain JS services to use in AngularJS, dependency injection supported by AngularJS may coerce you to create JS services there.
In any Drupal behavior, for example, you could retrieve an AngularJS service this way:
var $http = Drupal.injector.get('$http');
Forcing AngularJS to use jQuery
Since we are using jQuery and AngularJS together, we wanted to make sure AngularJS used jQuery instead of its internal jqlite library.
<?php
/**
* Implement hook_preprocess_html.
*
* Tell AngularJS to expect jQuery instead of using jqlite.
*
* @param $variables
*/
function cafprojectedit_preprocess_html(&$variables) {
$variables['html_attributes']['data-ng-jq'] = 'jQuery';
}
Grabbing AngularJS $scope from jQuery
When AngularJS is run in debug mode (its default), the DOM element data contains a reference to the $scope service for that element's directive. But when debug mode is disabled, which is recommended for production, that property does not exist. In the AngularJS directive ddDrupalEntity
you can see this code which fills in the $scope
when debug mode is disabled.
// If debug mode is disabled, put scope into the element data.
if (!element.data('$scope')) {
element.data('$scope', scope);
}
When jQuery makes a change to the DOM which needs to be reflected in the AngularJS model for a directive, all that needs to be done is this:
var scope = angular.element('[dd-drupal-entity="https://localhost/node/123"]')
.eq(0)
.scope();
scope.$apply(function () {
// This function will be run prior to triggering a digest cycle, so
// this is where you can make your changes to the AngularJS models
// need to be reflected in views.
});
// The same methods from the directive scope are available here!
scope.save();
We used this method to register changes that happen inside Drupal AJAX Dialog forms and to save them using the AngularJS code we already wrote for Drupal's REST API.
End Results
Using AngularJS and Drupal 8's REST API, users are given a smooth, interactive experience where all project steps can be seen and edited within a single page. This avoids taking them out of the experience, even when uploading content, and allows for easy back and forth between steps. The card display provides a way to quickly review project progress, review uploaded media, and make changes as needed. By building on the Drupal 8 REST API, we can create compelling single-page editing experiences like this one.