Khanh Hoang - Kenn
Kenn is a user experience designer and front end developer who enjoys creating beautiful and usable web and mobile experiences.
Drupal 8 is coming and with it, a slough of drama and controversy. One of the big concerns is that a number of the major API changes (Symfony, OOP, PSR-0/PSR-4, Twig, YAML - the list goes on) is going to alienate a lot of current Drupal developers who are more comforable working in the procedural world of hooks and global PHP functions. While this may be true, I prefer to look at it as an opportunity for developers to learn something new and expand their current skill set. After all, as a developer, knowledge is your best tool, so you'd better keep it sharp.
For today's post, we're going to try and demystify some of the new Drupal API by upgrading a relatively simple (no pun intended) module that I maintain Simple Dialog. All the module does is provide a mechanism to launch a dialog with content loaded from a different page, without writing any javascript. I'm not gonna lie. Pretty much every part of the API that I used in the module has changed in some way. One thing even changed while I was writing it. But have no fear, we're gonna go through it part by part and examine what's happening. The completed module is also available on drupal.org in case you want to just download it and follow along.
Don't like reading long-winded tutorials? Try clicking the tl;dr button for the *compact* version of this post.
DISCLAIMER: Drupal 8 is currently in Alpha and some parts of the API are still subject to change. I'll try and update the post as things change, but if you notice anything while reading through, feel free to let me know in the comments at the bottom!
All you need to get going with this tutorial is a working, up-to-date Drupal 8 install, and some experience with module develpment in Drupal 7. However, some understanding of Object Oriented Programming, PHP Namespaces, and the PSR initiatives will help. I'll be explaining everything as we go along, but there are a lot of big concepts to cover so I won't be able to go as in depth as might be necessary.
While we could use the Drupal Module Upgrader to do a bunch of the work for us, I'm going to do this upgrade manually in order to better explore the new API. I'll start by breaking down simple_dialog based on which part of Drupal 7's API it leverages.
Then, I'm going to go through these items and figure out what's changed in the Drupal API and make the appropriate upgrades. drupal.org has added a new feature for tracking changes called "Change Records". I've found it's the best place to start your search. For example if you want to see if anything has changed with drupal_add_js(), you'd just search for it in the "Keywords" field (and you'll find that it's been removed - more on that later). Following that, Google is still probably your best choice. The drupal.org documentation for D8 is actually pretty good, but I always find Google does a better job of finding the right page. Lastly, you can always take a look at some of the core module's code as examples.
Drupal 8 now offers two places you can store your modules. You can do it the existing way in sites/all/modules or just directly in the modules folder of the drupal root. Since it's new, let's use the latter method. And, actually, I'm going to put the module in a "contrib" sub-folder (I prefer to keep contrib and custom modules separated). We'll be starting with the Drupal 7 version of the module so download that and put it in modules/contrib. A lot is going to change in simple_dialog.module so for the purposes of this tutorial let's just delete all the code and start fresh. (Note: The .module file isn't technically required anymore for the module to work. See this change record for more info)
Info files are now being written in YAML instead of the old .ini format. In fact, the YAML markup language is being adopted quite thoroughly in Drupal for settings and configuration so it's worth it to take some time to get to know it. The info file should be named simple_dialog.info.yml (not ".yaml" as some of us have learned the hard way) and it looks like this:
name: Simple Dialog type: module description: 'Provides an API to create simple modal dialogs. Leverages the jQuery ui dialog plugin included with Drupal.' core: 8.x package: User interface configure: simple_dialog.settings
More information on Drupal 8 module info files can be found here.
Next, we'll be defining our default simple dialog settings using the new Configuration API. Gone are the days of calling variable_set() and variable_get() to manage your module's settings. Now, config is handled using a special configuration class and your settings are stored in .yml files (making tracking your config in version control a breeze). Also, a new configuration management system has been introduced to facilitate syncing config between your various dev, staging and production environments (something that historically was handled by the features module).
Simple dialog's configuration requirements are pretty straight foward. We're really just defining a set of default values for the settings form. Create a "config" folder in the module root and within that a file named simple_dialog.settings.yml (that's right, more YAML!). The name of the file is important because everything before the .yml extension will be used as the name of the configuration object when you're getting and setting configuration values later on. Open the file and add the following:
js_all: true classes: '' defaults: settings: 'width:300;height:auto;position:[center,60]' target_selector: 'content' title: ''
Easy, right? The next thing we're going to do is write a schema for our configuration. Configuration schemas are another new feature introduced Drupal 8. Based on metadata language called Kwalify, it allows us to explicitly define metadata about our configuration variables such as data type and label. According to the docs, the primary use case for the schemas was for multilingual support, although I wouldn't be surprised if this expands as Drupal 8 matures. For example, supposing that a 'description' was added, you could pretty much auto generate your config form from the schema.
At first, I thought writing a configuration schema would be more complicated than it is, but after reading the docs and looking at some core examples it's actually quite straight forward. For simple configuration it will often follow the same pattern. Each variable will have a type and a label, and the nesting should match what we did in simple_dialog.settings.yml. The file we're going to create will be <simple_dialog root>/config/schema/simple_dialog.schema.yml and the schema looks like this:
# Schema for configuration files of the Simple Dialog module. simple_dialog.settings: type: mapping label: 'Simple Dialog settings' mapping: js_all: type: boolean label: 'Add simple dialog javscript files to all pages' classes: type: string label: 'Additional Classes' defaults: type: mapping label: 'Defaults' mapping: settings: type: string label: 'Default Dialog Settings' target_selector: type: string label: 'Default Target Selector' title: type: string label: 'Default Dialog Title'
If you want to read more about configuration schemas, the docs page (linked above) is quite informative. There are also links to a few (very long) issues that tell the story of how this came about.
For the most part, the Forms API hasn't changed that much. There's some new HTML5 elements, but otherwise the form array is still structured the same and the validation and submit steps are maintained. The implementation of the form itself has changed, however. Instead of defining the form using a global PHP function, we'll be extending one of the base form classes that Drupal provides (time to put on your OOP hat). In this case we'll be using the ConfigFormBase class.
Another big change is the adoption of the PSR standard for class autoloading. This is actually one of those things that's currently in flux as I write this. Right now, we're still using the PSR-0 standard for class autoloading, however that's going to change to PSR-4 very soon. They're not that different really. PSR-4 is just a little easier on the nested folder structures. Unfortunately we'll have to use PSR-0 for the purposes of this tutorial, but I'll come back and change that when the PSR-4 changes are committed. For now, create the following folder/file structure: <simple_dialog root>/lib/Drupal/simple_dialog/Form/SimpleDialogSettingsForm.php and add the following code:
namespace Drupal\simple_dialog\Form; use Drupal\Core\Form\ConfigFormBase; /** * Defines a form to configure maintenance settings for this site. */ class SimpleDialogSettingsForm extends ConfigFormBase { /** * {@inheritdoc} */ public function getFormID() { return 'simple_dialog_settings_form'; } /** * {@inheritdoc} */ public function buildForm(array $form, array &$form_state) { $config = $this->configFactory->get('simple_dialog.settings'); $form['javascript']['js_all'] = array( '#type' => 'checkbox', '#title' => t('Add simple dialog javscript files to all pages'), '#description' => t("This setting is for people who want to limit which pages the simple dialog javscript files are added to. If you disable this option, you will have to add the js files manually (using the function simple_dialog_add_js() ) to every page that you want to be able to invoke the simple dialog using the 'simple-dialog' class. If you are adding simple dialog links to the page using theme('simple_dialog'...) the necessary javascript is added within those functions so you should be okay.'"), '#default_value' => $config->get('js_all'), ); $form['classes'] = array( '#type' => 'textfield', '#title' => t('Additional Classes'), '#description' => t("Supply a list of classes, separated by spaces, that can be used to launch the dialog. Do not use any leading or trailing spaces."), '#default_value' => $config->get('classes'), ); $form['default_settings'] = array( '#type' => 'textfield', '#title' => t('Default Dialog Settings'), '#description' => t('Provide default settings for the simple dialog. The defaults should be formatted the same as you would in the "rel" attribute of a simple dialog link. See the <a href="/admin/help/simple_dialog">help page</a> under "HTML Implementation" for more information.'), '#default_value' => $config->get('defaults.settings'), ); $form['default_target_selector'] = array( '#type' => 'textfield', '#title' => t('Default Target Selector'), '#description' => t('Provide a default html element id for the target page (the page that will be pulled into the dialog). This value will be used if no "name" attribute is provided in a simple dialog link.'), '#default_value' => $config->get('defaults.target_selector'), ); $form['default_title'] = array( '#type' => 'textfield', '#title' => t('Default Dialog Title'), '#description' => t('Provide a default dialog title. This value will be used if no "title" attribute is provided in a simple dialog link.'), '#default_value' => $config->get('defaults.title'), ); return parent::buildForm($form, $form_state); } /** * {@inheritdoc} */ public function submitForm(array &$form, array &$form_state) { $this->configFactory->get('simple_dialog.settings') ->set('js_all', $form_state['values']['js_all']) ->set('classes', $form_state['values']['classes']) ->set('defaults.settings', $form_state['values']['default_settings']) ->set('defaults.target_selector', $form_state['values']['default_target_selector']) ->set('defaults.title', $form_state['values']['default_title']) ->save(); parent::submitForm($form, $form_state); } }
Let's examine what we're doing here more closely.
namespace Drupal\simple_dialog\Form; use Drupal\Core\Form\ConfigFormBase;
First, we're namespacing our file and the contained class. The namespace is constructed using the PSR-0 standard that I mentioned before which matches it's directory structure in our module's lib directory (<simple_dialog root>/lib/Drupal/simple_dialog/Form/SimpleDialogSettingsForm.php). Following that we're aliasing/importing the Drupal\Core\Form\ConfigFormBase class.
public function getFormID() { return 'simple_dialog_settings_form'; }
The getFormID method just returns an id for the form that you define. In Drupal 7 the form id was the form's function name.
The buildForm method is where you'll define your form using the Forms API. Like I mentioned before the Forms API hasn't changed that much so we won't talk about that. However, here's where the new configuration API changes are coming into play. You'll notice that we're getting our config from an object provided by a configFactory Object. It's only a line of code, but there's a lot going on here. Essentially we're retrieving a configuration object (named simple_dialog.settings - smae as our .yml file that we defined earlier) that has been dependency injected that can be used for reading (or writing) configuration settings. Dependency Injection is a programming design pattern that is used throughout Symfony and therefore, Drupal 8. In simple terms it's a mechanism to inject your class in the place of another class somewhere in the application. Fabien Potencier, the creator of Symfony, has a great 6 part series about Dependency Injection on his blog. If you're planning on developing for Drupal 8, it's a must-read. For the purposes of this tutorial, however, all you need to know is that you can use this configuration object to set the default values of your form array. Also, if this is the first time our form has been visited, the values returned by the get() method will be pulled from our simple_dialog.settings.yml file.
public function submitForm(array &$form, array &$form_state) { $this->configFactory->get('simple_dialog.settings') ->set('js_all', $form_state['values']['js_all']) ->set('classes', $form_state['values']['classes']) ->set('defaults.settings', $form_state['values']['default_settings']) ->set('defaults.target_selector', $form_state['values']['default_target_selector']) ->set('defaults.title', $form_state['values']['default_title']) ->save(); parent::submitForm($form, $form_state); }
Lastly we're overridding the submitForm() method to save our submitted configuration values. Once again we're using the configFactory to get our configuration object. This is essentially the Drupal 8 implementation of the old Forms API #submit handler. Notice that we are explicitly calling the parent class submitForm() method. There is also a validation method we can override: validateForm(), but we aren't. One could argue that we could use some simple validation on this form... but let's not for now.
Routing is not a new concept to Drupal, but you'll probably hear the term being used quite a bit more than in past versions. A route is a path in drupal that can accept a request (GET/POST) and returns a response (HTML, JSON, 404 etc). In D7 hook_menu() was our goto hook for handling routes. But hook_menu() was also serving the double duty of managing all our menu links, local tasks and local actions. In D8, all the menu stuff has been separated into yaml files and routing is now implemented with Symfony's Routing component. In fact, hook_menu() has been completely removed from core.
Our routing needs for Simple Dialog are relatively... well... simple. All we really need to do is define a path for our configuration form. To do this we'll need to first define the route. Create a file called simple_dialog.routing.yml and add the following code:
simple_dialog.settings: path: '/admin/config/content/simple-dialog' defaults: _form: '\Drupal\simple_dialog\Form\SimpleDialogSettingsForm' requirements: _permission: 'administer simple dialog'
What we can determine from this is that the route name is
As far as routing goes in Drupal 8, this is a relatively simple one. There is a lot more you can do with the routing component, but it is outside the scope of this tutorial. To learn more, The documentation on drupal.org is a great place to start.
The next thing we need to do is create the menu link for it so it will show up in the config section of the administration area. We'll create a simple_dialog.menu_links.yml file and add the following:
simple_dialog.settings: title: Simple Dialog description: 'Configure default settings for simple dialogs' parent: system.admin_config_ui route_name: simple_dialog.settings
The first item is the menu_link name (sort of a machine name). This is important when defining parents. When specifying a parent menu item you have to use that menu item's menu_link name. This can be a bit tricky to determine. See the drupal.org documentation on the subject for more info. There is also documentation for setting up module-defined local tasks, actions and contextual links.
One last thing to note, we'll need to define the permission we're using for the route using hook_permission(). This hasn't changed from Drupal 7 so it should be familiar:
/** * Implements hook_permission(). */ function simple_dialog_permission() { return array( 'administer simple dialog' => array( 'title' => t('Administer Simple Dialog'), ), ); }
First things first, we need to copy the js and css folders from the D7 version of Simple Dialog. This JS and CSS, as well as the jquery ui.dialog library needs to be added to every page. Previously, this was acheived through hook_init(), however, hook_init has been removed in D8. A couple things have changed that led to this. The biggest change was the adoption of the Symfony kernel and the concept of "events" that came with it. Now, the lifespan of an http request in drupal is broken into a series of events that can be "subscribed" to. You subscribe to events using the EventSubscriberInterface class and register methods to be run when the event happens. Think of it as an OOP hook system. So, all we need to do is figure out which event is the hook_init equivalent and run the appropriate drupal_add_*(), right?
Unfortunately, drupal_add_js(), drupal_add_css() and drupal_add_library() have been removed in Drupal 8. This is part of a shift towards putting a heavier focus on render arrays and the #attached property. So, now, you need to get your hands on a render array so you can attach what you need. Luckily the new hook_page_build() will do the trick for us:
/** * Implements hook_page_build() */ function simple_dialog_page_build(&$page) { $path = drupal_get_path('module', 'simple_dialog'); // Add JavaScript/CSS assets to all pages. // @see drupal_process_attached() $page['#attached']['css'][$path . '/css/simple_dialog.css'] = array('every_page' => TRUE); if (\Drupal::config('simple_dialog.settings')->get('js_all')) { simple_dialog_attach_js($page, TRUE); } } /** * Adds the necessary js and libraries to make the * dialog work. Really just adds the jquery.ui * library and the simple dialog javscript file * but if we need to add anything else down the road, * at least it's abstracted into an api function * * @param array $element * The renderable array element to #attach the js to * * @param boolean $every_page * Optional variable to specify the simple dialog code should be added * to every page. Defaults to false. If you're calling this function, * you likely will not need to change this as the module has settings * to specify adding the js on every page */ function simple_dialog_attach_js(&$element, $every_page = FALSE) { $element['#attached']['library'][] = 'system/ui.dialog'; $element['#attached']['js'][] = array( 'data' => array('simpleDialog' => array( 'classes' => \Drupal::config('simple_dialog.settings')->get('classes'), 'defaults' => array( 'settings' => \Drupal::config('simple_dialog.settings')->get('defaults.settings'), 'target_selector' => \Drupal::config('simple_dialog.settings')->get('defaults.target_selector'), 'title' => \Drupal::config('simple_dialog.settings')->get('defaults.title'), ), )), 'type' => 'setting', ); $element['#attached']['js'][drupal_get_path('module', 'simple_dialog') . '/js/simple_dialog.js'] = array('every_page' => $every_page); }
The only thing to note here is how we get configuration from the system. No more variable_get(). Instead we're using the Drupal class' static method config(). Also, I've abstracted the attaching of the js to it's own function in case someone wants to do it manually instead of on every page.
In the D7 version of simple dialog, I provided a little theme function that would build the link for you from a set of options. To be honest, this theme function is pretty unnecessary. You could just as easily use the core l() function. In fact, that's all the theme function did in the end. Ultimately I think I'm going to remove it, but for the sake of exploring the theme system in D8, I'm going to upgrade it.
The theme system has gone through a pretty major overhaul in Drupal 8. The theme engine has been switched from phptemplate to twig, theme functions are all being converted to templates, and even the previously ubiquitous theme() function has been removed. We'll start in familiar territory: hook_theme()
/** * Implements hook_theme(). */ function simple_dialog_theme($existing, $type, $theme, $path) { return array( 'simple_dialog_link' => array( 'variables' => array( 'text' => NULL, 'path' => NULL, 'selector' => NULL, 'title' => NULL, 'options' => array(), 'link_options' => array(), 'class' => array(), ), 'template' => 'simple-dialog-link', ), ); }
hook_theme() actually hasn't changed that much. The only difference between this and the D7 version of simple_dialog is that I specified a template file. This is because I want to turn this particular theme implementation into a twig template. Before I do that however, I need to preprocess the variables.
/** * Preprocesses variables for simple dialog links * * @param $variables * An associative array containing: * - text: The link text for the anchor tag. * - path: The URL to pull the dialog contents from. * - title: The 'title' attribute of the link. Will also be used for the title * of the jQuery ui dialog * - selector: The css id of the element on the target page. This element and it's * containing html will be loaded via AJAX into the dialog window. * - attributes: An associative array of additional link attributes * - class: An array of classes to add to the link. Use this argument instead * of adding it to attributes[class] to avoid it being overwritten. * - options: (optional) An associative array of additional jQuery ui dialog * options keyed by the option name. example: * @code * $options = array( * 'optionName' => 'optionValue', // examples: * 'width' => 900, * 'resizable' => FALSE, * 'position' => 'center', // Position can be a string or: * 'position' => array(60, 'top') // can be an array of xy values * ), * @endcode */ function template_preprocess_simple_dialog_link(&$variables) { // Somewhere to store our dialog options. Will be imploded at the end $dialog_options = array(); // as long as there are some options and the options variable is an array if ($variables['options'] && is_array($variables['options'])) { foreach ($variables['options'] as $option_name => $value) { if ($option_name == 'position' && is_array($value)) { $dialog_options[] = $option_name . ':[' . $value[0] . ',' . $value[1] . ']'; } elseif ($value) { $dialog_options[] = $option_name . ':' . $value; } else { $dialog_options[] = $option_name . ':false' ; } } } // Concatenate using the semi-colon $dialog_options = implode(';', $dialog_options); // Setup the default attributes array_unshift($variables['class'], 'simple-dialog'); $attributes = array( 'title' => $variables['title'], 'name' => $variables['selector'], 'rel' => $dialog_options, 'class' => $variables['class'], ); // We need to merge any other attributes that were provided through the // attributes variable if (!empty($variables['attributes'])) { $attributes = array_merge($variables['attributes'], $attributes); } $variables['attributes'] = new Attribute($attributes); }
Most of the preprocessor is just slightly modified from the original D7 theme function. One thing to note is the way we're handling the attributes. Where we used to use the drupal_attibutes() function, we're now using a helper Attribute class. The full namespace of the class is technically Drupal\Core\Template\Attribute, but I've added a use statement to the top of my module file so I can avoid the long name.
use Drupal\Core\Template\Attribute;
Lastly, we'll create the twig template in a 'templates' subfolder of our module root. The template name needs to match what we specified in hook_theme, but with the '.html.twig' extension (<simple_dialog root>/templates/simple-dialog-link.html.twig). Note: try to be consistent with dashes and underscores in your template names. The template name you specify in hook_theme() will be used verbatim so if you use dashes, the filename will have dashes. It appears core always uses dashes for template names, although it's not technically a requirement.
{# /** * @file * Default theme implementation to print a simple dialog link * * Available variables: * - text: The link text for the anchor tag. * - path: The URL to pull the dialog contents from. * - attributes: The attributes for the link compiled in a preprocessor * * @see template_preprocess_simple_dialog_link() * * @ingroup themeable */ #} <a href="{{ path }}" {{ attributes }}>{{ text }}</a>
On a final note there isn't a ton of information out there about the new theme system from a module developer's perspective. Most of it is geared towards themers. Your best bet is probably to start reading through hook_theme() and exploring how some of the core modules do it. Also the fine people on IRC #drupal-contribute usually have some insights.
One last thing: hook_help() still works and can actually be copied over directly from the D7 module.
If you've done everything right your module directory structure should look something like this:
Congratulations. You've successfully upgraded simple_dialog to Drupal 8! You can test that it works by adding a simple dialog link to a node's body field, or better yet, you can try upgrading the accompanying simple_dialog_example module.