Feeds

Author

Jun 07 2019
Jun 07

I'd like to briefly share a new handy Chrome Extension that I recently created: Drupal Issue Chrome

chrome. noun. features added to something to make it nicer, but which don't affect the core functionality.

This extension will render links to Drupal.org issues in order to clearly indicate node id, title, and issue status. It closely mimics Drupal.org's own rendering of links to issues, bringing the same formatting to ANY website.

For example, an anchor link with the href and text content "https://www.drupal.org/project/drupal/issues/1308152" would become "#1308152: Add stream wrappers to access extension files" and would be colored appropriately. You may also hover over the issue to see the exact issue status, provided via the anchor title attribute. Take a gander:

Extension Screenshot

If the anchor text and href do not match, the extension will style the link without altering the content:

Google Search Screenshot

Simple, but very handy!

You can install the Drupal Issue Chrome extension here or contribute on GitHub!

Why?

I frequently reference Drupal.org issues in my daily routine, especially in JIRA. It's tiresome and inefficient to keep the title and status of those Drupal.org links up-to-date in JIRA.

I wanted to automate the process, but I wasn't interested in writing a JIRA plugin in Java. It occurred to me that it would be both easier to implement and more universally applicable if I could just create a solution as a Chrome extension via Javascript.

Sep 12 2018
Sep 12

Six months ago, Drupal's evaluator experience was complex, time-consuming, and frustrating. That's changed dramatically.

I previously published an article in which I compared the evaluator experience for Drupal against that of Wordpress, Symfony, and Laravel. I attempted to get a new "Hello World" site up and running in each of the four different PHP frameworks and recorded a few statistics.

At the time, Drupal required the most in-browser clicks (to discover relevant documentation, download, etc.) and came in next-to-last in total time required.

Google search term Clicks Commands run Total Time Symfony 3 3 1:55 Wordpress 7 0 7:51 Drupal 20+ 0 10:42 Laravel 3 9 17:28

It wasn't pretty. I repeated that experiment after the release of Drupal 8.6.0, and I'm pleased to report that those numbers look entirely different today:

Google search term Clicks Commands run Total Time Drupal 3 2 1:27 Symfony 3 3 1:55 Wordpress 7 0 7:51 Laravel 3 9 17:28

Whoa! Drupal is now the fastest to get up and running and is tied for least clicks! Additionally, the Drupal site that I created isn't an empty "Bartik" shell (as in the initial experiment). It's now a beautifully designed and fully functional application.

Dries highlighted these improvements today during his keynote at DrupalCon Europe:
Driesnote slide

The evaluator experience is now fast, simple, and dare I say... fun.

What changed?

Let's review how we made this happen as a community.

  1. Drupal 8.6.0 has been released, introducing two salient improvements.

First, a new quick-start command has been added to core. This single command will install a temporary Drupal demo site and automatically log you into it. Best of all, its only dependency is PHP. There's no need to install or configure a web server or database server.

Second, a new Umami installation profile is now in core. Thanks to the hard work done as part of the Out of the Box initiative, the Umami profile provides a fully functional, beautifully themed Drupal application out of the box. It's ready for evaluator experimentation.

  1. UX changes have been made to Drupal.org.

Notably, a new /download page has been published (leave feedback here). This page sports a number of UX improvements.

To start, the new page provides a link that always downloads the latest version of Drupal. Unlike the previous download page, this does not require that you navigate to the release node. The click path to download Drupal has been reduced from 5 clicks to 2 clicks.

Just as importantly, the page provides two prominently-featured and instructive blocks directly below the download links: "Demo Drupal" and "Getting Started." These are intended to steer users into relevant Official Documentation. No more wild goose chases to find the right docs. This greatly reduces the "20+" clicks (in the first chart).

See Documentation Initiative Update, UX Changes to Drupal.org for more information on recent and planned UX changes.

  1. The Evaluator Guide has been published

One of the key parts of the Documentation Initiative Proposal was to introduce a new class of Official Documentation for Drupal. It differs from the community documentation in a few vital ways. Official Documentation:

  • Concisely provides the recommended way to accomplish a task (not a list of all possible ways)
  • Is version controlled
  • Follows an established standard
  • Is subject to a formal review process

The (pre-existing) Drupal 8 User Guide is now considered Official Documentation.

In preparation for the Drupal 8.6.0 release, a new Evaluator Guide was published via the new Official Docs project (leave feedback here). To quote the guide:

This guide provides instructions for creating a temporary Drupal demo application that can be used to evaluate Drupal on your local machine. The demo application is unsuitable for live websites and should be used for demonstration purposes only.

It provides the two commands that you need to evaluate Drupal.

Recap

With the release of Drupal 8.6.0, Drupal's evaluator experience has become excellent and competitive. This has been achieved through the work of multiple initiatives and contributors.

But, the work isn't done! The goals of Documentation Initiative aren't complete. We want to continue to improve existing documentation, expand the Official Documentation, and make more UX improvements to Drupal.org.

How you can help

Are you interested in helping with this work? You can.

Issues

Look for issues tagged with “Documentation Initiative” to follow progress and contribute!

Bi-weekly community “office hours”

Community members are invited to attend bi-weekly community “office hours.” This meeting is intended to provide a forum for status updates, discussion, feedback, etc. Feel free to propose agenda items in the #documentation channel on Drupal Slack. The meeting is held via Google Hangout on the first and third Tuesday of each month at 11am ET.

Slack channel

Contributors and interested community members are welcome to join #documentation channel on Drupal Slack.

Thank you!

I’d like to extend thanks to a few groups and individuals...

  • The documentation initiative team.
  • The Drupal Association and its engineers.
  • Jennifer Hodgdon @jhodgdon

Appendix

For the curious and skeptical, the breakdown of the time-to-Drupal measurements published in this post is as follows:

  • 20s Discover documentation: Drupal.org --> /download --> Evaluator Guide
  • 12s Download & decompress Drupal on CLI (copy & paste command)
  • 41s Install Drupal on CLI (copy & paste command)
  • 14s Open & load browser page (triggered by previous command)
Jul 28 2018
Jul 28

The documentation initiative was announced at DrupalCon Nashville nearly four months ago. In his keynote, Dries’ highlighted my blog post, in which I provided statistics and anecdotes about the challenges of Drupal.org’s documentation and evaluator experience. The documentation initiative aims to address these challenges. What’s happened since then?

I’ve worked over the past few months with a small team of contributors to propose solutions, build consensus, and make improvements to the documentation on Drupal.org. Thank you to all of those that have been active in the issue queues and bi-weekly meetings!

The work has been focused on the initiative’s three goals:

  1. Make UX improvements to documentation on Drupal.org.
  2. Improve existing Community Documentation by consolidating, re-organizing, and revising it.
  3. Introduce a new class of “Official” documentation that will be version controlled and subject to a review process that will enforce documentation standards.

I initially hoped to tackle these three goals in parallel, but quickly discovered that I needed to start with addressing UX issues on Drupal.org. Why?

The UX challenges on Drupal.org make it very difficult to find and use documentation. They also make it hard to improve that documentation. I’ll explain.

I began planning by attempting to audit the existing documentation. As I traversed Drupal.org’s documentation in my browser I found it very challenging to get a comprehensive view of the information architecture. Manually compiling a list of pages by clicking on hundreds of documentation links isn’t much fun. It also made the engineer in me squirm.

So, I spun up a Drupal.org dev site (thanks for the help DA!) and ran some very hairy MySQL queries to get a sense of things. Even when looking at the Drupal 8 Community guide alone, the task was cumbersome and the results were messy. As an example of messy MySQL output, take a look at this unordered list of pages (belonging only to the Drupal 8 Community guide):

Understanding Drupal 8
- Overview
- Directory Structure
- Translation in Drupal 8
Understanding Drupal version numbers
- Which version of Drupal core should I install?
- Which version of contributed modules, themes, and translations should I install?
- When is the next release?
- Which version of Drupal am I running?
- Which version of a module or theme am I running?
- What about upgrading and backwards compatibility?
- Drupal release versions
- Development snapshots
- What do version numbers mean on contributed modules and themes?
- What are alpha and beta releases, and release candidates?
- Release stable version
- Drupal release history
Cron automated tasks
- Cron automated tasks overview
Installing Drupal 8
- Before a Drupal 8 installation
- Step 1: Get the Code
- Step 2: Install dependencies with composer
- Step 3: Create a database
- Step 4: Configure your installation
- Step 5: Run the installer
- Step 6: Status check
- Trusted Host settings
- Quick-start: launch a local demo version of Drupal 8 using 4 brief steps
Configuration management
- Changing the storage location of the sync directory
- Workflow using Drush
- File system based workflow
- Workflow using the Drupal UI
- Managing your site's configuration
- Keeping Your Local and Remote Sites Synchronized - Drupal 8
System requirements
- Browser requirements
- Web Server
- Limitations of 32-bit PHP
- Database requirements
- PHP requirements
- Database server
Extending Drupal 8
- Installing modules' Composer dependencies
- Installing Drupal 8 Modules
- Overview
- Installing Themes
- Interface guidelines (Seven admin theme)
- Finding Contributed Modules
- Uninstalling Modules
- Installing Modules from the Command Line
- Updating Modules
- Module Documentation and Help
- Troubleshooting
- Module Configuration
- Installing Sandbox Modules
Administering a Drupal 8 site
- Automated Cron
- Internal Page Cache
- Getting started with Drupal 8 administration
- Managing content
- Working with content types and fields
- Working with content types and fields
Migrating to Drupal
Multisite Drupal
- Multisite Drupal 8 considerations
- Multisite Drupal 8 Setup
- Multisite folder structure in Drupal 8
- Drupal 8 Multisite on a LAMP stack
Contributed modules
- Default Content
- Varbase Editor
- Business Rules
- Youtube Gallery
- Permissions by Term
- Block Style Plugins
- Developer Suite
- Commerce Braintree
- Feature Toggle - Deprecated
- Socialfeed
- HelloSign
- [marked for deletion]
- Marketing Cloud
Accessibility
- Drupal 8 Accessibility Features
- Contributed Modules for Extending Accessibility in Drupal 8
- Hide content properly
- External Accessibility Resources
Updating Drupal 8
- Update core via Composer
- Updating Drupal 8 - overview of options
- Update core manually
- Update core via Drush
- Update modules
Upgrading to Drupal 8
- Upgrade using Drush
- Preparing a site for upgrade to Drupal 8
- Upgrade using web browser
- Known issues when upgrading from Drupal 6 or 7 to Drupal 8
- Upgrading from Drupal 6 or 7 to Drupal 8
- Drupal 8 migrate modules
- Contributing to Migrate
- Learn key Drupal 8 concepts prior to upgrading
- Customize migrations when upgrading to Drupal 8
- Choosing the upgrade approach
Security in Drupal 8
- Writing secure code for Drupal 8
- Security of generated PHP files
- Secure configuration for site builders
- Secure Database Queries
- Drupal 8: Sanitizing Output
- US NIST Password Guidelines review
Mobile guide
- Responsive Images in Drupal 8
- Native mobile application development
- Mobile-specific website
- Web-based mobile apps
- Responsive web design
- Responsive Design + Server-side Components (RESS)
- Mobile testing tools
- Related mobile technologies
- Front-end performance
Multilingual guide
- Choosing and installing multilingual modules
- Translating configuration
- Translating content
- Install a language
- Enable language negotiation
- Menu translation in Drupal 8
'Clean URLs' in Drupal 8
- Fix Drupal 8 'Clean URLs' problems
Theming Drupal 8
- Defining a theme with an .info.yml file
- Drupal 8 Theme folder structure
- Adding Regions to a Theme
- Adding stylesheets (CSS) and JavaScript (JS) to a Drupal 8 theme
- Classy themes css selectors
- Including Default Image Styles With Your Theme
- Working with breakpoints in Drupal 8
- Including Part Template
- Preprocessing and modifying attributes in a .theme file
- Using attributes in templates
- Sub-theming: Using Classy as a base theme
- Creating a Drupal 8 sub-theme, or sub-theme of sub-theme
- Creating advanced theme settings
- Theming differences between Drupal 6, 7 & 8
- Drupal Twig conversion instructions (tpl.php to html.twig)
- Upgrading classes on 7.x themes to 8.x
- Sub-Theme inheritance
- Creating automation tools for custom themes (Gulpjs)
- How to edit ALT tag on your site logo
- Theming Overview: How data is displayed
- Z-indexes in Drupal 8
- Using CSS pre-processors
- Add Google Webmasters Tools verification meta tag via the .theme file
Managing site performance and scalability
- Content Delivery Network [CDN]
- Blazy
- Server Scaling
Creating custom modules
- Building a Views display style plugin for Drupal 8
- Creating a custom Field
- Create a custom field widget
- Create a custom field formatter
- Create a custom field type
- A practical guide to building basic Drupal 8 modules
- Testing
- Defining a Block
- Settings
- Theming
- Basic structure
- Prepare a Module skeleton
- Add a composer.json file
- Naming and placing your Drupal 8 module
- Let Drupal 8 know about your module with an .info.yml file
- Adding Custom Blocks to your custom Module
- Add a Default Configuration
- Use Config in Block Display
- Process the Block Config Form
- Add a Form to the Block Configuration
- Create a custom block
- A "Hello World" Custom Page Module
- Going further
- Add a menu link
- Add a routing file
- Adding a basic controller
- Create a custom page
- Getting Started - Background & Prerequisites (Drupal 8)
- Defining and using your own configuration in Drupal 8
- Adding stylesheets (CSS) and JavaScript (JS) to a Drupal 8 module
- Understanding Hooks
- Add module to drupal.org
- Event Systems Overview & How To Subscribe To and Dispatch Events
Drupal 8 APIs
- Runtime Assertions
- Installation & Setup Guide
- Configuration
Testing
- Converting D7 SimpleTests to Drupal 8
- Simpletest Class, File, and Namespace structure
- Types of tests in Drupal 8
- Javascript testing using Nightwatch
Converting Drupal 7 modules to Drupal 8
- Step 3: Convert hook\_menu() and forms
- Step 2: Convert automated tests to Drupal 8
- Debugging Drupal 8 module upgrades
- Step 1: Convert mymodule.info to mymodule.info.yml
- Intro & Before you start: Setting up a Drupal 8 module dev environment
- Resources and tutorials
- D7 to D8 upgrade tutorial: Convert hook\_menu() and hook\_menu\_alter() to Drupal 8 APIs
- D7 to D8 Upgrade: Generated HTML
- D7 to D8 upgrade: fields, widgets and formatters
- WSCCI Conversion Guide
- WSCCI Conversion Guide - Best practices
- WSCCI Conversion Guide - Pass 3
- WSCCI Conversion Guide - Pass 2
- WSCCI Conversion Guide - Pass 1
- D7 to D8 tutorial: pathinfo module
- D7 to D8 upgrade tutorial: Pants module
- Step 5: How to upgrade D7 variables to D8's state system
- Step 4: Convert Drupal 7 Variables to Drupal 8 Configuration
Creating distributions
- How to Write a Drupal 8 Installation Profile
Core modules and themes
- Basic structure of Drupal 8
Distributions
- deGov
- Thunder
- Install deGov 1.x
- Install deGov 2.x
- Upgrading from deGov 1.x to 2.x
Contributed themes
- Drupal8 W3CSS Theme Configuration
External Libraries in Core
- External PHP Libraries
- External JavaScript Libraries
- External CSS Libraries

Frankly, that list isn’t very helpful. It's 238 lines, it's unordered, and it shows only two levels of docs for a single guide. Representing more than that with MySQL is rough, and running a MySQL query (on a dev site) to generate it just isn’t a good or sustainable method of content auditing.

I determined that we need to do a better job of exposing the information architecture of Drupal.org’s documentation in the UI. So, that’s where I started to focus effort. By improving the visibility and discoverability of documentation we enable ourselves to better improve our documentation.

So, let’s talk about the Drupal.org documentation UI and UX.

Documentation Landing Page

I started with the documentation landing page and identified a few UX issues to resolve:

  1. The UI doesn’t tell me where I should start.
  2. The difference between the “Drupal 8 User Guide,” “Drupal 8 documentation,” and “Developer documentation” is not inherently obvious.
  3. The combination of Drupal 7, Drupal 8, and version-independent content is too much to easily scan and parse.

To resolve these issues, I dove into the Drupal.org customizations issue queue and the Drupal.org theme issue queue (bluecheese) and started submitting patches.

Take a gander at the before and after (still in dev) screenshots of this page:

In particular, the patches and content updates introduce the following salient changes:

  1. Add a “Getting Started” section at the top of the page to provide clear instruction to new users.
  2. Label and group the three different types of documentation, with descriptions for each:
    1. Official Documentation
    2. Community Guides
    3. API Reference
  3. Add “Official Guides” directly after “Getting Started” to steer users toward Official Guides.
  4. Add a listing of each top-level guide’s child pages.
  5. Add a menu to the sidebar (which can be used on child pages) to provide the user with a short list of the top-level documentation guides.
  6. Split the documentation landing page into two tabs for Drupal 7 and Drupal 8, defaulting to Drupal 8.
  7. Add a preprocessor to automatically generate invisible anchors for each section so that anchors can be used in the sidebar menu.

Status: Awaiting review from Drupal.org engineering team. These changes are not yet live on Drupal.org.

The following issues must be reviewed and merged:

There will certainly be follow up issues to introduce more improvements, but these changes are a step in the right direction.

Next, let’s move down one level in the documentation hierarchy by visiting the Drupal 8 Community Guide.

Documentation Guide Nodes

Pages like Drupal 8 Community Guide and Drupal 8 User Guide are documentation_guide nodes. They’re also Organic Groups, which allows them to have their own menu, content, maintainers, etc. That’s cool.

Since a documentation_guide node can belong to another documentation_guide node, there’s also no limit to the depth to which guides can be nested. That makes for some very well hidden content. Not cool. But I digress.

I identified and addressed two UX issues on this page (they may look familiar):

  1. The “child guides” under the Drupal 8 Community Guide do not list their content on the parent guide node. For the Drupal 8 Community Guide, you’d need to open 32 separate guide pages just to learn what pages the child guides contain. That’s not to mention the grandchild guides and so forth.
  2. The sidebar has an incomplete list of top-level guides, and does not differentiate between various types of documentation.
  3. The guide maintainers are not listed.

Here’s are before and after screenshots of the Drupal 8 Community Guide:

These issues were resolved by re-using the menu and guide panes that I created for the documentation landing page, and filing a issue to add a maintainer panes (which was quickly addressed by @drumm).

I was astonished to see how many sub-guides are actually part of the parent Drupal 8 Community guide! This view is much more helpful than MySQL query output.

It exposes the fact that there are some very odd things going on in the information architecture. For instance, the Understanding Drupal 8 child guide has only a single page in it -- the overview page. It’s strange to have a guide that has an overview without subsequent content, and it feels very awkward as a reader. This is exactly the type of thing that is helpful to discover when refactoring the information architecture.

Status: Awaiting review from Drupal.org engineering team. These changes are not yet live on Drupal.org.

The following issues must be reviewed and merged:

Next, let’s jump down two levels by clicking into the Front-end performance page in the Mobile Guide.

Documentation Page nodes

Each documentation guide node has one or more documentation pages that belong to it. Front-end performance is a documentation page that belongs to the Mobile Guide documentation guide, which belongs to the Drupal 8 Community Guide.

I identified two major UX issues on this page, all of which are particularly problematic on very long pages.

  1. There is no table of contents that lists the sections of the page.
  2. I cannot easily jump to (or even link to) sections of the page because most headings have no anchors in the markup.
  3. I cannot easily navigate to the next or previous pages in the guide.

To resolve these issues, I made the following changes:

  1. Added a new field formatter to the documentation page node “body” field that will automatically generate anchors for all h2, h3, and h4 tags (see hash tags to the left of each heading). This was accomplished by creating a new 7.x-2.x-dev branch of the Table of Contents module. Thank you to the maintainer @e0ipso for prompt responses and assistance!
  2. Added an automatically generated table of contents block to the right hand sidebar on documentation pages. Naturally this is also provided by the new Table of Contents module branch.
  3. Added a “tool tip” markup template to enable authors to call out important information.
  4. I’ve yet to tackle the task of creating a pager (previous and next links), but an issue has been created. Volunteers?

These UX changes make documentation pages more navigable. They also may encourage documentation contributors to better structure content by using headings.

Status: Awaiting review from Drupal.org engineering team. These changes are not yet live on Drupal.org.

The following issues must be reviewed and merged:

That’s it for documentation pages. But wait, there’s more! Now we will take a few steps back and look how users find the documentation section.

Evaluator click path

We’ve looked at the UX for the documentation landing pages and many of the pages under it, but how do we get to /documentation in the first place? I’m particularly interested in ensuring those new Drupal users can easily download Drupal and intuitively find documentation that provides next steps.

At present, you must visit five separate pages and perform four clicks in order to simply download a Drupal tarball or zip file. I’d like to get that down to two clicks total.

To do that, we need to make changes to one or more of the following pages: 1) The home page, 2) The /try-drupal page, 3) the /download page.

I’ve proposed a few wireframes in this issue. Take a look at the current /download page as compared to one potential wireframe:

Notable changes include:

  • The zip file and tarball are available directly on the /download page, no need to visit the release node.
  • A separate link “release notes” link has been added.
  • The most relevant information (for new users) is displayed first -- Installation, Documentation, Requirements.
  • Secondary information (Translations, Modules, etc.) have been clearly placed in a separate section.

In addition to simplifying the click path in the web browser, some of the work required to accomplish this will also simplify the process of downloading Drupal on the command line.

As an example, let’s look at the new quick-start command that will ship with Drupal 8.6. This awesome new command makes it possible to install a Drupal demo application and login to in via your web browser in a single command. It does not require Composer, Drush, or Drupal Console, but it does require you to download Drupal first!

At present, you’d need to run this set of commands like this to quick-start:

curl -sS https://ftp.drupal.org/files/projects/drupal-[x.y.z].zip --output drupal-[x.y.z].zip
unzip drupal-[x.y.z].zip
cd /path/to/drupal-[x.y.z]
php core/scripts/drupal quick-start demo_umami

Notice this code requires you to research and find the latest stable version of Drupal, then substitute it for the “[x.y.z]” snippets. You can’t copy and paste the code as-is and execute it.

By contrast, consider this:

curl -L https://www.drupal.org/download-latest/tarball > drupal.tar.gz
mkdir drupal && tar -zxvf drupal.tar.gz -C drupal --strip-components=1
cd drupal
php core/scripts/drupal quick-start demo_umami

This snippet makes use of a URL that always references the recommended version of Drupal, so you can directly copy, paste, and execute! No need to choose and specify the version of Drupal.

Status: Awaiting review from Drupal.org engineering team. These changes are not yet live on Drupal.org.

The following issues must be reviewed and merged:

Recap

The documentation initiative making significant progress towards its first goal, to improve the user experience of discovering, navigating, and consuming documentation on Drupal.org. This work will set us up to tackle the next goals of improving the existing documentation and drafting new documentation.

Multiple issues are in progress to improve the user experience for:

  • The documentation landing page
  • Documentation guides
  • Documentation pages
  • The /download page

We will continue to work on these issues and roll out improvements to Drupal.org documentation. We can then turn more attention to working on the docs themselves!

How you can help

Are you interested in helping with this work? You can.

Issues

Look for issues tagged with “Documentation Initiative” to follow progress and contribute!

In particular, we need help with the following issues:

Bi-weekly community “office hours”

Community members are invited to attend bi-weekly community “office hours.” This meeting is intended to provide a forum for status updates, discussion, feedback, etc. Feel free to propose agenda items in the #documentation channel on Drupal Slack. The meeting is held via Google Hangout on the first and third Tuesday of each month at 11am ET.

Slack channel

Contributors and interested community members are welcome to join #documentation channel on Drupal Slack.

Thank you!

I’d like to extend thanks to a few groups and individuals...

  • The documentation initiative team.
  • The Drupal Association and its engineers.
  • Ryan Weaver from Symfony Docs @weaverryan, for thoughts and advice.
  • Jennifer Hodgdon @jhodgdon, for lots of input in the issue queues.
Mar 21 2018
Mar 21

I recently performed an experiment in which I attempted to emulate the experience of a Drupal evaluator. I highlighted a few glaring pain points and shared my experience and conclusions in a blog post: Stranger in a familiar land: Comparing the novice's first impression of Drupal to other PHP frameworks.

That post sparked a tremendous degree of engagement. Many people commented, wrote responses on their own blogs, and voiced support on twitter. I was pleasantly surprised by the Drupal community's response, which was (nearly) uniform and highly corroborative. The jury is in. Drupal's evaluator experience is fraught.

A few notable long-form responses include:

In this post, I'd like to shift the conversation toward specific solutions. In particular, I'd like to focus on ways that we can improve the evaluator, developer, and site builder experiences through changes to our documentation.

Documentation Initiative Proposal

The 2018 Stack Overflow Developer Survey indicated that 83% of Developers learn via the official documentation. Documentation is very important. To borrow Adam Hoenich's statement:

I think the Drupal community should undertake a first-class, long-term Documentation Initiative. ... it is absolutely worthy of becoming a full-fledged core initiative, with all the resources and support that existing initiatives enjoy.

I'd like to propose a formal Documentation Initiative for community consideration. In drafting this proposal, I've tried to incorporate:

The Proposal

There was a strong documentation effort undertaken not too long ago. It produced the Drupal 8 User Guide, which is far and away the best documentation on Drupal.org.

Unfortunately, Drupal.org's UX doesn't make the Drupal 8 User Guide easy to find or navigate. If you're lucky enough to find the Drupal 8 User Guide, you're unlikely to understand the difference between the Drupal 8 User Guide and competing documentation like the Drupal Community Guide. It makes for a very confusing experience.

I'd like to propose that we elevate the Drupal 8 User Guide to the status of "Official Documentation," and update the UX on Drupal.org to prominently feature the Official Documentation on major site entry points. A Drupal novice should encounter the Official Documentation essentially by default. By contrast, our Drupal Community documentation should be clearly identified as wiki-style and unofficial. We should minimize its visibility.

The team that produced the Drupal 8 User Guide established a set of admirable best practices. They implemented a governance process, version control, and documentation standards. They wrote their docs in markdown format and created a continuous integration process to generate and update screen shots of Drupal interfaces. Bravo.

We should adopt these best practices for all Official Documentation. In fact, we should go further. The Drupal 8 User Guide already has multiple branches corresponding with Drupal core minor and major versions. E.g., 8.5.x, 8.6.x, 9.0.x, etc. Let's integrate these into the Drupal.org UX, much in the way that Symfony and Laravel do. We should be able to intuitively identify and switch the documentation version.

Let's go further and lower the contribution barrier. At present, the Drupal 8 User Guide uses a Drupal Issue Queue. This means contributing by attaching patches and text files to issues. I, for one, would be immediately deterred from contributing by these requirements.

I'm going to be a bit controversial and propose that we use GitHub (or GitLab) to manage this instead. Allowing community members to use pull requests and an in-browser editor to contribute documentation will lower the bar while simultaneously leveraging version control and peer review. That would have massive impact. Drupal.org is apparently planning to migrate to one of these services. Let's not wait for that.

Lastly, I'd like to expand the Official Documentation by creating a new class of documentation that we're lacking: "The Official One Pager." The purpose of this class of documentation would be to provide a one page guide that prescribes the single recommended way to accomplish common Drupal tasks, like installing Drupal locally or using Configuration Management. Initially, I'd suggest starting with the following one page guides.

  • Official Quick Start Guide (download, install, and log into Drupal)
  • Official Configuration Management Guide (export, import, update, deploy)
  • Official Composer Guide (install, update, extend, maintain, troubleshoot)

These are not intended to be exhaustively comprehensive. They are not intended to communicate all the myriad ways you can do things with Drupal. They will simply prescribe a straightforward path for accomplishing a discrete set of tasks. More detailed, nuanced, and comprehensive documentation will still be referenced and made visible.

Lastly, I'd like to propose that we prominently feature high quality, free training videos to help people get started. These should be provided as alternatives to the one page guides. Videos like this already exist. I believe that the Drupal Association could find willing partners to provide these materials in exchange for market visibility.

The picture of success

The documentation initiative would bring our documentation into line with competing frameworks. Let's take a quick look at Symfony as an example of a framework with stellar documentation.

alt text

Finding the official documentation is a breeze. Visiting Symfony.com displays a "Documentation" link as the second primary menu link. One intuitive click gets me to their official documentation.

The first "Getting Started" section begins with "Setup" as the first link. It's easy to find. Upon visiting the "Setup" page, I find that it has all of the elements of this proposal:

  • It provides a single clear path forward that ends with a running web server and a working application (in one concise page).
  • It is version controlled and it clearly indicates the framework version that the documentation refers to.
  • It includes an "edit" button that allows you to contribute on GitHub.
  • It links to free video tutorials.
  • It links to more detailed guides for further reading.

The process of visiting Symfony.com, finding documentation, creating a brand new Symfony project, and visiting that new app in a browser took less than two minutes in practice. That is the bar. Drupal can do this too.

What you can do

Speak Up! To even begin this initiative, we need "buy-in" from the community and the Drupal Association. Assuming you, the reader, belong to one or both of these groups, speak up! Post a comment with your support, suggestions, or constructive criticism. This proposal needs review and modification by experienced community members.

Join a related BOF (Birds of a Feather) at DrupalCon Nashville and lend your voice or your help:

Get involved. If you'd like to contribute, or if you're in a position to facilitate the changes proposed in this post, contact me!

Let's make this a reality.

Feb 23 2018
Feb 23

Drupal 8 adoption is flagging. Why? I tried to lay my biases and assumptions aside and set out to find the answer. What I found surprised me.

I decided to perform an experiment. Placing myself (as much as possible) in the shoes of a senior developer without any Drupal experience, I attempted to get a new "Hello World" site up and running in four different PHP frameworks: Wordpress, Laravel, Symfony, and Drupal.

I set a few ground rules for myself:

  • Start at square 1. Google "Drupal" (or Wordpress, etc.).
  • Use only information found organically via my Google search and subsequent clicks.
  • Take the path of least resistance. In other words, choose the easy way when more than one option exists.
  • Avoid the command line when possible.

Measurements:

  • Time required.
  • Number of clicks in web browser.
  • Number of CLI commands run.

I do not claim that this experiment was purely objective or scientific. The fact is that I do have plenty of Drupal, PHP, and CLI experience and I'm not a good representation of the average Drupal novice. However, I think that the exercise did provide a rough sense of the developer experience.

My system setup before beginning:

  • Operating system: Mac OSX 10.13.3
  • Installed libraries:
    • PHP
    • Git
    • Composer
    • Vagrant
    • Virtualbox
    • MAMP (MySql, Apache, PHP)
  • Presumed knowledge/skills:
    • Ability to create new VirtualHost entry in Apache (via MAMP)
    • Ability to create new MySQL database (via MAMP)
    • Ability to use CLI to execute basic commands (cd, cp, mkdir, git) and to copy and paste commands from docs.

TLDR; these are the results:

Broken into 3 categories:

  1. Creating a site via path of least resistance
  2. Creating a site explicitly on my local machine
  3. Creating a site explicitly on a free, organically discoverable hosting provider

Creating a site via path of least resistance

Framework Clicks Commands run Local Total Time Wordpress 7 0 no 1:14 Symfony 3 3 yes 1:55 Drupal (pantheon) 11 0 no 5:04 Laravel 3 9 yes 17:28

Creating a site on local machine

Framework Clicks Commands run Total Time Symfony 3 3 1:55 Wordpress 7 0 7:51 Laravel 3 9 17:28 Drupal *20+ 0 *15:00+

* Spoiler, I gave up counting clicks and reading docs on Drupal.org after 20 clicks and 15 minutes.

Creating a hosted site

Framework Host Clicks Local Total Time Wordpress wordpress.com 7 no 1:14 Symfony (heroku) heroku.com 10 no 4:21 Drupal (pantheon) pantheon.io 11 no 5:04 Drupal (acquia) acquia.com 8 no 16:34

Wordpress is a clear winner for the fastest and simplest setup via the path of least resistance, which is (no suprise) on a hosted service. Symfony was the clear front runner for setup on my local machine.

Analysis

Hosted Site

In each case, setting up a new site with a free hosting provider (when available) proved to be the simplest solution. But, the experience of doing it with Drupal was the least intuitive.

Drupal is the only framework that made me choose a hosting provider and navigate through a hosting UI to find the framework's homepage. I was presented with the choice between Patheon, Acquia, and 1&1. As an (imagined) Drupal noob, I had no idea which to pick and I was tempted to veer off course to research the providers.

Instead I chose Pantheon purely because it was the first option listed. I later learned that the ordering of hosting providers is randomized. With Pantheon, I was walked through an 11 click and 5 minute process that eventually dropped me onto the homepage of my new Drupal site. I decided to try the Acquia path just for fun. 8 clicks and ~16 minutes later, I was back on a Drupal homepage. This process was not without some consternation.

With both Pantheon and Acquia, I found myself in the unexpected territory of a hosting provider's UI. In the case of Acquia's UI, it took me a full minute of staring at the screen before I could determine which link would actually "get me into Drupal." This blog post describes the experience well (with screenshots).

If I hadn't already been somewhat familiar with both Pantheon and Acquia, I would have found it bewildering to navigate through a landscape of environments, servers, and workflows before even seeing Drupal. It left plenty of room for improvement.

Let's move on to the experience of setting up a new site locally.

Local site

In terms of the technical setup, both Symfony and Laravel had superior developer experiences, primarily because both provided out-of-the-box development environments.

Symfony had a surprisingly easy set of instructions that did everything from create the project to launch the web server in less than 2 minutes. Wow. I wish Drupal would replicate that. We have a lot to learn from the Symfony community. I didn't need to think about LAMP stacks or VMs at all.

Laravel on the other hand, provided a ready-to-go Vagrant box for my needs. While somewhat weighty and complex, it was wonderful to have a clear go-to solution that just worked, albeit slowly. I'm not a huge fan of Vagrant, but I am a huge fan of plug n' play.

Wordpress and Drupal had similar setup experiences from a technical perspective. Both required a tarball download and the configuration of a LAMP stack. For many developers, particularly those new to PHP or those on Windows, LAMP stack setup isn't trivial. It can be a serious impediment to require that users first research, choose, and set up a LAMP stack. It reminds me of Carl Sagan's quote "If you wish to make an apple pie from scratch, you must first invent the universe." It feels like you need to invent the universe before building a Drupal site.

Choosing and providing a standard solution for this, as Laravel and Symfony have, would be a great improvement. Drupal claims to be developer focused, but Symfony and Laravel do a much better job of enabling developers out-of-the-box.

But let's move on to the biggest and most interesting difference.

The biggest difference

The experience of navigating Drupal documentation as an (imagined) novice was confusing, frustrating, and ultimately demoralizing. After 20 minutes of clicking back and forth between various conflicting and competing documentation guides, I was seriously questioning whether I even wanted to finish this blog post.

I'm going to give a play-by-play of my user experience, and wrap up with a summary with the underlying issues that it reveals.

I began by Googling "Drupal" and clicking on the first organic result, drupal.org. I spotted the "get the code" link pretty quickly and followed that with a click on "Download Drupal 8.4.4". A little redundant, but I wasn't feeling annoyed yet.

I was taken to the release page for Drupal 8.4.4, which was filled with release notes, known issues, and a slew of information that was irrelevant to my purposes and somewhat overwhelming. Ignoring 90% of the page content, I clicked "Download tar.gz" and then hunted down the sidebar content to find "Learn how to install Drupal". I've already clicked some form of "Download / get code" three times at this point, but I'm feeling like I'm almost done. If only.

I land on the Installing Drupal 8 section of the Drupal 8 docs guide, the first line of which reads:

"Chapter 3 of the Drupal 8 User Guide covers server requirements".

I ask myself...

  • "Am I not in the Drupal 8 User Guide? "
  • "Is that a different guide?"
  • "Should I read that instead?"

I decide that I should follow the link. This takes me to Chapter 3. Installation of the Drupal 8 User Guide which is a completely different set of docs. I think to myself, "this is weird, but I'm going to go with it." I also notice that I'm on Chapter 3, which means that I may have skipped over important information.

I decide to start at the beginning and visit Chapter 1. Understanding Drupal. This is filled with information on Drupal terminology, licensing, data types, and architecture. In fact, there are 19 sections that preceed the Installation chapter. I am decidedly not interested in learning this yet, I'm just trying to get a basic site running! My experiences with Wordpress, Symfony, and Laravel did not subject me anything like this as a first step. I'm going to abandon the background info and go back to Chapter 3.

I encounter "3.1. Concept: Server Requirements", which gives me an overview of various types of web servers, including Hiawatha and Microsoft IIS. I'll skip over that and get down to business. Next is "3.2. Concept: Additional Tools," which tells me all about Drush, Git, Composer, Devel, Drupal Console, Coder, and other tools. I still haven't found any instructions and I'm starting to doubt that I made the right choice in following the Drupal 8 User Guide to get a quick start. I decide to go back to Installing Drupal 8 section of the Drupal 8 docs guide, where I initially started.

I'll start with the first section, "Before a Drupal 8 installation". This contains a preamble with the words:

Documentation for Drupal 8 on drupal.org is found in two separate areas... The basic differences between the two documentation areas are discussed here.

I decide that I'll click the link because I do want to know what the hell is going on with the documentation. I'm rewarded with the following choice snippets of text:

if you are trying to find a clear step-by-step guide here at drupal.org (d.o) to achieve exactly what you want to do, (first of all, "Good luck,")

and:

Which guide you should start with? I can not answer that, since by all appearances to me, the documentation at d.o has all the appearances of being a war waged on two fronts.

and a literal cry for help:

If you, like me, cherish daunting challenges, and welcome the prospect of helping with what I loosely, and comedic-ly, refer to as the 'train wreck' that the d.o documentation is...
And if you find pleasure knowing you have made contributions which help thousands of people, without a need for gratification from others...
Help.

Are. You. Kidding. At this point the farce is wearing thin. Drupal is a great framework and I've been proud to be a contributor and member of the community for many years. As I go through this exercise, I'm becoming embarrassed on behalf of Drupal.

I decide to just push forward. Read the docs, install Drupal, and go take a walk. I continue reading "Before a Drupal 8 installation". I don't get halfway through the section before I am told to see another guide on Local server setup. That guide has 10 chapters of its own each with multiple sections. There is no way I need to wade through all of that to get a Drupal site set up. Forget it, I'll just use MAMP since there is no clear recommendation or clear path forward.

Before I even leave the "Before a Drupal 8 installation" section, I encounter this text:

h1. Three Drupal sites per live site

By the way, when you have a live Drupal site, you will at times have a total of three Drupal sites running on your computer or web host.

Ignoring the fact that I'm reading a section that starts with "By the way," this is just wrong. No one runs 3 versions of the same Drupal site on their local machine, and those who have multiple environments in the cloud may or may not have three of them. Why is this section even on a "Before a Drupal 8 installation" page?

Why would a "Before a Drupal 8 installation" page contain this:

Before you do make changes to your live site, you will want to grab a copy of your Drupal codebase, and your Drupal database, and use them to create a backup site to make sure you have what you need to recreate your live site as it is in the event that your changes to your live site go horribly wrong, for whatever reason.

You can, of course, delete the 'backup' installation after you establish that your backup codebase and database are 'good', but be sure to keep at least three separate sets of that codebase/database set, in three separate locations.

Three separate locations mean separate online companies or separate USB drives/hard drives in separate locations. This will prepare for a disaster: for example, a fire at your home/place of business, or one of your online storage facilities getting hacked into.

I just wanted to install Drupal. I'm 20 clicks into a rat's nest of documentation, I've stumbled across three different guides, one apparent documentation war, a suggestion that changes to my live site may go "horribly wrong," and instructions to place my non-existent Drupal site's backups onto three separate USB drives stored in geographically disparate locations in case of fire or hacking.

I stopped counting clicks at this point and restarted the exercise using the knowledge that I already have. Even then, it took longer than getting WordPress set up locally (for the first time ever).

Honestly, if I were evaluating Drupal, this experience alone would make me choose a different framework.

As compared to other popular PHP frameworks, the experience of setting up a new "hello world" Drupal site as a novice is uniquely difficult and frustrating.

The first major roadblock encountered is documentation, which is sprawling, conflicting, redundant, and disorganized. To compete with other frameworks, we need to have clear, concise, and consolidated documentation that explicitly indicates the recommended/supported/official way to do things. I understand that Drupal is flexible and allows us to do things in infinite ways. It's not unique in that way. We can still provide clear instruction.

The technical process of setting up a Drupal site isn't the worst, but also not the best in any category. It lacks the "out-of-the-box" tools that Symfony and Laravel provide for getting quickly spun up on a web server, and it lacks the simplicity and speed of Wordpress.

I think that Drupal can be a framework for ambitious sites and also one that is a joy to use, even for novices.

We need to ...

  • Improve our documentation.
    • Docs should be clear, concise, and consolidated
    • Docs should explicitly indicate the recommended/supported/official way to do things
    • Docs should be both curated and very easy to contribute to
  • Provide an official, documented, out-of-the-box solution for spinning up new Drupal sites quickly and easily on a local machine.

I am frankly hesitant to jump into the fray and help solve these problems. The scope of the technical and documentation problems isn't intimidating. The fact that there are many stakeholders and a pre-existing controversy is intimidating.

To be successful, we need coordination from the Drupal Association to make changes on Drupal.org. We need buy-in from the community to support an "official" solution for local development. We need collaboration from the Drupal User Guide maintainers and the Drupal 8 Guide maintainers.

Given those challenges, I'm hesitant. But I'm still willing to give it a shot.

If you're in a position to help address these challenges, contact me. Let's fix this.

Feb 02 2015
Feb 02

Drupal's locale module includes a lot of great features for supporting multilingual sites. One such feature is the ability to associate a language with a path alias. This allows you to have one node with two versions (let's say an English version and a Spanish version)--each with its own alias.

But your use case may not require language-specific paths per node. Maybe you want to call a spade a spade-- you've got a Spanish node or and English node and that's it. No fancy multiple versions.

Well then you've got a bit of a problem--a few actually. This can wreak havoc with aliases, and pathauto in particular. The solution is the Local Path Ignore module. It ignores the language of a given path, and instead forces all paths to have a language of "undefined," effectively allowing path aliases to behave the way you'd expect--one path per node.

Aug 22 2013
Aug 22

views cache bully admin settingsTucked away under the Views UI's "advanced" fieldset is a too-seldom-used option: Views caching. It allows you to cache the query results and/or rendered markup for any given view. This can drastically improve your site's performance.

Unfortunately, many people don't use this option. Maybe they don't know about it, maybe they've forgotten about it, or maybe they don't like. Well, the Views Cache Bully module is here to say "Too bad. You're gonna cache your views, and you're gonna like it."

To quote Dave Stoline (dstol):

Views Cache Bully some serious assumptions about caching your views. It forces caching down your throat

How does it work?

This is pretty straightforward module. It works by setting the views_plugin_cache_none plugin to use the views_plugin_cache_time plugin. Once enabled, all of your site's uncached views will be using time-based caching. The settings for bully-enforced caching can be globally administered on a settings page, which requires the "administer views cache bully" permission. This will keep naughty content admins from trying to pull a quick one.

If you'd rather not use the globally set cache expirations, that's fine. You can still choose customized time-based caching per view—you just can't get away with no caching. Well, maybe you can.

Administrators with sufficient permissions may make exemptions for specific views. This is most important for views with exposed filters (which don't always play well with caching). By default, views that use exposed filters are exempt. This is a bully with a heart of gold.

Mar 14 2013
Mar 14

This article provides a step-by-step tutorial for creating a custom, multistep registration form via the Ctools Form Wizard in Drupal 7. If you'd prefer to solely use the core Form API, take a look at Building a Multistep Registration Form in Drupal 7, a previous blog post. In the interest of saving time, I'm going to be lifting some text directly from that post, given that there are a number of overlapping tasks.

Why use the Chaos Tools module to build a multistep form? Well, Ctools offers a number of tools that build upon the core Form API, allowing you to create a multistep form faster. This includes providing a method for caching data in between steps, adding 'next' and 'back' buttons with associated callbacks, generating a form breadcrumb, etc.

The Tut

First things first— create a new, empty, custom module. In this example, the module will be named grasmash_registration. In the interest of reducing our bootstrapping footprint and keeping things organized, we're also going to create an include file. This will store the various construction and helper functions for our form. Let's name it grasmash_registration_ctools_wizard.inc.

We'll start by defining a "master" ctools form wizard callback. This will define all of the important aspects of our multistep form, such as the child form callbacks, titles, display settings, etc. Please take a look at the help document packaged with ctools in ctools/help/wizard.html for a full list of the available parameters.

<?php
/**
 * Create callback for standard ctools registration wizard.
 */
function grasmash_registration_ctools_wizard($step = 'register') {
  // Include required ctools files.
  ctools_include('wizard');
  ctools_include('object-cache');

  $form_info = array(
    // Specify unique form id for this form.
    'id' => 'multistep_registration',
    //Specify the path for this form. It is important to include space for the $step argument to be passed.
    'path' => "user/register/%step",
    // Show breadcrumb trail.
    'show trail' => TRUE,
    'show back' => FALSE,
    'show return' => FALSE,
    // Callback to use when the 'next' button is clicked. 
    'next callback' => 'grasmash_registration_subtask_next',
    // Callback to use when entire form is completed.
    'finish callback' => 'grasmash_registration_subtask_finish',
    // Callback to use when user clicks final submit button.
    'return callback' => 'grasmash_registration_subtask_finish',
    // Callback to use when user cancels wizard.
    'cancel callback' => 'grasmash_registration_subtask_cancel',
    // Specify the order that the child forms will appear in, as well as their page titles.
    'order' => array(
      'register' => t('Register'),
      'groups' => t('Connect'),
      'invite' => t('Invite'),
    ),
    // Define the child forms. Be sure to use the same keys here that were user in the 'order' section of this array.
    'forms' => array(
      'register' => array(
        'form id' => 'user_register_form'
      ),
      'groups' => array(
        'form id' => 'grasmash_registration_group_info_form',
        // Be sure to load the required include file if the form callback is not defined in the .module file.
        'include' => drupal_get_path('module', 'grasmash_registration') . '/grasmash_registration_groups_form.inc',
      ),
      'invite' => array(
        'form id' => 'grasmash_registration_invite_form',
      ),
    ),
  );

  // Make cached data available within each step's $form_state array.
  $form_state['signup_object'] = grasmash_registration_get_page_cache('signup');

  // Return the form as a Ctools multi-step form.
  $output = ctools_wizard_multistep_form($form_info, $step, $form_state);

  return $output;
}
?>

As you can see, our registration form will have threes steps:

  1. the default user registration form
  2. the groups form
  3. the invite form

These have been respectively titled "Register," "Connect," and "Invite."

You should also see that we have referenced a number of, as of yet, non-existent callback functions, as well as a cache retreival function. Let's talk about that cache function first, then look at the callbacks.

Data caching

Ctools provides a specialized Object Cache feature that allows us to store arbitrary, non-volatile data objects. We will use this feature to store user-submitted form values in between the form's multiple steps. Once the entire form has been completed, we will use that data for processing.

To efficiently utilize the Object cache, we will define a few wrapper functions. These wrapper function will be used to create, retreive, and destroy cache objects.

<?php
/**
 * Retreives an object from the cache.
 *
 * @param string $name
 *  The name of the cached object to retreive.
 */
function grasmash_registration_get_page_cache($name) {
  ctools_include('object-cache');
  $cache = ctools_object_cache_get('grasmash_registration', $name);

  // If the cached object doesn't exist yet, create an empty object.
  if (!$cache) {
    $cache = new stdClass();
    $cache->locked = ctools_object_cache_test('grasmash_registration', $name);
  }

  return $cache;
}

/**
 * Creates or updates an object in the cache.
 *
 * @param string $name
 *  The name of the object to cache.
 *
 * @param object $data
 *  The object to be cached.
 */
function grasmash_registration_set_page_cache($name, $data) {
  ctools_include('object-cache');
  $cache = ctools_object_cache_set('grasmash_registration', $name, $data);
}

/**
 * Removes an item from the object cache.
 *
 * @param string $name
 *  The name of the object to destroy.
 */
function grasmash_registration_clear_page_cache($name) {
  ctools_include('object-cache');
  ctools_object_cache_clear('grasmash_registration', $name);
}
?>

Submit callbacks

Now, we will define the various callbacks that were referenced in our $form_info array. These callbacks are executed when a user clicks the 'next', 'cancel', or 'finish' buttons in the multi-step form.

<?php
/**
 * Callback executed when the 'next' button is clicked.
 */
function grasmash_registration_subtask_next(&$form_state) {
  // Store submitted data in a ctools cache object, namespaced 'signup'.
  grasmash_registration_set_page_cache('signup', $form_state['values']);
}

/**
 * Callback executed when the 'cancel' button is clicked.
 */
function grasmash_registration_subtask_cancel(&$form_state) {
  // Clear our ctools cache object. It's good housekeeping.
  grasmash_registration_clear_page_cache('signup');
}

/**
 * Callback executed when the entire form submission is finished.
 */
function grasmash_registration_subtask_finish(&$form_state) {
  // Clear our Ctool cache object.
  grasmash_registration_clear_page_cache('signup');

  // Redirect the user to the front page.
  drupal_goto('<front>');
}
?>

Child Form callbacks

These forms comprise the individual steps in the multistep form.

<?php
function grasmash_registration_group_info_form($form, &$form_state) {
  $form['item'] = array(
    '#markup' => t('This is step 2'),
  );

  return $form;
}
function grasmash_registration_invite_form($form, &$form_state) {
  $form['item'] = array(
    '#markup' => t('This is step 3'),
  );

  return $form;
}
?>

You can use all of the magic of the Form API with your child forms, including separate submit and validation handlers for each step.

Integration with Drupal core user registration form

Now for the tricky part— we're going to override the Drupal core user registration form with our multistep ctools form, making user registration the first step.

We will do this by modifying the menu router item that controls the 'user/register' path via hook_menu_alter(). By default, the 'user/register' path calls drupal_get_form() to create the registration form. We're going to change that so that it calls our ctools multistep form callback instead.

Note, all hook implementations should be place in your .module file.

<?php
/**
 * Implements hook_menu_alter().
 */
function grasmash_registration_menu_alter(&$items) {
  // Ctools registration wizard for standard registration.
  // Overrides default router item defined by core user module.
  $items['user/register']['page callback'] = array('grasmash_registration_ctools_wizard');
  // Pass the "first" step key to start the form on step 1 if no step has been specified.
  $items['user/register']['page arguments'] = array('register');
  $items['user/register']['file path'] = drupal_get_path('module', 'grasmash_registration');
  $items['user/register']['file'] = 'grasmash_registration_ctools_wizard.inc';

  return $items;
}
?>

We will also need to define a new menu router item to handle the subsequent steps of our multistep form. E.g., user/register/%step:

<?php
/**
 * Implements hook_menu().
 */
function grasmash_registration_menu() {
  $items['user/register/%'] = array(
    'title' => 'Create new account',
    'page callback' => 'grasmash_registration_ctools_wizard',
    'page arguments' => array(2),
    'access callback' => 'grasmash_registration_access',
    'access arguments' => array(2),
    'file' => 'grasmash_registration_ctools_wizard.inc',
    'type' => MENU_CALLBACK,
  );

  return $items;
}
?>

Lastly, we need to make a slight alteration to the user_register_form. It will now have at least two submit handlers bound to it: user_register_submit, and ctools_wizard_submit. We need to make sure that the user_register_submit callback is called first!
``
/**
* Implements hook_form_FORM_ID_alter().
*/
function hook_form_user_register_form_alter(&$form, &$form_state) {
$form['#submit'] = array(
'user_register_submit',
'ctools_wizard_submit',
);
}
?>


That's it! You should now be able to navigate to user/register and see the first step of your multistep form. Subsequent steps will take you to user/register/[step-name]. Now, for a bonus snippet snack, here's how you can take the first step of your form and put it into a block! <h3>Displaying the first step of our form in a block</h3>

/**
* Implements hook_block_info().
*/
function grasmash_registration_block_info() {
$blocks['register_step1'] = array(
'info' => t('Grasmash Registration: Step 1'),
'cache' => DRUPAL_NO_CACHE,
);

return $blocks;
}
/**
* Implements hook_block_view().
*
* This hook generates the contents of the blocks themselves.
*/
function grasmash_registration_block_view($delta = '') {
switch ($delta) {
case 'register_step1':
$block['subject'] = 'Create an Account';
$block['content'] = grasmash_registration_block_contents($delta);
break;
}
return $block;
}
/**
* A module-defined block content function.
*/
function grasmash_registration_block_contents($which_block) {
global $user;
$content = '';
switch ($which_block) {
case 'register_step1':
if (!$user->uid) {
module_load_include('inc', 'grasmash_registration', 'grasmash_registration_ctools_wizard');
return grasmash_registration_ctools_wizard('register');
}
break;
}
}
?>
```

Good luck!

Feb 25 2013
Feb 25

Devit Admin
You have a live website and you need to copy a fresh version of the (live) database onto your local machine for development. Next, you need to run through one or more of these rote tasks:

  • Disable Drupal core caches (page cache, block cache, CSS & JS optimization, etc.)
  • Sanitize user data
  • Update Drupal's file system paths (public, private, tmp directories)
  • Enable email rerouting
  • Update logging and error level settings
  • Re-configure a contrib module. E.g., Secure Site (enable, set permissions, guest accounts).

Does this sound familiar? If so, I have good news! I've created a module that will help you automate that process.

Devit allows you to select or create tasks that should be run when your database needs to be deved. You can initiate these tasks via an administrative page, or via Drush.

Devit comes with a submodule that will provide you with the basic tasks, but it also provides an API that allows you to create your own tasks! Ideally, various contrib modules will be packaged with their own Devit tasks. For example:

  • Secure Site could have a Devit task that enables secure_site and sets default permissions, passwords, etc.
  • Advagg could have a task that disables all of its fancy features.
  • Features could have a task that performs a features revert and enables the Features UI module.

Choosing & executing Devit tasks

There are a few ways to choose and execute the selection devit tasks that you'd like to run.

  1. The devit_ui sub-module provides a user interface that allows administrators to choose and execute Devit tasks (image above).
  2. Your own, custom devit tasks can be defined with a default status of TRUE—these will run when your site is deved via Drush.
  3. A selection of Devit tasks can be defined in a devit.settings.php file. This option allows you to easily define a set of Devit tasks that are unique to your development environment. These settings will supersede the default status of your tasks, and the Devit configuration stored in your database. Must be executed via Drush.

This file must be placed in your site directory, e.g., /sites/default. It contains a simple, flat array keyed by task names:
// Set whether a task should be executed when Devit is run.
$tasks = array(
// Key rows by task name.
'clear-caches' => TRUE,
'update-file-paths' => FALSE,
);
?>

Here's a quick example of deving your site via Drush!

$ drush devit
Please enter the new password for all users [password]: new_pass
Set files directory path [sites/files]: new_files_dir
Files directory path changed to new_files_dir.                                                                         [success]
Site ready for development! 

Defining your own tasks

Devit provides an API that works much like the Menu API. The hook_devit_tasks() function allows you to define your own Devit tasks, and gives you the familiar framework to specify details like title, description, task callback, user access callback, file includes, etc.
Here's a quick example:

<?php
/**
 * Implements hook_devit_tasks().
 */
function yourmodule_devit_tasks() {
  $tasks = array();

  $tasks['disable-caching'] = array(
    'title' => t('Disable core caching features'),
    'description' => t('Disables page cache, page compression, block cache, and CSS & JS optimization.'),
    'task callback' => 'yourmodule_disable_caching',
    'access arguments' => 'administer site configuration',
  );

  return $tasks;
}

/**
 * Disables core caching.
 */
function yourmodule_disable_caching() {
  variable_set('cache', "0");
  variable_set('block_cache', "0");
  variable_set('preprocess_js', "0");
  variable_set('preprocess_css', "0");
  variable_set('page_compression', "0");
}
?>

Wrap Up

This module is still very much in development, but I'd like to gauge the level of interest in this tool. Would you use it? Do you have any ideas to contribute?

You could say things like:

  • "I want to help you!"
  • "Great idea, but you should consider..."
  • "Matt, this is a terrible idea because...".
  • "This module is like a beautiful snowflake."

Thanks!

Feb 23 2013
Feb 23

Writer Theme
My friend and co-acquian, Bryan Braun, recently released a new, beautiful, minimalist blogging theme on Drupal.org called Writer. I'll let Bryan make the official introduction:

"The story is quite simple. I am a front-end developer who blogs. I searched the Drupal theme repository, but I was unable to find a blogging theme designed specifically for developers. So I made one.

This theme was designed using three driving principles:

  • Brutally simple design
  • Fantastic typography
  • Support for code snippets

These principles guided me through the tradeoffs and helped me make various design decisions. Let's get into the details..."

For more information, take a look at Bryan's post on his blog, or visit Bryan's live demo of the theme.

If the pictures aren't enough to convince you to click that link, reflect on Bryan's closing words:

"There are many great Drupal developers who continue to use Garland or Bartik (the default Drupal themes) as the theme for their blog. These themes are fine, but they were never designed to be used for a blog (not to mention that some would say their overuse has made them tacky). I don't blame them too much... the alternative options are often bleak. Sometimes when I browse themes at Drupal.org, I feel like I'm browsing the web in 1994.

We developers deserve better."

Looks like my site might need an upgrade soon! Does yours?

Oct 18 2012
Oct 18

Drupal 7's Field API is amazing—it allows us to easily add fields to any type of entity, and customize those fields with various widgets and display formats. I'm going to walk you through two examples of how you can leverage the Field API to create your own custom field formats.

Example use cases:

  1. You're using the phone field to display phone numbers, but you'd like to customize the HTML output to make it mobile-compatible (click to call).
  2. Your nodes display full addresses via the addressfield module, but you'd like to render those addresses as google maps links.

Let's start at the beginning: you're going to be creating a custom module. Let's call is grasmash.module for vanity's (and sanity's?) sake.

We'll start by letting the Field API know that we have a new field format for it to play with.

<?php
/**
 * Implements hook_field_formatter_info().
 */
function grasmash_field_formatter_info() {
  return array(
    'grasmash_phone_mobile_call_link' => array(
      'label' => t('Mobile Call Link'),
      'field types' => array('phone_number'),
      'multiple values' => FIELD_BEHAVIOR_DEFAULT,
    ),
}
?>

What exactly did we do there? We told the Field API:

For any field of type phone_number, make available a new format called grasmash_phone_mobile_call_links with the noob name "Mobile Call Link." For fields with multiple values, just do your thing.

See hook_field_formatter_info() for more details.

Next, we'll let the Field API know what it should do when a user asks to view a field in this format. This is done via hook_field_formatter_view().

Given that we may want to define additional field formatters later, we're going to write something that will work for any custom formatter. Our implementation of hook_field_formatter_view() will cycle through all of the values for a given field (if it is multi-valued), and for each delta, it will generate markup by calling a theme() function that corresponds with the requested display format.

<?php
/**
 * Implements hook_field_formatter_view(). 
 * This code just passes straight through to a theme function.
 */
function grasmash_field_formatter_view($entity_type, $entity, $field, $instance, $langcode, $items, $display) {
  $elements = array();
  foreach ($items as $delta => $item) {
    $element = array('element' => $item, 'field' => $instance, 'display' => $display);
    $elements[$delta] = array(
      '#markup' => theme('grasmash_formatter_'. $display['type'], $element),
    );
  }
  return $elements;
}
?>

You can see that, for each field delta, this will call a theme function matching the naming convention "grasmash_formatter_FORMATTER_NAME." Currently, those theme functions exist only in our imaginations, so let's make them!

We'll start by telling the Theme API that we have a function named grasmash_formatter_FORMATTER_NAME:

<?php
/**
 * Implements hook_theme().
 */
function grasmash_theme() {
  return array(
    'grasmash_formatter_grasmash_phone_mobile_call_link' => array(
      'variables' => array('element' => NULL),
    ),
  );
}
?>

I'm going to take a brief moment to talk about the structure of hook_theme(), because when I first came across it, I found it confusing.

What exactly did we do there? We told the Theme API:

Whenever a function calls , look for a corresponding function named theme_grasmash_formatter_grasmash_phone_mobile_call_link($element). Furthermore, expect that one variable name 'element' will be along for the ride.

So, let's define that theme function!

<?php
/**
 * Theme function for grasmash_formatter_grasmash_phone_mobile_call_link.
 */
function theme_grasmash_formatter_grasmash_phone_mobile_call_link($element) {
  return '<a class="mobile-tel" href="http://matthewgrasmick.com/posts/field-api-creating-your-own-field-formatters/tel:' . $element['element']['number']  . '">Call</a>';
}
?>

Awesome! We can now select "Mobile Call Link" as a display format for our phone fields! Now that we've got the explanations out of the way, let's run through one more quick example: we will add a custom formatter that permits us to display addressfields as Google Maps links.

Add an additional row to the hook_field_formatter_info() array:

<?php
/**
 * Implements hook_field_formatter_info().
 */
function grasmash_field_formatter_info() {
  return array(
    'grasmash_phone_mobile_call_link' => array(
      'label' => t('Mobile Call Link'),
      'field types' => array('phone_number'),
      'multiple values' => FIELD_BEHAVIOR_DEFAULT,
    ),
    'grasmash_addressfield_full_inline_gmap_link' => array(
      'label' => t('Full Address Inline w/ Gmap Link'),
      'field types' => array('addressfield'),
      'multiple values' => FIELD_BEHAVIOR_DEFAULT,
    ),
}
?>

No need to worry about hook_field_formatter_view() again, since we built it to accomodate multiple formatters. So, let's add an additional row to our hook_theme() array:

<?php
/**
 * Implements hook_theme().
 */
function grasmash_theme() {
  return array(
    'grasmash_formatter_grasmash_phone_mobile_call_link' => array(
      'variables' => array('element' => NULL),
    ),
    'grasmash_formatter_grasmash_addressfield_full_inline_gmap_link' => array(
      'variables' => array('element' => NULL),
    ),
  );
}
?>

And lastly, let's define our theme function and build the markup:

<?php
/**
 * Theme function for grasmash_formatter_grasmash_addressfield_full_inline.
 */
function theme_grasmash_formatter_grasmash_addressfield_full_inline_gmap_link($element) {

  // Mimic addressfield_field_formatter_view().
  $handlers = array('address' => 'address');
  $context = array('mode' => 'render'); 
  $markup = addressfield_generate($element['element'], $handlers, $context);

  // Wrap markup in a link!
  $options = array(
    'query' => array('q' => $markup), 
    'attributes' => array(
      'class' => array('addressfield-gmap-link'),
    ),
  );
  $output = l($text, 'https://maps.google.com/maps', $options);

  return $output;
}
?>

Have fun!

Sep 17 2012
Sep 17

Just a quick snippet!

Dropping this in a custom module will allow you to easily index values from the Flag module's {flag_count} table in a Search API Solr index.

<?php
/**
 * Get the flag count for a given node.
 */
function mymodule_get_count($entity, $options, $name, $entity_type, &$info) {
  // Requiring type node since we're relying on $entity->nid,
  // but this could be used for user objects too.
  if ($entity_type == 'node') {
    $query = db_select('flag_counts' ,'fc');
    $query->fields('fc', array('count'));
    $query->condition('fc.fid', $info['data']['flag']->fid);
    $query->condition('fc.content_type', 'node');
    $query->condition('fc.content_id', $entity->nid);
    $count = $query->execute()->fetchColumn();
  }
  return !empty($count) ? $count : 0;
}

/**
* Implements hook_entity_property_info_alter().
*/
function mymodule_entity_property_info_alter(&$info) {
  if (isset($info['node']['bundles'])) {
    // For each content type.
    foreach ($info['node']['bundles'] as $bundle_type => $bundle) {
      // Find all applicable flags for this content type.
      $flags = flag_get_flags('node', $bundle_type);
      // For each applicable flag.
      foreach ($flags as $fid => $flag) {
        $info['node']['bundles'][$bundle_type]['properties']['flag_' . $flag->name . '_count'] = array(
          'label' => t('@title Flag Count', array('@title' => $flag->title)),
          'description' => t('The total number of @title flags for this node.', array('@title' => $flag->title)),
          'type' => 'integer',
          'getter callback' => 'mymodule_get_count',
          'computed' => TRUE,
          'data' => array('flag' => $flag),
        );
      }
    }
  }
}
?>

After placing this in a custom module enabling, just go to the 'fields' tab on the desired Search API Solr index and select the 'flag_type Flag Count' field with type integer.

Sep 10 2012
Sep 10

Everyone loves the Services module. It allows you to easily create API endpoints for Drupal's core features, permitting you to interact with Drupal from external applications. It also very easily extended.

Among its many default resources, Services offers a user.login method that allows external applications to authenticate via Drupal. I'm going to share a quick snippet that will permit you to extend Services so that it allows your users to login via Facebook (leveraging the FBOauth module).

Start by downloading and enabling Services and Fboauth. Then, create a custom module to house your code. Your module's info file should specify services and fboauth as requirements.

<?php
name = Services FBOauth
description = Provides FBOAuth integration for the Services module.
package = services
core = 7.x

dependencies[] = services
dependencies[] = fboauth
?>

Easy enough. Now let's use Service's hook_services_resources() function to define a new resource.

<?php
/**
 * Implements hook_services_resources().
 */
function services_fboauth_services_resources() {
  $definition['fboauth']['actions']['connect'] = array(
    'help' => 'Login a user for a new session via FBOAuth',
    'callback' => 'services_fboauth_connect',
    'args' => array(
      array(
        'name' => 'access_token',
        'type' => 'string',
        'description' => 'A valid Facebook access token',
        'source' => 'data',
        'optional' => FALSE,
      ),
    ),
    // The services module says this about services_access_menu: 
    // "If you think you need it you are almost certainly wrong."
    // But I think that this is one of those rare exceptions.
    'access callback' => 'services_access_menu',
  );

  return $definition;
}
?>

Here, we've defined a new resource type "fboauth." We've also defined a new action for that resource, labeled 'connect'. This will be available at /your_endpoint_path/fboauth/connect.

Now let's define the services_fboauth_connect() callback that makes the magic happen.

<?php
/**
 * Allow FBOAUTH login via services.
 *
 * @param $data,
 *   An associative array containing:
 *   - access_token: a valid Facebook access token (not access code).
 *     The requesting application must have already gone through the 
 *     process of requesting permissions, getting access code, requesting
 *     access token, etc.
 *
 * @return
 *   A valid session object, just like _user_resource_login().
 */
function services_fboauth_connect($data) {
  if ($user->uid) {
    // user is already logged in
    return services_error(t('Already logged in as @user.', array('@user' => $user->name)), 406);
  }

  // Include fboauth functions as required.
  module_load_include('inc', 'fboauth', 'includes/fboauth.fboauth');
  $access_token = $data['access_token'];
  $app_id       = variable_get('fboauth_id', '');

  // Find Drupal user that corresponds with this Facebook user.
  $fbuser = fboauth_graph_query('me', $access_token);
  $uid = fboauth_uid_load($fbuser->id);
  if ($user = user_load($uid)) {
    if ($user->status) {
      // Much of the login logic was taken from _user_resource_login().
      user_login_finalize();

      $return = new stdClass();
      $return->sessid = session_id();
      $return->session_name = session_name();

      services_remove_user_data($user);

      $return->user = $user;

      return $return;
    }
    else {
      $message = t('The username %name has not been activated or is blocked.', array('%name' => $account->name));
    }
  }
  else {
    $message = t('Error: no Drupal account was found for the specified Facebook user');
  }

  watchdog('services_fboauth', $message);
  return services_error($message, 401);
}
?>


To use this function, your external application must POST a valid 'access_token' to /your_endpoint_path/fboauth/connect. The endpoint will then return a valid session object, identical to the one posted by the default user.login method.

That's all there is to it!

Aug 24 2012
Aug 24

I recently began sending out mass emails via Drupal, and I was surprised to find that no universal solution existed for providing an "unsubscribe" link on Drupal emails. Sure, you can use Subscriptions to manage actual subscriptions to content, or Notifications for activity notification preferences, but what if you just want to unsubscribe from email communication in general? What if your Drupal admins periodically send out mass emails manually, and you'd like to unsubscribe from even those?

Now there's a simple way to let your users unsubscribe from any Drupal-originating email. Introducing the Unsubscribe module. This module provides a simple unsubscribe form, and an "unsubscribe from email communications" checkbox on the account settings page.

How does the unsubscribing work? It's pretty simple. When a user unsubscribes, they are added to an "unsubscribe list," which is stored in the database. Whenever Drupal sends mail, the unsubscribe module will check to see if the recipient is on the unsubscribe list. If so, the email is blocked.

You can exempt specific modules from this blocking via the unsubscribe module's configuration page. By default, the "user" and "system" modules are exempted— you still want people to be able to reset their passwords. You can also use hooks to alter the list of exemptions, or override the blocking via a more complex set of conditions.

Additionally, I've added integration with:

  • Core Actions
  • User Operations
  • Rules
  • Views

A bit of a disclaimer— this module is only about a week old. I have plans to add a number of additional features, including token integration and a slightly more robust API. But for now, feel free to download and try it out! If you run into a bug, post it in the issue queue. I should be fairly responsive until I'm confident in a tried-and-true, stable release.

Feature requests welcome.

Have fun!

Aug 01 2012
Aug 01

Learning to use the Views module from the front end of Drupal is a daunting task, but you can rest assured that many others struggle along with you. There are many tutorials and screencasts dedicated the the subject. Tackling the Views API from the backend, however, is a bit more of a challenge. It's not easy to document the behemoth that is Views, and hence, the documentation is limited.

This blog post is the first of a series that will explore the Views API from the backend-- from the code. Clearly, it's not going to be possible for me to give examples of all the various ways that you can integrate with Views. My goal is to provide you with a general understanding Views, and to give you the same tools that I use to tackle the beast. Namely, a methodology for figuring things out on your own. If you're a developer looking to integrate your module with Views, or if you'd like to build custom Views handlers for your site, then stay tuned.

To Begin

You will need to create a custom module that will house your custom code. I will refer to this as grasmash.module. Once you have your grasmash.info file and a brand, spanking new grasmash.module file ready to go, you'll want to:

Tell Views that your module will be using the Views API

/**
* Implements hook_views_api().
*/
function grasmash_views_api() {
return array(
'api' => 3,
'path' => drupal_get_path('module', 'grasmash') . '/views',
);
}
?>

This snippet of code tells Views that I'll be using version 3 of the Views API, and that it should look for Views-related files in sites/all/modules/grasmash/views. You can adjust the path accordingly, depending on where you'd like to store your custom Views files.

Views expects you to create a file named [your-module].views.inc, so we'll be creating grasmash.views.inc in sites/all/modules/grasmash/views.

Provide your custom data to Views

At its heart, Views is a query builder. It builds a database query, fetches a result, and renders it. So, our first step will be to tell Views where it can find our data. We accomplish this by using hook_views_data(). Start by dropping in a snippet like this:

grasmash.views.inc:
/**
* Implements hook_views_data().
*/
function grasmash_views_data() {

}
?>

hook_views_data() must return an array containing the tables you'd like to query, their fields, and the handlers that will handle the display, sorting, filtering, etc., of those fields.

Now let's find a few examples of hook_views_data() implementations. The best place to find those examples is in the Views module itself. After all, Views author Earl Miles had to integrate Views with core modules like Node, Comment, and User. To see how he did it, we're going to dig through the views/modules sub-directories. First up: the Node module, which is implemented with views/modules/node.views.inc.

Defining a base table to query:

/**
* Implements hook_views_data()
*/
function node_views_data() {
// ----------------------------------------------------------------
// node table -- basic table information.

// Define the base group of this table. Fields that don't
// have a group defined will go into this field by default.
$data['node']['table']['group'] = t('Content');

// Advertise this table as a possible base table
$data['node']['table']['base'] = array(
'field' => 'nid',
'title' => t('Content'),
'weight' => -10,
'access query tag' => 'node_access',
'defaults' => array(
'field' => 'title',
),
);
?>

As you can see, we deliver the data in the form that Drupal loves— arrays inside of arrays (in arrays). You may also notice that there are some helpful comments in this code. Did I add them? No. Views is loaded with great comments like this, you just need to do a bit of exploring to find them!

This snippet starts by telling Views that it should query a table called node. It does this by defining 'node' as the associative key in the $data array. Beneath $data['node'], it starts to get more specific. It indicates that, when {node} is used as a base table in a join, 'nid' should be used as the primary key. It also indicates the table, title, weight, default display field, etc.

The Views API allows you to pass all sorts of information about the data that you'll be querying, but it's not all necessary. For instance, it wasn't necessary for us to indicate the weight or default field for the {node} table. Which fields are necessary? That's a tough question, so I'm just going to say "it depends." You can answer the question best by continuing to look at examples and read the comments in code. Take a look at the following examples:

Joining a table:

// For other base tables, explain how we join
$data['node']['table']['join'] = array(
// this explains how the 'node' table (named in the line above)
// links toward the node_revision table.
'node_revision' => array(
'handler' => 'views_join', // this is actually optional
'left_table' => 'node_revision', // Because this is a direct link it could be left out.
'left_field' => 'nid',
'field' => 'nid',
// also supported:
// 'type' => 'INNER',
// 'extra' => array(array('field' => 'fieldname', 'value' => 'value', 'operator' => '='))
// Unfortunately, you can't specify other tables here, but you can construct
// alternative joins in the handlers that can do that.
// 'table' => 'the actual name of this table in the database',
),
);
?>

Defining a database field

// ----------------------------------------------------------------
// node table -- fields

// nid
$data['node']['nid'] = array(
'title' => t('Nid'),
'help' => t('The node ID.'), // The help that appears on the UI,
// Information for displaying the nid
'field' => array(
'handler' => 'views_handler_field_node',
'click sortable' => TRUE,
),
// Information for accepting a nid as an argument
'argument' => array(
'handler' => 'views_handler_argument_node_nid',
'name field' => 'title', // the field to display in the summary.
'numeric' => TRUE,
'validate type' => 'nid',
),
// Information for accepting a nid as a filter
'filter' => array(
'handler' => 'views_handler_filter_numeric',
),
// Information for sorting on a nid.
'sort' => array(
'handler' => 'views_handler_sort',
),
);
?>

Now that looked a bit frightening, but not all fields need quite so much detail. For example:
// changed field
$data['node']['changed'] = array(
'title' => t('Updated date'), // The item it appears as on the UI,
'help' => t('The date the content was last updated.'), // The help that appears on the UI,
'field' => array(
'handler' => 'views_handler_field_date',
'click sortable' => TRUE,
),
'sort' => array(
'handler' => 'views_handler_sort_date',
),
'filter' => array(
'handler' => 'views_handler_filter_date',
),
);
?>

Yet more simple:
$data['node']['path'] = array(
'field' => array(
'title' => t('Path'),
'help' => t('The aliased path to this content.'),
'handler' => 'views_handler_field_node_path',
),
);
?>

You probably get the idea. Most of the array keys make their purpose obvious, but you may still be wondering, "What exactly is a handler?" Great question.

Handlers

Views handlers tell views how it should handle the data that you pass it. After all, there are all sorts of data that you can have in your database. E.g., node ids, arbitrary integers, text strings, dates, etc. Depending on the type of data, you may want to view, sort, filter, or join the data in a different way.

Views comes with a large variety of default handlers located in the views/handlers directory. In most cases, these default handlers will cover the data types that you're working with. These are an excellent resource for learning. I highly recommend that you click through these files to get a sense of the available handlers and how they work.

Sometimes these default handlers aren't enough, and you'll need to write your own custom handler. So, let's talk briefly about their architecture. Handlers are defined using PHP Classes. This is a very good thing; if you'd like to create a custom handler, you can simply extend an existing handler class. There is no need to reinvent the wheel and specify independent methods for construction, rendering, querying, etc. You need only make the modifications or additions that are required for your custom handler— the parents class will take care of defining the rest. Yay for object oriented programming.

Note: for each of the handler examples below, I'll also show you the hook_views_data() array that helps Views find the handler class.

Filter Handler

In this example, we're going to create a Views filter for the Node Ownership module.

A little background information.

This module defines the {nodeownership} table, which contains a 'status' field (among others). The 'status' field will contain an integer with values 0, 1, or 2. These values correspond with the statuses pending, approved, and declined. I want users to be able to filter the nodeownership entities using a dropdown filter with these three options.

Let's start by seeing if there's already a good handler for this field. Browsing through the default views handlers, I see that the views_handler_filter_equality handler class does almost exactly what I need. I could just use it as my handler, but there's one problem— it doesn't know the correct corresponding labels for the status field's integer values. We'll have to make a custom handler to handle this.

First, let's tell Views about the status field in nodeownership.views.inc:
/**
* Implements hook_views_data()
*/
function nodeownership_views_data() {

// ----------------------------------------------------------------
// nodeownership table -- basic table information.
$data['nodeownership']['table']['group'] = t('Node ownership');

// Status.
$data['nodeownership']['status'] = array(
'title' => t('Status'),
'help' => t('The status of a given claim. E.g., pending, approved, or declined.'),
'filter' => array(
'handler' => 'views_handler_filter_nodeownership_status',
'label' => t('Status'),
'use equal' => TRUE,
),
);
?>

Notice that I defined a custom handler class of 'views_handler_filter_nodeownership_status.' That doesn't exist yet. Let's create a views_handler_filter_nodeownership_status.inc file and create that class. We've already determiend that the views_handler_filter_equality handler class does almost exactly what I need. So, let's create our new class by extending views_handler_filter_equality:
/**
* Simple filter to handle equal to / not equal to filters
*
* @ingroup views_filter_handlers
*/
class views_handler_filter_nodeownership_status extends views_handler_filter_equality {
}
?>

Now our new class has inherited everything that was in views_handler_filter_equality. Next, let's skim over the various methods that are contained in the parent class to determine which one we should override. After a quick perusal, It looks like value_form() is responsible for generating the actual exposed filter form. So let's copy, paste, and modify it!
/**
* Simple filter to handle equal to / not equal to filters
*
* @ingroup views_filter_handlers
*/
class views_handler_filter_nodeownership_status extends views_handler_filter_equality {

/**
* Provide a select list for value selection.
*/
function value_form(&$form, &$form_state) {
parent::value_form($form, $form_state);

$form['value'] = array(
'#type' => 'select',
'#title' => t('Status'),
'#options' => array(
0 => t('Pending'),
1 => t('Approved'),
2 => t('Declined'),
),
'#default_value' => $this->value,
'#required' => FALSE,
);
}
}
?>

I simply mapped the integer values to their corresponding labels, and we're set! Just clear the caches, and voila! I've got a new, working custom filter handler.

Field Handler

Using the same methodology, I can create a custom field handler with the same small tweak. First, add the information to hook_views_data():
/**
* Implements hook_views_data()
*/
function nodeownership_views_data() {

// ----------------------------------------------------------------
// nodeownership table -- basic table information.
$data['nodeownership']['table']['group'] = t('Node ownership');

// Status.
$data['nodeownership']['status'] = array(
'title' => t('Status'),
'help' => t('The status of a given claim. E.g., pending, approved, or declined.'),
'field' => array(
'handler' => 'views_handler_field_nodeownership_status',
'click sortable' => TRUE,
),
'filter' => array(
'handler' => 'views_handler_filter_nodeownership_status',
'label' => t('Status'),
'use equal' => TRUE,
),
);
?>

Then, create a new custom handler with an overridden method:
/**
* Field handler to present an 'accept' link for a given claim.
*
* @ingroup views_field_handlers
*/
class views_handler_field_nodeownership_status extends views_handler_field {
function render($values) {
$value = $this->get_value($values);
$status_map = array(
0 => t('Pending'),
1 => t('Approved'),
2 => t('Declined'),
);

return $status_map[$value];
}
}
?>

Pretty easy stuff!

The wrap up

That should give you a good sense of how Views finds and handles data. Experience is the best teacher, so I suggest that you jump in and try your hand at Views integration.

I will be posting a follow up article that goes a little bit further into Views by focusing on relationships, contextual filters (arguments), and query modification.

Until then, good luck and have fun!

Aug 01 2012
Aug 01

Here's a bit of an esoteric issue that was a bit tricky to hunt down. I hope that this blog post helps the few people out there that it applies to!

You've got a secure (https) Drupal site, and you'd like it to contain facebook-compatible, open graph meta tags. So, you download the meta tags module and do some configuration magic. Everything looks good until you post a page on Facebook, and the image doesn't work!

Apparently, Facebook doesn't play nice with og:image tags served over https.

Ok, so let's not serve our og:image tags over https. A quick theming hook will fix that in no time:
/**
* Theme callback for an OpenGraph meta tag.
*/
function THEMENAME_metatag_opengraph($variables) {
$element = &$variables['element'];
switch ($element['#name']) {
case 'og:image':
$element['#value'] = str_replace('https://', 'http://', $element['#value']);
break;
}

element_set_attributes($element, array('#name' => 'property', '#value' => 'content'));
unset($element['#value']);
return theme('html_tag', $variables);
}
?>

For most people, you can just clear your cache and you'll be set. However, I forgot about my htaccess rule that directed all http requests to https. If you have a similar rule, you'll need to make an exemption for file requests (or perhaps just file requests to a certain directory). For me, that looked something like:

RewriteCond %{HTTP:X-Forwarded-Proto} !=https
RewriteCond %{HTTP_HOST} ^www.obfuscate.com$ [NC]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ https://www.obfuscate.com/$1 [L,R=301]

An then it worked! Yay.

If you're still having trouble, checkout these useful tools:
Web sniffer -- See what the bots are seeing.
Facebook debugger -- get some insight from the FB developers!

May 15 2012
May 15

In Part 1: Connecting Facebook with Drupal, the easy way I extolled the wonders of Facebook OAuth and showed off its excellent API with an example of how to map facebook user data to Drupal user objects.

For part 2, I'm going to show you another side of the API. But before we get started, I'd like to give a quick shout-out to Nathan Haug (@quicksketch), author of Facebook OAuth. I'm about to take a lot of text directly from the Nate's well-written README.txt, so don't be confused by the liberally-applied quotation marks.

Writing custom Facebook OAuth Actions

"The Facebook OAuth module provides an API for executing queries against Facebook's vast store of user data." How does this translate into user experience (UX)? In extreme layman's terms, Facebook Oauth allows you to create custom buttons that, when clicked, will 1) grab some good facebook data, and 2) let Drupal interact with it. Sounds like fun, right?

Here are a few examples of things that you could do:

  • Create a post to facebook wall link on certain node types
  • Import a list of a user's facebook friends and expose it to Views (whoa!)
  • Import a user's facebook pictures and ... expose them to the Media module!? (that would be cool)

Hopefully you're starting to see how great this can be! Best of all, you can accomplish all of this without learning Facebook's Javascript SDK! Nate has streamlined the process of asking for facebook permissions, obtaining access tokens, etc. You just need to make a few Drupal hooks and you're off!

A simple example

To create a custom Facebook Oauth action, you'll need to drop a bit of code into a custom module. We'll start by telling Facebook Oauth that we have a new action that it should be aware of. This is done with hook_fboauth_actions().
/**
* Implements hook_fboauth_actions().
*/
function mymodule_fboauth_actions() {
// Give each action a unique key, such as "mymodule_photo_import" for a photo
// import. This function should begin with the name of your module.
$actions['mymodule_photo_import'] = array(
// Give you action a human-readable name. This will be used when showing
// the user a link to start this action.
'title' => t('Import my Facebook photos'),

// Specify the name of your callback function that contains the import.
'callback' => 'mymodule_fboauth_action_photo_import',

// Specify permissions you need to do this action. See the Facebook API for
// a list: http://developers.facebook.com/docs/authentication/permissions/
'permissions' => array(
'user_photos', // Gets access to a user's photos.
),

// Optionally specify a file that contains your callback function. If you
// put your callback function in the .module file, this is unnecessary.
// 'file' => 'mymodule.inc',

// Optionally define a theme function for printing out your link (not
// including the "theme_" prefix). If you use this option, you must register
// this function in hook_theme(). If you don't use this option, the link
// will be output with the theme_fboauth_action() function or the automatic
// suggestion theme_fboauth_action__[action_name]().
// 'theme' => 'mymodule_fboauth_action',
);
return $actions;
}
?>

Next, you'll need to actually create the mymodule_fboauth_action_photo_import() function specified in the above hook. Until now, we've engaged in what Jeff Eaton might refer to as "Wishful Programming," meaning that we make calls to functions that we wish existed. Let's make this function a reality.
/**
* Facebook OAuth action callback; Import a user's Facebook photos.
*/
function mymodule_fboauth_action_photo_import($app_id, $access_token) {
// Query against the Facebook Graph API. See the Facebook API for a list of
// commands: http://developers.facebook.com/docs/reference/api/
$result = fboauth_graph_query('me/photos', $access_token);
foreach ($result->data as $photo) {
// Do whatever you like with the photos!
}

// Optionally set a completion or error message.
drupal_set_message(t('Import complete!'));

// Optionally return a path to which the user will be redirected. If not set
// the path in the $_REQUEST['destination'] variable will be used. If there
// is no path at all specified, the user will be redirected to the homepage.
return 'mymodule/import-complete';
}
?>

"Now to get the user to actually execute this action, you need to link to Facebook so that the user can grant the necessary access. You can do this with the utility function fboauth_action_display(). Our example action was keyed as "mymodule_photo_import", so we would print the link like this:"

"Now when the user clicks on the output link, they will have the option of granting access to the requested information. If they approve, your callback function will be executed."

What's going on behind the scenes?

Before I go too much further, it may be good for us to go over exactly how Facebook Oauth is doing its magic. The README.txt does a great job of explaining this:

..in order to use this API it is important to understand the basic concepts of OAuth. In short, the user (and only the user) is capable of granting your site access to query information
against Facebook. The user is also only able to do this on Facebook.com, so any requests to query against Facebook must first redirect the user to Facebook where they can grant access. The full workflow looks like this:

  1. The user clicks on a link (such as the Facebook Connect button) that sends the user to Facebook. If the link is requesting permissions that the user has not yet granted, the user is prompted to allow access. After the user has granted access, or if the user granted access previously, the user is redirected back to your site.
  2. When the user is redirected back to your site, Facebook sends along an access "code". Your site then takes this access code and does a server-side request to Facebook's API servers. Facebook's servers return an access "token" to your server. This token is valid for a short amount of time and allows you to access the information to which the user granted you access.
  3. Your site can now execute queries against the user's Facebook information while the token is valid. Because this token only lasts a short amount of (about 6 hours usually), it's safest to always request access from Facebook before every data import session (by having the user click the link), which will renew the existing token or generate a new one.

Whew! Let it me said this really does happen behind the scenes. If the user has already granted the necessary permissions, they don't even see the redirect. Now let's dive back into the code.

Calling your custom actions programmatically

You may be tempted to ask, "But Matt, what if I don't want to require the user to click a button? Can I just fire my facebook action programmatically?" I'm glad you asked, because yes, you can! There's a fairly simple way to accomplish this.

// Extract the link from a given fboauth action.
$fb_link = fboauth_action_link_properties('my_custom_action');

// Extract the request url from a given fboauth action link, including the query parameters.
$fb_query_url = url($fb_link['href'], array('absolute' => TRUE, 'query' => $fb_link['query']));

// Redirect user to facebook for authorization.
drupal_goto($fb_query_url);
?>

When might you want to do use this method? I had two use cases for this approach:

  • Creating a menu callback that will execute an action.
  • Creating a $form['submit'][] handler that will execute an action.

Pushing data rather than pulling it

Facebook also offers a rich API for sending data back to Facebook. How can we utilize those features? It's not actually that hard. Let's take a look at how we can accomplish this by leveraging some of Facebook Oauth's built-in ability to communicate with Facebook.

The following snippet uses Facebook's Graph API to post to a user's Facebook wall.

function mymodule_fboauth_action_post_to_wall($app_id, $access_token) {
// Build the data array that we'd like to post to facebook.
// See https://developers.facebook.com/docs/reference/api/user/#posts for valid array keys.
$query = array(
'link' => $awesome_node,
'picture' => url(drupal_get_path('theme', 'my_theme') . '/images/logo.png', array('absolute' => TRUE)),
'name' => t('@name loves Drupal', array('@name' => $user->name)),
'caption' => t('ZOMG'),
'description' => t('This message was endorsed by Grasmash'),
'app_id' => variable_get('fboauth_id', ''),
);

$response = fboauth_graph_query('me/feed', $access_token, $query, 'POST');
if (isset($response->id)) {
drupal_set_message(t("You have posted to your facebook wall. That must have been hard. Take a break. Have drink. You're done."));
}
else {
watchdog('mymodule', 'Error executing fboauth action: @error', array('@error' => (isset($reponse->error) ? $response->error : t('Something went horribly wrong'))));
drupal_set_message(t("Oops! We couldn't post to your facebook wall. Try clicking harder."));
}

// Optionally redirect user.
return '';
}
?>

The Encore

The possibilities don't stop there! Facebook OAuth also provides:

  • alter hooks to modify default actions
  • save and pre-save hooks for facebook registrations
  • the ability to hook into the deauthorization process

How might that help you? Well, I used these hooks to integrate Facebook OAuth with the Invite module, such that Drupal can send invites via facebook and then react to fulfilled (facebook) invitations. I'm sure you'll think of great ways to use it too.

That's all folks! I hope these quick posts have been helpful for you. If you liked the article, mention me on twitter or /msg me on IRC — madmatter23.

May 13 2012
May 13
May 07 2012
May 07

There are a few heavyweight modules contending to provide Facebook integration for Drupal. Which module is right for you? Simply looking at the Reported installs and Maintenance Status won't present a clear answer.

Choices

A recent project of mine required the use of Facebook's OAuth service. Here are the three modules that I considered:

Decisions

I installed and configured each of these modules before coming to a decision. Here's a quick summary of what I found:

  • Drupal for Facebook
    is still very much in development. It isn't production ready, so if you're not willing to contribute, you may want to cross it off the list. Beyond that, it's important to realize that DFF's strength lies in providing a rough framework for using Drupal to build Facebook Applications. It is not ideal (or intended) for providing polished Facebook features to a pre-existing Drupal site. If you're looking for a good launch pad for some serious Facebook App development, give it a shot-- it may be just what you need. Otherwise, you may want to consider another module.

  • Facebook Connect
    provides a number of unique out-of-the-box Facebook features, like finding Facebook friends on your Drupal site, or publishing a customizable message on their Facebook feed. However, this module is still in beta— it is not in a stable state. It should also be noted that FC uses the Facebook Javascript SDK, which means that you need to be familiar with the SDK if you'd like to extend the module's functionality.

  • Facebook OAuth
    is best described in its author's own words, "This module is built with simplicity and flexibility in mind, it provides login services (and does it well), and an API for performing any other actions you may want to write yourself to query against Facebook's APIs."

The Winner (for me)

After attempting to extend Drupal for Facebook and Facebook Connect, Facebook OAuth was like a breath of fresh air. It's simple and flexible, and best of all, you don't need to learn Facebook's Javascript SDK to use it! Facebook OAuth has a well documented and well designed API that any PHP (particularly Drupal) programmer would be comfortable with. Some key features include:

  • One-click login through Facebook.
  • Automatic import of user e-mail and profile information during initial login.
  • A flexible and direct API for modules to get authenticated and query Facebook's APIs (plus extensive documentation).
  • Does not require any external libraries or downloads.

Harnessing the Power (the fun part)

Before I jump in, take note that you can find detailed information about all of FBOAuth's API functions in fboauth.api.php, which is bundled with the module. And don't forget to read the README.txt!

Now let's run through a quick example of how you can utilize the API.

Facebook OAuth allows you to import values from a user's facebook account and map them to that user's Drupal account during registration. In addition to providing a nice GUI for this mapping, it also allows you to define custom field types and callbacks for accomplishing the mapping and for processing the data. Most popular field types are supported by default.

For my site, I wanted to import a user's facebook address to an addressfield and geocode the location for storage in a geofield. By default, FBOAuth does not support mapping to fields of type addressfield. No problem! We'll use a few handy hooks to add that ability.

First, I want to let FBOAuth know that it can map Facebook location information to fields of type addressfield. I'll use hook_fboauth_user_properties_alter() to do that:
/**
* Implements hook_fboauth_user_properties_alter().
*/
function grasmash_fboauth_user_properties_alter(&$properties) {
// Allow the location property to be mapped to Addressfield typed fields.
$properties['location']['field_types'][] = 'addressfield';
}
?>

That was easy. Some detail:

  • The first array key 'location' specifies the Facebook source field. Change that to another acceptable value in order to map a different source field to a new field type.
  • You can add as many target 'field_types' as you'd like.
  • For a full list of possible source field values, just take a look at fboauth_user_properties().

Next, let's tell FBOAuth what it should do with data that's been mapped to an addressfield by defining a callback that will process the data.

/**
* Implements hook_fboauth_field_convert_info().
*/
function grasmash_fboauth_field_convert_info_alter(&$convert_info) {
$convert_info += array(
'addressfield' => array(
'label' => t('Address Field'),
'callback' => 'grasmash_fboauth_field_convert_location',
),
);
}
?>

If you're familiar with Drupal's array-heavy architecture, this snippet shouldn't surprise you at all. We're just adding another row to $convert_info. It specifies the name of the function that FBOAuth should look for when dealing with an addressfield. Now let's actually create that function!
/**
* Facebook data conversion function.
* Converts an incoming Facebook location (which is an object) into an array compatible with the addressfield module.
*/
function grasmash_fboauth_field_convert_location($facebook_property_name, $fbuser, $field, $instance) {
$value = NULL;
if ($field['type'] == 'addressfield' && isset($fbuser->location) && module_exists('geocoder')) {
$geodata = geocoder('google', $fbuser->location->name);
foreach ($geodata->data['geocoder_address_components'] as $key => $components) {
switch($components->types[0]) {
// Set city.
case 'locality':
$value['locality'] = $components->short_name;
break;
// Set state.
case 'administrative_area_level_1':
$value['administrative_area'] = $components->short_name;
break;
// Set country.
case 'country':
$value['country'] = $components->short_name;
break;
}
}
}
return $value;
}
?>
Clearly, this will be completely different for fields of any other type. The example above is specifically tailored for populating addressfield fields, and it relies entirely on the output returned by the geocoder module. If you're looking for some guidance in creating your own callback, just look at some of the default callbacks in fboauth.field.inc, like fboauth_field_convert_text().

The last step is to simply configure which field on the user object FBOAuth should actually import the data to. That is done through FBOAuth's admin page, located at admin/config/people/fboauth.

Other Awesome Things

From the README.txt:
"The Facebook OAuth module provides an API for executing queries against Facebook's vast store of user data." The README.txt goes on to explain the workflow for this process, and how you can write your own custom actions.

I plan to write a subsequent blog post about how you can programmatically execute these custom actions, but that's for another day!

Enjoy!

Mar 18 2012
Mar 18

This installment of the Migrate Classes series will give you a bit of sample code for migrating Content Profile nodes (D6) to Profile2 entities (D7).

Please note that this is not a tutorial for the Migrate module. If you'd like a detailed explanation of the Migrate API, please check either the examples in the Migrate module, or read this excellent blog post on the migrate module.

Here's a bare bones migration class that will query a base node table, join the necessary CCK field table, and migrate them into a Profile2 entity. Please note that you will require the Migrate Extras module to perform this migration.

You'll also notice that this migration depends on another migration, grasmashUser, which will first populate the {users} table. Please see BTMash's example module to see an example of a user migration class.

/**
* @file
* Examples and test fodder for migration into profile2 entities.
*/

class grasmashProfile2Migration extends Migration {
public function __construct() {
parent::__construct();
$this->description = t('Migration of customer profiles into profile2 entities');
$this->dependencies = array('grasmashUser');

$this->map = new MigrateSQLMap($this->machineName,
array(
'nid' => array(
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'description' => 'D6 Unique Node ID',
'alias' => 'n',
)
),
MigrateDestinationProfile2::getKeySchema()
);

$query = db_select(grasmash_MIGRATION_DATABASE_NAME . '.node', 'n')
->fields('n', array('nid', 'vid', 'type', 'language', 'title', 'uid', 'status', 'created', 'changed', 'comment', 'promote', 'moderate', 'sticky', 'tnid', 'translate'))
->condition('n.type', 'profile', '=');
$query->join(grasmash_MIGRATION_DATABASE_NAME . '.node_revisions', 'nr', 'n.vid = nr.vid');
$query->addField('nr', 'body');
$query->addField('nr', 'teaser');
$query->addField('nr', 'format');
$query->join(grasmash_MIGRATION_DATABASE_NAME . '.users', 'u', 'n.uid = u.uid');
$query->addField('u', 'name');
$query->leftJoin(grasmash_MIGRATION_DATABASE_NAME . '.content_type_profile', 'ctp', 'n.nid = ctp.nid AND n.vid = ctp.vid');
$query->addField('ctp','field_name_last');
$query->addField('ctp','field_name_first');
$query->addField('ctp','field_name_middle');
$query->addField('ctp','field_name_prefix');
$query->addField('ctp','field_name_suffix');
$query->orderBy('n.nid', 'ASC');

$source_fields = array(
'nid' => t('The node ID of the page'),
'uid' => t('The user ID of a user'),
'lid' => t('The location ID of a location instance'),
);

// Create a MigrateSource object, which manages retrieving the input data.
$this->source = new MigrateSourceSQL($query, $source_fields);
$this->destination = new MigrateDestinationProfile2('customer');

// Add a few field mappings.
$this->addFieldMapping('field_name_first', 'field_name_first');
$this->addFieldMapping('field_name_last', 'field_name_last');

// Unmapped destination fields
$this->addUnmigratedDestinations(array('id'));
$this->addUnmigratedSources(array('vid', 'type', 'language', 'moderate', 'tnid', 'translate', 'teaser', 'format', 'name'));
}
}
?>

Mar 11 2012
Mar 11

This article provides a step-by-step tutorial for creating a custom, multistep registration form via the Forms API in Drupal 7. For a Drupal 6 guide, I recommend Multistep registration form in Drupal 6.

Drupal 7's updated Form API makes the process of building multistep forms relatively painless. In combination with the excellent Examples for Developers module, it's really just a matter of copy, paste, and tweak.

We're going to be putting a slightly different spin on the standard approach to creating a multistep form.

  • We'll use at least one, pre-existing, system form— the user registration form.
  • We will incrementally trigger #submit handlers after each step, rather than waiting until the end to process all of the form values.
Note:

This code is based of of the example code from in form_example_wizard.inc in the Examples for Developers module. Please use that file as a reference for your own development, as it contains more detailed notes on the functions used here.

The Tut

First things first— create a new, empty, custom module. Since we're going to be overriding the default registration form, you'll want to modify the menu router item that controls the 'user/register' path. We're going to do this with hook_menu_alter().

The 'user/register' path already calls drupal_get_form() to create the registration form, so all we need to do is change which form it will be getting. The name of my form (and form building function) is grasmash_registration_wizard, so that's what we'll use.

<?php
/**
 * Implements hook_menu_alter().
 */
function grasmash_registration_menu_alter(&$items) {
  $items['user/register']['page arguments'] = array('grasmash_registration_wizard');
  return $items;
}
?>

Great. We'll get around to actually making that form in just a second. But first, let's set up a a function that will define the other forms that we'll be using. Each step of the multistep form will actually be using an independent form. We'll define those child forms here:

<?php
function _grasmash_registration_steps() {
  return array(
      1 => array(
        'form' => 'user_register_form',
      ),
      2 => array(
        'form' => 'grasmash_registration_group_info',
      ),
    );
}
?>

Note that we're going to be calling the default 'user_register_form' for the first step of the registration process. That will get the important things out of the way, freeing the user to abandon the subsequent steps without screwing everything up.

To create more steps, just add additional rows to that array.

Next, we'll build the parent form grasmash_registration_wizard. This will generate the 'next' and 'previous' buttons, take care of helping us move between steps, and attach the necessary #validate and #submit handlers to each of our child forms. You probably won't need to modify this function much, but you may want to at least change the drupal_set_title() parameters.

<?php
function grasmash_registration_wizard($form, &$form_state) {

  // Initialize a description of the steps for the wizard.
  if (empty($form_state['step'])) {
    $form_state['step'] = 1;

    // This array contains the function to be called at each step to get the
    // relevant form elements. It will also store state information for each
    // step.
    $form_state['step_information'] = _grasmash_registration_steps();
  }
  $step = &$form_state['step'];
  drupal_set_title(t('Create Account: Step @step', array('@step' => $step)));

  // Call the function named in $form_state['step_information'] to get the
  // form elements to display for this step.
  $form = $form_state['step_information'][$step]['form']($form, $form_state);

  // Show the 'previous' button if appropriate. Note that #submit is set to
  // a special submit handler, and that we use #limit_validation_errors to
  // skip all complaints about validation when using the back button. The
  // values entered will be discarded, but they will not be validated, which
  // would be annoying in a "back" button.
  if ($step > 1) {
    $form['prev'] = array(
      '#type' => 'submit',
      '#value' => t('Previous'),
      '#name' => 'prev',
      '#submit' => array('grasmash_registration_wizard_previous_submit'),
      '#limit_validation_errors' => array(),
    );
  }

  // Show the Next button only if there are more steps defined.
  if ($step < count($form_state['step_information'])) {
    // The Next button should be included on every step
    $form['next'] = array(
      '#type' => 'submit',
      '#value' => t('Next'),
      '#name' => 'next',
      '#submit' => array('grasmash_registration_wizard_next_submit'),
    );
  }
  else {
    // Just in case there are no more steps, we use the default submit handler
    // of the form wizard. When this button is clicked, the
    // grasmash_registration_wizard_submit handler will be called.
    $form['finish'] = array(
      '#type' => 'submit',
      '#value' => t('Finish'),
    );
  }

  $form['next']['#validate'] = array();  
  // Include each validation function defined for the different steps.
  // First, look for functions that match the form_id_validate naming convention.
  if (function_exists($form_state['step_information'][$step]['form'] . '_validate')) {
    $form['next']['#validate'] = array($form_state['step_information'][$step]['form'] . '_validate');
  }
  // Next, merge in any other validate functions defined by child form.
  if (isset($form['#validate'])) {
    $form['next']['#validate'] = array_merge($form['next']['#validate'], $form['#validate']);
    unset($form['#validate']);
  }


  // Let's do the same thing for #submit handlers.
  // First, look for functions that match the form_id_submit naming convention.
  if (function_exists($form_state['step_information'][$step]['form'] . '_submit')) {
    $form['next']['#submit'] = array_merge($form_state['step_information'][$step]['form'] . '_submit', $form['next']['#submit']);
  }
  // Next, merge in any other submit functions defined by child form.
  if (isset($form['#submit'])) {
    // It's important to merge in the form-specific handlers first, before 
    // grasmash_registration_wizard_next_submit clears $form_state['values].
    $form['next']['#submit'] = array_merge($form['#submit'], $form['next']['#submit']);
    unset($form['#submit']);
  }

  return $form;
}
?>

Next, we're going to lift the 'next' and 'previous' submit handler functions directly from the example module. There's no need to modify these (unless you really want to).

<?php
/**
 * Submit handler for the "previous" button.
 * - Stores away $form_state['values']
 * - Decrements the step counter
 * - Replaces $form_state['values'] with the values from the previous state.
 * - Forces form rebuild.
 *
 * You are not required to change this function.
 *
 * @ingroup form_example
 */
function form_example_wizard_previous_submit($form, &$form_state) {
  $current_step = &$form_state['step'];
  $form_state['step_information'][$current_step]['stored_values'] = $form_state['values'];
  if ($current_step > 1) {
    $current_step--;
    $form_state['values'] = $form_state['step_information'][$current_step]['stored_values'];
  }
  $form_state['rebuild'] = TRUE;
}

/**
 * Submit handler for the 'next' button.
 * - Saves away $form_state['values']
 * - Increments the step count.
 * - Replace $form_state['values'] from the last time we were at this page
 *   or with array() if we haven't been here before.
 * - Force form rebuild.
 *
 * You are not required to change this function.
 *
 * @param $form
 * @param $form_state
 *
 * @ingroup form_example
 */
function form_example_wizard_next_submit($form, &$form_state) {
  $current_step = &$form_state['step'];
  $form_state['step_information'][$current_step]['stored_values'] = $form_state['values'];

  if ($current_step < count($form_state['step_information'])) {
    $current_step++;
    if (!empty($form_state['step_information'][$current_step]['stored_values'])) {
      $form_state['values'] = $form_state['step_information'][$current_step]['stored_values'];
    }
    else {
      $form_state['values'] = array();
    }
    $form_state['rebuild'] = TRUE;  // Force rebuild with next step.
    return;
  }
}
?>

Now it's time to build the child forms. In the case of 'user_register_form', the form already exists. Since we aren't building it, we'll have to use hook_form_alter() to make the necessary modifications. We can target the user_register_form in specific by requiring that $form_state['step'] == 1.

<?php
function grasmash_registration_form_alter(&$form, &$form_state, $form_id) {
  if ($form_id == 'grasmash_registration_wizard' && $form_state['step'] == 1) {
    // Clean up the form a bit by removing 'create new account' submit button
    // and moving 'next' button to bottom of form.
    unset($form['actions']);
    $form['next']['#weight'] = 100;
  }
}
?>

That takes care of step one. Now I'll build the form for step 2. This is where most of your modifications will come in to play.

<?php
/**
 * Build form elements for step 2.
 */
function grasmash_registration_group_info(&$form, &$form_state)  {

    $form['create-group'] = array( 
      '#type' => 'fieldset',
      '#title' => t('Create a new community.'),
    );

    // Allow users to create a new organic group upon registration.
    $form['create-group']['group-name'] = array(
      '#type' => 'textfield',
      '#title' => t('Community name'),
      '#description' => t('Please enter the name of the group that you would like to create.'),
      '#required' => TRUE,
      '#weight' => -40,
    );

    $form['#validate'][] = 'grasmash_registration_validate_og';
    $form['#submit'][] = 'grasmash_registration_new_og';

    return $form;
}
?>

I'm not going to be including the step 2 submit or validate handlers in this tutorial, since they are specific to my current project. The important point is that for each step, you can alter or build any form you'd like.

Lastly, we'll define the final #submit function. This will be run when the user completes the last step of the form. You'll probably want to display a message and issue a redirect.

<?php
// And now comes the magic of the wizard, the function that should handle all the
// inputs from the user on each different step.
/**
 * Wizard form submit handler.
 * - Saves away $form_state['values']
 * - Process all the form values.
 *
 * This demonstration handler just do a drupal_set_message() with the information
 * collected on each different step of the wizard.
 *
 * @param $form
 * @param $form_state
 *
 * @ingroup form_example
 */
function form_example_wizard_submit($form, &$form_state) {
  $current_step = &$form_state['step'];
  $form_state['step_information'][$current_step]['stored_values'] = $form_state['values'];

  // In this case we've completed the final page of the wizard, so process the
  // submitted information.
  drupal_set_message(t('This information was collected by this wizard:'));
  foreach ($form_state['step_information'] as $index => $value) {
    // Remove FAPI fields included in the values (form_token, form_id and form_build_id
    // This is not required, you may access the values using $value['stored_values']
    // but I'm removing them to make a more clear representation of the collected
    // information as the complete array will be passed through drupal_set_message().
    unset($value['stored_values']['form_id']);
    unset($value['stored_values']['form_build_id']);
    unset($value['stored_values']['form_token']);

    // Now show all the values.
    drupal_set_message(t('Step @num collected the following values: <pre>@result</pre>', array('@num' => $index, '@result' => print_r($value['stored_values'], TRUE))));
  }
  // Redirect the new user to their user page.
  $user = user_load($form_state['uid']);
  drupal_goto('user/' . $user->uid);
}
?>

Not too bad! This structure will allow you to easily add new steps using existing or custom forms, and to easily add #submit and #validate handlers at any step in the process.

Closing Thoughts

The method above works well, but I would be remiss if I didn't mention Ctools built-in multi-step form wizard. In addition to providing a framework for multi-step forms, Ctools also offers an excellent method for caching data in between steps. I may write an article on implementing this API as well. For now, take a look at the help document packaged with ctools in ctools/help/wizard.html. You may also want to take a look at this tutorial on using the ctools multi-step form wizard.

Update

I've written a follow up to this article, explaining Building a Multistep Registration Form in Drupal 7 using Ctools.

Good luck!

Feb 27 2012
Feb 27

I've just begun to tackle migrating a site from Drupal 6 to Drupal 7 via V2 of the Migrate module. It's truly an excellent module, but like most things Drupal, it has a steep learning curve. To help offset that slope, I plan to post migration snippets on this blog throughout the next few weeks. The snippets should help to serve as starting points for the many different migration scenarios that you all may encounter. Here is a short list of tentatively planned examples:

  1. Migrating from Location CCK fields (i.e. mapadelic) to Address Fields.
  2. Migrating from Content Profile nodes to Profile2 entities.
  3. Migrating Organic Groups memberships

Please note that this is not a tutorial for the Migrate module. If you'd like a detailed explanation of the Migrate API, please check either the examples in the Migrate module, or read this excellent blog post on the migrate module.

First up, Location CCK to Address Field!

<

p>It seems that the Address Field module has taken a strong lead in the realm on Drupal geolocation, so after a bit of research, I've decided to jump on the bandwagon. Here's a bare bones migration class that will query a base node table, join the necessary mapadelic location tables, and migrate them into an address field. Please note that you will require the Migrate Extras module to perform this migration.

class grasmashNodeSericeProviderMigration extends Migration {
public function __construct() {
parent::__construct();

$this->description = t('Migrate location-based nodes.');
$this->dependencies = array('grasmashUser');

$this->map = new MigrateSQLMap($this->machineName,
array(
'nid' => array(
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'description' => 'D6 Unique Node ID',
'alias' => 'n',
)
),
MigrateDestinationNode::getKeySchema()
);

$query = db_select(GRASMASH_MIGRATION_DATABASE_NAME . '.node', 'n')
->fields('n', array('nid', 'vid', 'type', 'language', 'title', 'uid', 'status', 'created', 'changed', 'comment', 'promote', 'moderate', 'sticky', 'tnid', 'translate'))
->condition('n.type', 'service_provider', '=');
$query->join(GRASMASH_MIGRATION_DATABASE_NAME . '.node_revisions', 'nr', 'n.vid = nr.vid');
$query->addField('nr', 'body');
$query->addField('nr', 'teaser');
$query->addField('nr', 'format');
$query->join(GRASMASH_MIGRATION_DATABASE_NAME . '.users', 'u', 'n.uid = u.uid');
$query->addField('u', 'name');
$query->leftjoin(GRASMASH_MIGRATION_DATABASE_NAME . '.location_instance', 'li', 'n.nid = li.nid AND n.vid = li.vid');
$query->addField('li', 'lid');
$query->leftjoin(GRASMASH_MIGRATION_DATABASE_NAME . '.location', 'l', 'li.lid = l.lid');
$query->addField('l', 'street');
$query->addField('l', 'additional');
$query->addField('l', 'city');
$query->addField('l', 'province');
$query->addField('l', 'postal_code');
$query->addField('l', 'country');
$query->addField('l', 'latitude');
$query->addField('l', 'longitude');
$query->orderBy('n.nid', 'ASC');

$source_fields = array(
'nid' => t('The node ID of the page'),
'uid' => t('The user ID of a user'),
'lid' => t('The location ID of a location instance'),
);

$this->source = new MigrateSourceSQL($query, $source_fields);

$this->destination = new MigrateDestinationNode('service_provider');

// Assign mappings TO destination fields FROM source fields.
$this->addFieldMapping('is_new')->defaultValue(TRUE);
$this->addFieldMapping('title', 'title');
$this->addFieldMapping('nid', 'nid');
$this->addFieldMapping('uid', 'uid');
$this->addFieldMapping('revision')->defaultValue(TRUE);
$this->addFieldMapping('revision_uid', 'uid');
$this->addFieldMapping('created', 'created');
$this->addFieldMapping('changed', 'changed');
$this->addFieldMapping('status', 'status');
$this->addFieldMapping('promote', 'promote');
$this->addFieldMapping('sticky', 'sticky');
$this->addFieldMapping('comment', 'comment');
$this->addFieldMapping('path', 'url_alias');
$this->addFieldMapping('language')->defaultValue(LANGUAGE_NONE);

// Map to addressfield. See addressfield.inc in migrate_extras for available argument keys.
$arguments = array(
'thoroughfare' => array('source_field' => 'street'),
'premise' => array('source_field' => 'additional'),
'locality' => array('source_field' => 'city'),
'administrative_area' => array('source_field' => 'province'),
'postal_code' => array('source_field' => 'postal_code'),
'first_name' => array('source_field' => 'field_name_first'),
'last_name' => array('source_field' => 'field_name_last'),
);
// Note that of the country field is NULL, none of the values will be migrated!
$this->addFieldMapping('field_address', 'country')->arguments($arguments);

// Since the excerpt is mapped via an argument, add a null mapping so it's not flagged as unmapped.
$this->addFieldMapping(NULL, 'street');
$this->addFieldMapping(NULL, 'additional');
$this->addFieldMapping(NULL, 'city');
$this->addFieldMapping(NULL, 'province');
$this->addFieldMapping(NULL, 'postal_code');
$this->addFieldMapping(NULL, 'country');
$this->addFieldMapping(NULL, 'latitude');
$this->addFieldMapping(NULL, 'longitude');
$this->addFieldMapping(NULL, 'lid');

// The body text.
$body_arguments = MigrateTextFieldHandler::arguments(array('source_field' => 'teaser'), array('source_field' => 'format'));
$this->addFieldMapping('body', 'body')->arguments($body_arguments);

// Unmapped source fields
$this->addUnmigratedSources(array('vid', 'type', 'language', 'moderate', 'tnid', 'translate', 'teaser', 'format', 'name'));
}

public function prepareRow($current_row) {
// Set the text format for the node.
$current_row->format = 'wysiwyg_ckeditor';
return TRUE;
}
}
?>

In addition to creating this migration class, I also added a quick strtoupper() wrapper to line 56 of addressfield.inc file, packaged with the migrate_extras module:
$return[$language][$delta] = array('country' => strtoupper($value)) + array_intersect_key($arguments, $field_info['columns']);
?>

I'm fairly certain that this can be better accomplished with migrate's prepareRow() method, but I haven't had time to fool with it yet.

Good luck!

Jan 24 2012
Jan 24

The Scenario

You're building a new Drupal site that needs to handle two distinct types of users: Consumers and Service Providers. Each user group must have a unique role, profile type, and registration page. Users of each type should be able to visit your site, find the correct registration page, fill out their profile, and be granted an account with the correct role. Sounds easy, right?

Well, I recently found myself in this exact scenario. I was surprised to find that no combination of modules would exactly fit these requirements. This seemed like a great opportunity to build a legitimate, contributed module. Here it is: Profile2 Registration Path.

The purpose of this blog post is to tell a bit of that story, but more importantly, to show you how to do use it!

The Journey

Jump to the Quick Step-by-step (TLDR)

Drupal 7 core provides us with an easy means of adding fields to the user entity. Unfortunately, using the core method requires that we make said fields available to all users. That's not very useful for our current requirements. Enter Profile2.

Designed to be the successor of the core profile module, Profile2 gives us a great starting point. Let's take a quick snippet from the Profile2 project description:

  • With profile2 user account settings and user profiles are conceptually different things, e.g. with the "Profile pages" module enabled users get two separate menu links "My account" and "My profile".
  • Profile2 allows for creating multiple profile types, which may be assigned to roles via permissions (e.g. a general profile + a customer profile)
  • Profile2 supports private profile fields, which are only shown to the user owning the profile and to administrators.

Awesome. Now we can create a Consumer profile type and a Service Provider profile type. We're all set, right? Not quite. We need to make sure that our users can easily fill out these profiles during registration, and that they are granted the correct user roles after signing up.

The Problem with Profile2

By default, the Profile 2 module permits you to add fields from each profile type to the default user registration form. Unfortunately, there is only one user registration form. Thus, during registration, every user will be presented with fields from all of the selected profiles. If you have two profile types targeted at two different audiences, you cannot have two separate registration forms. Bummer.

The Solution

Profile2 Registration Path enables you to set a unique registration path for each Profile2 profile type. Users who register via that unique path will be presented with fields from the specified profile type(s), and may have corresponding roles assigned to them upon account creation. Yay.

Here's a quick list of features taken from the Profile2 Registration Path project page:

  • Each registration path can be assigned roles that will be granted to users upon registration
  • Multiple profile types can be assigned to a shared registration path.
  • Profile types can be attached to the core 'user/register' page (while still maintaining other, unique registration paths).

So now that we have all of the tools that we need, how do we put it all together?

The Quick Step-by-step (TLDR)

  1. Download Profile2
  2. Download Profile2 Registration Path
  3. Enable the modules
  4. Create the 'consumer' and 'service provider' roles
  5. Go to admin/structure/profiles and add a new profile type named 'consumer'
  6. Check 'Enable unique registration path'
  7. Enter a URL path to use for this profile type, e.g., 'consumer/register'
  8. Select the role(s) that you would like to apply to users registering from this path. In this case, 'consumer'
  9. Add a few fields to the profile type
  10. Create additional profile types as needed
  11. Configure your permissions so that the correct roles can edit the correct profile types. E.g., only users with role 'consumer' can edit profiles of type 'consumer'

That should do it! You now have a site that can truly support the organic growth of multiple, distinct user groups.

After-thoughts

I initially tried to collaborate with the maintainers of the Auto Assign Role module, which was my go-to Drupal 6 solution for this scenario. I submitted a patch here that would introduce a hook, which would in turn permit the integration of other modules with AAR. Unfortunately, nothing came of it. I still think this is a good idea, so feel free to petition for it!

I'm working on adding more features to Profile2 Registration Path, including:

  • Unique registration blocks
  • Per-profile page titles for the login, registration, and forgot password pages (implemented in dev)

Of course, all feature requests and code contributions are welcome!

Happy coding.

Dec 29 2011
Dec 29

It can be difficult to remember all of the usernames and passwords that you use to log in to various websites across the internet, so why force users to create a new username for your web site? It's easier on everyone to simply combine the username and email address fields. It also cleans up your registration form a bit.

In Drupal, there are two modules that can help you to accomplish this:

These two modules are mutually exclusive— they are not compatible with each other, so you'll have to pick one. I prefer to use Email Registration, and I'll explain why.

Email Registration

Email Registration simplifies the user registration form by removing the 'username' field. By default, the module will automatically generate a username for a new user based upon the first part (before the @) of his/her email address. For example, if I registered for a new account with email address [email protected], I would end up with username madmatter23.

That's great, but it's not exactly what I'd like. I'd like my username to be my full email address. Luckily, Email Registration provides a hook_email_registration_name() to let you customize exactly how the username will be generated. I used the hook in this way:
/*
* Implements hook_email_registration_name().
*/
function grasmash_email_registration_name($edit, $account) {
return $account->mail;
}
?>

Adding a similar snippet to your own custom module will give you complete control. That's all there is to it!

LoginToboggan

This module still forces users to choose their own username during registration. However, after they're registered, it will allow them to login using either their username or their email address. Hence my preference for Email Registration.

LoginToboggan also has a number of other nice features, such as redirecting users after registration or login, and providing a login form on the 403 Access Denied page. If you really need one of these features, there are a number of alternative modules that provide the same functionality:

Final Thought

Using this method does have at least one major drawback: you no longer have the option to display a user's username while preserving the privacy of their email address. However, this problem can easily be circumvented by simply using an optional 'alias' field, or by utilizing a programmatically applied handle for users based on other field values.

Dec 23 2011
Dec 23

As of Drush 4.5, migrating a Drupal site between servers became much easier. The new, little-known drush archive-dump and drush archive-restore commands make it an essentially three step process.

Overview

A basic Drupal site is made of two fundamental elements: the codebase and the database. When you migrate a Drupal site, you need to migrate both of these elements, often with a bit of re-configuration to boot.

The Old Way

Before using Drush to migrate a site, my standard procedure for site migration looked something like this:

Database Migration

  1. create a new, empty database
  2. create a new user
  3. associate the new user with the new database (with correct permissions)
  4. create a db dump from the source environment
  5. import the db dump to the target environment

Codebase Migration

  1. compress the codebase into a tar.gz file
  2. transfer files
  3. unarchive tarball
  4. edit the settings.php file to reflect the new database settings
  5. check file ownership, group membership, and permissions

The New Way (for me)

I'll run through the first iteration using psuedo [tokens] as placeholder for file and sitenames.

Open up the command line on the source machine (or SSH in) and cd to your Drupal installation.

  1. Create a Drush archive dump (code and db in one)
    drush archive-dump [site]
  2. Transfer the dump to your target machine (clearly, you could just FTP it)
    scp [new-backup-archive] [user]@[target-machine]:[target-folder]
  3. Now switch to the command line on your target machine, cd to the folder above your to-be-created Drupal installation and type:
    drush archive-restore [new-backup-archive] [site] --destination=./[new-folder-name] --db-url=mysql://[msql-user]:[mysql-password]@[target-server]/[db-name]

Drush will then create the new directory, unarchive your tarball into it, create a new database, populate it, and edit your settings.php file accordingly. Wow. Awesome.

Here's how the process looks for me when moving to my local MAMP stack (without tokens):

  1. drush archive-dump default
  2. scp /home/grasmash/drush-backups/archive-dump/20111256023713/grasmash.20116223_023613.tar.gz [email protected][my-ip]:/Applications/MAMP/htdocs
  3. drush archive-restore grasmash.20116223_023613.tar.gz default --destination=./grasmash --db-url=mysql://root:[email protected]/grasmash

Now it's important to note that you can use an existing directory or an existing database. If you'd like the contents of the existing directly overwritten, you can use the --overwrite flag. Likewise, you can grant Drush sudo MYSQL privileges to modify your dbs by adding a --db-su flag, followed by the correct credentials.

Furthermore, you don't need to use --destination or --db-url flags at all. If you leave these out, Drush will attempt to unarchive to the current working directory, and will attempt to use the database credentials defined in the settings.php file.

For more information on these commands, use drush's --help flag, e.g., drush archive-restore --help

Enjoy!

Quick disclaimer:  There are many ways to migrate a Drupal site between servers. You should certainly be using SCM (like git), which can be used to move the codebase. You can use drush or backup_migrate to transfer the database. Or, you could simply move around Virtual Machines.

EDIT
At present, it seems that the --db-url flag only works with Drupal 6. Please leave a comment and let me know if this is not the case!

Dec 15 2011
Dec 15

By default, Drupal's core field.module with add 'odd' and 'even' classes to your field items, but it won't add 'first' and 'last' classes! This can quickly be remedied by overriding the core field.tpl.php file with your own custom one.

I'd recommend first copying /modules/field/theme/field.tpl.php into your site's theme folder. For me, the destination was /sites/all/themes/grasmash/templates/field.tpl.php. Then clear your caches. This will force Drupal to check for new template files in your theme's directory, and select the new field.tpl.php as the prioritized template for generating fields.

Here's the business part of my customized field.tpl.php file:

>

>

>
$item): ?>

>

There are two important changes here:

  • counts the total number of field items in each field, allowing us to determine which field item should be given the 'last' class
  • will print out our new sparkly classes.

You should note that this may add a small performance tax to your site by requiring PHP to count the $items array for every field, but I think that it is fairly negligible. Some people would prefer not to include this kind of logic in a template file at all. Let me know if you've got a better way!

Dec 08 2011
Dec 08

Drupal 7's new and improved Form API makes using AJAX a breeze. It enables you to add, replace, or remove form elements via AJAX without ever really having to get your hands dirty.

I recently had the opportunity to give it a whirl. Here's a the deal:
I have content type Resume and Profile2 type Job Seeker Profile. I'd like my users to be able to create up to 5 resumes, and I'd like them to have the option of importing information from their profile into their brand-spanking new resume. So, I'm going to create an 'Import from profile' button that will populate the resume fields with profile field values via AJAX.

Note: It is possible to accomplish something like this by setting default values via a token. However, in this case, I'd like to be able to import things like field collections, addresses, and fields with unlimited cardinality. Here's the code that accomplished it!

/**
* Implements hook_form_FORM_ID_alter().
* Resume node form.
*/
function grasmash_form_resume_node_form_alter(&$form, &$form_state, $form_id) {
// Load user's current profile.
$uid = $form['uid']['#value'];
$author = user_load($uid );
$profile = profile2_load_by_user($author, 'seeker_profile');

// Add button permitting users to import Work Experience from their Profile.
if (array_key_exists('und', $profile->field_work_experience)) { // It's good to first check that there's actually something to import.
// Wrapping the element with HTML lets us easily define the scope of the AJAX replacement later.
$form['field_work_experience']['#prefix'] = '

';
$form['field_work_experience']['#suffix'] = '

';

$form['field_work_experience']['import_work_experience'] = array(
'#type' => 'submit',
'#value' => 'Import work experience from your profile',
'#description' => t('This will overwrite anything you have entered into the Work Experience field!'),
'#weight' => -20,
'#attributes' => array('class' => array('field-add-more-submit')),
'#limit_validation_errors' => array(), // Don't validate when the button is clicked!
'#ajax' => array(
'callback' => 'grasmash_js_import_work_experience',
'wrapper' => 'field-work-experience-wrapper',
'effect' => 'fade',
'method' => 'replace',
),
);
}
}

/*
* Define AJAX return functions for Work Experience field.
*/
function grasmash_js_import_work_experience($form, $form_state) {
$field_collection = 'field_work_experience';
// Load the correct field collection using another function. This approach allows us to easily repeat this process with another field collection if desired.
$element = grasmash_js_import_field_collection($field_collection, $form, $form_state);
return $element;
}

/*
* Import pre-populated field_collection form element from a user's job seeker profile.
*/
function grasmash_js_import_field_collection($field_collection, &$form, &$form_state) {

// Load author profile if necessary.
$uid = $form['uid']['#value'];
$author = user_load($uid);

//module_load_include('inc', 'profile2_page', 'profile2_page'); // loading with hook_init instead
$profile2 = profile2_by_uid_load($uid, 'seeker_profile');
$profile2_form = entity_ui_get_form('profile2', $profile2, 'edit');

if (isset($profile2_form)) {
// Pull in corresponding form element from profile form.
$element = $profile2_form['profile_seeker_profile'][$field_collection];

// Process the imported element.
$element = grasmash_process_ajax_import($element);

return $element;
}

}

/**
* Recursively removes profile2 field prefixes from each array row.
* This ensures that all input names on the destination form are correct.
*/
function grasmash_process_ajax_import($element){
foreach ($element as $key => $value){
if (is_string($element[$key]) && !is_numeric($element[$key])){
$element[$key] = preg_replace('@(profile_seeker_profile){1}(\[){1}([^\]]+){1}(\]){1}@','$3$5', $element[$key]);
$element[$key] = str_replace('profile-seeker-profile-','', $element[$key]);
$element[$key] = str_replace('profile_seeker_profile_','', $element[$key]);
if ($element[$key] == 'profile_seeker_profile') {
unset($element[$key]);
}
}
elseif (is_array($element[$key])){
$element[$key] = grasmash_process_ajax_import($element[$key]);
}
}
return $element;
}
?>

Dec 04 2011
Dec 04

The Views Slideshow module makes the process of creating a slideshow in Drupal extremely easy, but I've always found one important feature to be missing: a fullscreen setting.

Here is a script that take a slide image (or any HTML element for that matter) and set it to be the size of the viewer's browser window (preserving the aspect ratio). This works with single elements or a set of elements. It's particularly useful if you'd like to set an image to be the background of a given page-- this will ensure that the image always covers the entire viewport.

I'd recommend dropping this code into a resize.js file located in your Drupal theme's folder. From there, just add the filename to your theme's .info file, and it will be loaded into every page of your site (if that's what you're going for).

Be sure to set the 'slides' variable to the appropriate target element, and you should be all set!

(function ($) {

Drupal.behaviors.yourTheme = {
attach: function (context, settings) {

  $(document).ready(function() { 

    // Define the target element(s)
    var slides = $("#views_slideshow_cycle_main_front_slide-block_1 .views-field img", context);

    // Resize on page load.
    slides.each( resize_slide );

    // Trigger resize of element on window resize.
    $(window).resize(function() {
       slides.each( resize_slide );
     });

    // Define resize function.
    function resize_slide() {
      var doc_width = $(window).width(); // can also use $(document).width(), which makes resizing faster
      var doc_height = $(window).height(); // can also use $(document).height(), which makes resizing faster

      var image_width = $(this).width();
      var image_height = $(this).height();

      var image_ratio = image_width/image_height;

      var new_width = doc_width;
      var new_height = Math.round(new_width/image_ratio);

      $(this).width(new_width);
      $(this).height(new_height);
      $(this).removeAttr('width').removeAttr('height');
      if (new_height<doc_height) {
        new_height = doc_height;
        new_width = Math.round(new_height*image_ratio);

        $(this).width(new_width);
        $(this).height(new_height);
        var width_offset = Math.round((new_width-doc_width)/2);

        $(this).css("left","-"+width_offset+"px");
      }
    }

  // End $(document).ready
  });
}

};

}(jQuery));

Oct 06 2011
Oct 06

The Faceted Search module produces a result set that looks almost exactly like Drupal's core search results. However, it does not use the search-results.tpl.php file. Rather, it uses it's own theming function: theme_faceted_search_ui_search_item().

You can, of course, set your own theme function to override this.

Here's a quick example of how I modified the way that users are displayed in the results:

<?php
function grasmash_faceted_search_ui_search_item($item, $type) {
  $output = ' <dt class="title"><a href="http://matthewgrasmick.com/posts/theming-faceted-search-result/'. check_url($item['link']) .'">'. check_plain($item['title']) .'</a></dt>';
  $info = array();
  if ($item['type']) {
    $info[] = check_plain($item['type']);
  }
  if ($item['user']) {
    $author = user_load($item['node']->uid);
    //if author is not an admin
    if (!in_array('site administrator', array_values($author->roles)) && $author->uid != 1) {
      //display first and last name using info from user's content profile
      $content_profile = content_profile_load('employee_profile', $item['node']->uid);
      $name = $content_profile->field_first_name[0]['value'] . ' ' . $content_profile->field_last_name[0]['value'];
      $info[] = l($name, url('user/' . $item['node']->uid, array('absolute' => TRUE)));
    }
    else {
      //$info[] = $item['user'];
    }
  }
  if ($item['date']) {
    $info[] = format_date($item['date'], 'small');
  }

  //remove OG details
  unset($item['extra']['og_msg']);

  if (is_array($item['extra'])) {
    $info = array_merge($info, $item['extra']);
  }

  $output .= ' <dd>'. ($item['snippet'] ? '<p>'. $item['snippet'] .'</p>' : '') .'<p class="search-info">'. implode(' - ', $info) .'</p></dd>';
  return $output;
}
?>

Oct 06 2011
Oct 06

This is a custom solution that uses hook_form_alter() to prepopulate the Ubercart Address field with fields from the logged in user's Content Profile.

<?php
/*
  * Implementation of hook_form_alter()
  * populate address field on ubercart checkout using 
  * user's content profile
  */
function grasmash_form_alter(&$form, &$form_state, $form_id) {
  if ($form_id == 'uc_cart_checkout_form') { 
    //load user and profile objects
    global $user;
    $profile = content_profile_load('employee_profile', $user->uid);

    //map source fields in content_profile to target fields on checkout form
    $field_matches = array (
      'billing_first_name' => $profile->field_first_name[0]['value'],
      'billing_last_name' => $profile->field_last_name[0]['value'],
      // 'billing_company' => NULL,
      'billing_street1' => $profile->field_employee_location[0]['street'],
      'billing_street2' => $profile->field_employee_location[0]['additional'],
      'billing_city' => $profile->field_employee_location[0]['city'],
      'billing_country' => $profile->field_employee_location[0]['country'],
      'billing_zone' => $profile->field_employee_location[0]['province_name'],
      'billing_postal_code' => $profile->field_employee_location[0]['postal_code'],
      'billing_phone' => $profile->field_employee_location[0]['phone'],
    );        

    //set default values
    foreach ($field_matches as $target_field => $source) {

      if ($source) {

        /* select list needs to be set differently
        determine appropriate target field key for source value */
        if ($target_field == 'billing_zone' || $target_field == 'billing_country') {
          foreach ($form['panes']['billing'][$target_field]['#options'] as $key => $value) {
            if ($source == $value) {
              $form['panes']['billing'][$target_field]['#value'] = $key;
            }
          }
        }

        //set values for normal text fields
        else {
          $form['panes']['billing'][$target_field]['#default_value'] = $source;
        }

      }
    }
  }
}
?>

You'll clearly need to change the mapping array to match your specific field names.

Note:
If I were to do this again, I'd consider using the Ubercart Addresses module with the following plan:

  • Set Ubercart Addresses to not require address on registration
  • Set Content Profile to require an address CCK field on registration
  • create custom module with hook_form_alter()
  • have hook_form_alter() add a new submit handler for the user_registration form
  • have submit handler use values provided to CCK address fields to create a new db entry in the uc_addresses table
Sep 23 2011
Sep 23

Conditional Fields is a great Drupal module for conditionally hiding CCK fields.

Unfortunately, it can't hide CCK fieldgroups. It's also not ideal if you're concerned about security— it simply hides fields; it doesn't deny access. A recent project of mine required that I conditionally deny access to field groups, so I decided to implement a programmatic solution.

In this case, I needed to deny access to specific fieldgroups in content type 'company' based on:

  • The role of the acting user
  • The value of the field_company_type CCK field

The downside to this approach is that you must save and return to the node edit page before seeing a change in fieldgroup visibility.

Here's my solution:
/*
* Implementation of hook_form_alter()
*
*
*/
function grasmash_form_alter(&$form, &$form_state, $form_id) {

/*
* remove specific fields from company node/edit form
* contigent upon value of field_company_type and user's role
*/

if ($form_id == 'company_node_form'){
global $user;

//if user is not admin
if (!in_array('site administrator', array_values($user->roles)) && $user->uid != 1){
$company_type = $form['#node']->field_company_type[0]['value'];
$hidden_groups = array();

//determine which fields should be hidden for which company types

//visible to only manufacturer
if ($company_type != 'manufacturer') {
$hidden_groups[] = 'group_manufacturing';
$hidden_groups[] = 'group_reactions';
$hidden_groups[] = 'group_equipment';
}

//visible to only manufacturer and distrubutors
if ($company_type != 'manufacturer' && $company_type != 'distributor_supplier'){
$hidden_groups[] = 'group_capabilities';
$hidden_groups[] = 'group_certifications';
$hidden_groups[] = 'group_products';
}

//visible to only manufacturers, distrubutors, and service providers
if ($company_type != 'manufacturer' && $company_type != 'distributor_supplier' && $company_type != 'industry_service_provider'){
//$hidden_groups[] = 'group_industries';
$hidden_groups[] = 'group_industries';
$hidden_groups[] = 'group_consultants';
}

//deny access to hidden fieldgroups
foreach ($hidden_groups as $group){
$form[$group]['#access'] = FALSE;
}
}
}
?>

Sep 13 2011
Sep 13

Drupal's default 'Generic File' format can be a bit ugly. Luckily, it's not too hard to override. If you'd like to change the default filefield theming for 'Generic File', try using the theme_filefield_item() function in your template.php file.

Note: You may also be interested in checking out my post on customizing the filefield format in views.

In the example below, I overrode the default theming for all cck fieldfields belonging to node type 'publication'.

<?php
function grasmash_filefield_item($file, $field) {
  if (filefield_view_access($field['field_name']) && filefield_file_listed($file, $field)) {
    $node = node_load($file['nid']);
    if ($node->type == 'publication'){ 

      $filepath = $file['filepath'];
      //$filename = $file['filename'];
      $icon = theme('filefield_icon', $file);
      $filesize = '<span class="file-size">' . format_size($file['filesize']) . '</span>';  
      $link = l(t('Download Related Publication'), $filepath, array('attributes' => array('class' => 'download-publication')));

      return $icon . $link . ' ' . $filesize;
    }

    else {
      return theme('filefield_file', $file); //default theming
    }
  }
  return '';
}
?>

One downside to this particular implementation is that I'm calling node_load every time that a file is themed. This makes a database call for each file, which can cause performance issues if you're loading too many. Just something to keep in mind.

Sep 12 2011
Sep 12

Actually, there is a way to use imagecache presets with the image module without patches. It involves creating a View that manually applies the imagecache preset to the image field (not imagefield). You'll need views customfield.

Create a view and add the relationships "Image:File" and "Image:Node." Then add fields "File:path," "Node->title," and "Customfield: PHP Code." Add this to your php customfield script:

//print var_export($data, TRUE); //reveals all available variables

$image_src = $data->files_image_filepath;
$imagecache_preset = "Front-Slide-ic"; //enter your own imagecache preset here

$attributes = array('class' => 'lightbox'); //if you want to add attributes
$image_title = $data->node_image_title;

print theme('imagecache', $imagecache_preset, $image_src, $image_title, $image_title, $attributes);

?>

Using this method allows you to use Image Crop to manually select the crop area.

Sep 03 2011
Sep 03

Alright, so this a bit nuanced, but I thought that I'd share it anyway. Maybe this snippet will help someone somewhere.

Scenario

I'm using the Signup module to permit users to signup for events, but I'd like them to be able to enter more than the standard name and phone number. I'd like my users to be able to provide their address.

Beyond that, if the user has already provided their address on their user profile (generated using Content Profile and the Location CCK module), then I'd like the fields to prepopulate.

Furthermore (we're getting crazy here), if they enter a new address on the signup form, I'd like them to be able to save that address to their profile.

Whoa!

Actually, with the power of the Forms API, this becomes an easy task.

You'll clearly have to modify this script to match your specific CCK field names, but it should give you a good jumpstart!

function grasmash_signup_user_form() {

global $user;
$profile_node = content_profile_load('profile', $user->uid);

$form['signup_form_data']['#tree'] = TRUE;

$form['signup_form_data']['name'] = array(
'#type' => 'textfield',
'#title' => t('Name'),
'#size' => 40,
'#maxlength' => 64,
'#default_value' => $profile_node->field_name[0]['first'] . ' ' . $profile_node->field_name[0]['last'],
);

$form['signup_form_data']['phone'] = array(
'#type' => 'textfield',
'#title' => t('Phone'),
'#size' => 40,
'#maxlength' => 64,
'#default_value' => $profile_node->field_location[0]['phone'],
);

$form['signup_form_data']['location'] = array (
'#type' => 'fieldset',
'#title' => t('Location'),
'#collapsible' => FALSE,
'#collapsed' => FALSE,
);

$form['signup_form_data']['location'] ['street'] = array(
'#type' => 'textfield',
'#title' => t('Street'),
'#size' => 40,
'#maxlength' => 64,
'#default_value' => $profile_node->field_location[0]['street'],
);

$form['signup_form_data']['location'] ['additional'] = array(
'#type' => 'textfield',
'#title' => t('Additional'),
'#size' => 40,
'#maxlength' => 64,
'#default_value' => $profile_node->field_location[0]['additional'],
);

$form['signup_form_data']['location'] ['province'] = array(
'#type' => 'select',
'#title' => t('State'),
'#options' => array(
'AL' => 'Alabama',
'AK' => 'Alaska',
'AZ' => 'Arizona',
'AR' => 'Arkansas',
'CA' => 'California',
'CO' => 'Colorado',
'CT' => 'Connecticut',
'DE' => 'Delaware',
'DC' => 'District Of Columbia',
'FL' => 'Florida',
'GA' => 'Georgia',
'HI' => 'Hawaii',
'ID' => 'Idaho',
'IL' => 'Illinois',
'IN' => 'Indiana',
'IA' => 'Iowa',
'KS' => 'Kansas',
'KY' => 'Kentucky',
'LA' => 'Louisiana',
'ME' => 'Maine',
'MD' => 'Maryland',
'MA' => 'Massachusetts',
'MI' => 'Michigan',
'MN' => 'Minnesota',
'MS' => 'Mississippi',
'MO' => 'Missouri',
'MT' => 'Montana',
'NE' => 'Nebraska',
'NV' => 'Nevada',
'NH' => 'New Hampshire',
'NJ' => 'New Jersey',
'NM' => 'New Mexico',
'NY' => 'New York',
'NC' => 'North Carolina',
'ND' => 'North Dakota',
'OH' => 'Ohio',
'OK' => 'Oklahoma',
'OR' => 'Oregon',
'PA' => 'Pennsylvania',
'RI' => 'Rhode Island',
'SC' => 'South Carolina',
'SD' => 'South Dakota',
'TN' => 'Tennessee',
'TX' => 'Texas',
'UT' => 'Utah',
'VT' => 'Vermont',
'VA' => 'Virginia',
'WA' => 'Washington',
'WV' => 'West Virginia',
'WI' => 'Wisconsin',
'WY' => 'Wyoming',
'AS' => 'American Samoa',
'FM' => 'Federated States of Micronesia',
'GU' => 'Guam',
'MH' => 'Marshall Islands',
'MP' => 'Northern Mariana Islands',
'PW' => 'Palau',
'PR' => 'Puerto Rico',
'VI' => 'Virgin Islands'
),
'#default_value' => $profile_node->field_location[0]['province'],

);

$form['signup_form_data']['location']['postal_code'] = array(
'#type' => 'textfield',
'#title' => t('Zip'),
'#size' => 40,
'#maxlength' => 64,
'#default_value' => $profile_node->field_location[0]['postal_code'],
);

$form['signup_form_data']['save_address'] = array(
'#type' => 'checkbox',
'#title' => t('Save address to your profile?'),
);

return $form;

}

function grasmash_form_alter(&$form, $form_state, $form_id) {
//add additional validation function to form submit method
if($form_id == 'signup_form'){
$form['#submit'][] = 'grasmash_signup_form_submit';
}
}

function grasmash_signup_form_submit(&$form, &$form_state) {
//update user profile
if ($form['#post']['signup_form_data']['save_address']){
global $user;
$node = content_profile_load('profile', $user->uid);

//update location fields (includes phone number)
foreach($node->field_location[0] as $field_name => $value){
if ($form['#post']['signup_form_data'][$field_name]){
$node->field_location[0][$field_name] = $form['#post']['signup_form_data'][$field_name];
}
}

//save profile node
if ($node = node_submit($node)) {
node_save($node);
drupal_set_message(t("Your profile has been updated."));
} else {
drupal_set_message(t("There was an error updating your profile."), "error");
}

}
}
?>

Aug 24 2011
Aug 24

Let's say that you'd like to create a nested menu in which one of the children links to the same path as its parent. Something like:

  •  people/friends
    • people/friends
    • people/family
    • people/neighbors

This works in theory-- the menu items will take you to the correct page. But if you're relying on Drupal to correctly set the active trail and expand the correct sub menus, you'll have to use a bit of a workaround. You're basically going to create dummy nodes that will redirect to the desired page rather than creating two menu links that have the exact same path. The parent (in menu) will redirect to the child's node.

Method 1 (D6 only):

  • Download and install CCK Redirection
  • Create a Content Type 'Redirect Node' and add a CCK Redirection field to the content type
  • Create a page node (node 1) with URL "people/friends" and add a menu link
  • Create a 'Redirect Node' (node 2) and create a menu link for this new node.
  • Set CCK Redirection field to the URL node 1 (people/friends).
  • Set node 2's menu item to be the parent of node 1's

Method 2 (D6&7):

If you want to get really fancy, you can take a more user friendly approach by combining CCK's Node Reference module with the Rules module.

  • Download and install the Rules module (and Rules UI) and enable CCK's Node Reference module
  • Create a Content Type 'Redirect Node' and add a Node Reference field 'field_redirect_target'. Be sure to configure this so that
    • it is required, and
    • users cannot select nodes of type 'Redirect Node'
  • Add a new rule via the Rules module
    • when content is going to be viewed
    • if content is of type 'Redirect Node'
    • Redirect to page: [node:field_redirect_target-path]
  • Create node 1 of type page
  • Create node 2 of type 'Redirect Node'
    • Set Node Reference field to Node 1
  • Set node 2's menu item to be the parent of node 1's

This method is a bit nicer for users, since it doesn't require them to find the system path for the target node.

Aug 18 2011
Aug 18

This one is a little bit more esoteric. I found the need change the page style (via CSS) based on whether the current user had permission to update the current node. Drupal preprocess_page to the rescue!

Just add this snippet to the yourTheme_preprocess_page function in your template.php file. It will add body classes (e.g., user-node-update, user-node-delete) to your page depending on the current user's permission to access the node being viewed.

<?php
function grasmash_preprocess_page(&$vars, $hook) {
  $body_classes = array($vars['body_classes']);

  //if this page is a node and we can access the user object
  if ($vars['node']->nid != "" && $vars['user']) {
    $node_actions = array('update','delete','create'); //leaving out 'view' since user can obviously view this node

    //check current user's access to this node, add body_classes accordingly
    foreach ($node_actions as $node_action) {
      if (node_access($node_action, $vars['node'], $vars['user'])){
        $body_classes[] = 'user-node-' . $node_action; //add an appropriate body class
      }
    }
  }

  $vars['body_classes'] = implode(' ', $body_classes); // Concatenate with spaces
}
?>

Aug 18 2011
Aug 18

If you'd like to change your site's appearance based on the current user's role, you can easily add the current user's role to the $body_classes array.

Just add this snippet to the yourTheme_preprocess_page function in your template.php file.

Drupal 6:

<?php
function grasmash_preprocess_page(&$vars, $hook) {
  $body_classes = array($vars['body_classes']);
   if ($vars['user']) {
    foreach($vars['user']->roles as $key => $role){
      $role_class = 'role-' . str_replace(' ', '-', $role);
      $body_classes[] = $role_class;
    }
  }
  $vars['body_classes'] = implode(' ', $body_classes); // Concatenate with spaces
}
?>

Thanks to Anders Iversen for a Drupal 7 Version:

<?php
function grasmash_preprocess_page(&$vars) {
  if ($vars['user']) {
    foreach($vars['user']->roles as $key => $role){
      $vars['class'][] = 'role-' . drupal_html_class($role);
    }
  }
}
?>

You may also want to check out my post on adding body classes based on user permissions.

Aug 18 2011
Aug 18

Views replacement patterns are great, but they take up valuable variable real-estate. If you need to literally output "[nid]" rather than the value of its corresponding replacement pattern, you might start hitting your head against the wall.

I found this issue to be particularly annoying when using the Prepopulate module, which required CCK field names (with bracketed arrays) to a URL.

Never fear! Rather than using "[nid]" in your views text, try using percent encoding rather than literal brackets. The result would be:

%5Bnid%5D

Using percent encoding with stop views from replacing the replacement pattern with a dynamic value.

Aug 15 2011
Aug 15

Drupal has a standard array of template suggestions that let you specify which TPL file should be used according to node type, node id, etc. However, there is no default template suggestion for pages generated by Panels.

You can easily fix that by adding a little snippet to your template.php files' preprocess_page() function.

function grasmash_preprocess_page(&$vars, $hook) {
// if this is a panel page, add template suggestions
if($panel_page = page_manager_get_current_page()) {

// add a generic suggestion for all panel pages
$suggestions[] = 'page-panel';

// add the panel page machine name to the template suggestions
$suggestions[] = 'page-' . $panel_page['name'];

// merge the suggestions in to the existing suggestions (as more specific than the existing suggestions)
$vars['template_files'] = array_merge($vars['template_files'], $suggestions);

//add a body class for good measure
$body_classes[] = 'page-panel';
}
}
}
?>

You can also preprocess the the page for specific Panels layouts:
function grasmash_preprocess_page(&$vars, $hook) {
// if this is a panel page, add template suggestions
if($panel_page = page_manager_get_current_page()) {

// add a generic suggestion for all panel pages
$suggestions[] = 'page-panel';

// add the panel page machine name to the template suggestions
$suggestions[] = 'page-' . $panel_page['name'];

// merge the suggestions in to the existing suggestions (as more specific than the existing suggestions)
$vars['template_files'] = array_merge($vars['template_files'], $suggestions);

$display = panels_get_current_page_display();
$layout = $display->layout;
$body_classes[] = 'panel-layout-' . $layout;

//add a body class for good measure
$body_classes[] = 'page-panel';
}
}
}
?>
And there you have it.

Aug 11 2011
Aug 11

Create the link:

Advanced Search

Or you could even add it to the search box with jQuery (I'm adding it to a Custom Search block here):

/* jQuery to expand advanced search field is contained in js_injector rule (in db) */
$('Advanced Search').appendTo('.block-custom_search_blocks .form-item');

Then, add a jQuery script. I used JS Injector to add this only to the 'search*' pages.

//wait until the rest of the jQuery has been loaded
$(document).ready(function() {
//break apart url to get anchor
var url = document.location.toString();
if (url.match('#')) {
var anchor = '#' + url.split('#')[1];
}

//click collapsed fieldset to expand
if (anchor == '#advanced-search'){
$('.search-advanced legend').find('a').click();
}
});

Pages

About Drupal Sun

Drupal Sun is an Evolving Web project. It allows you to:

  • Do full-text search on all the articles in Drupal Planet (thanks to Apache Solr)
  • Facet based on tags, author, or feed
  • Flip through articles quickly (with j/k or arrow keys) to find what you're interested in
  • View the entire article text inline, or in the context of the site where it was created

See the blog post at Evolving Web

Evolving Web