Object-Oriented Forms in Drupal 7

Roomify, LLC · April 2, 2015

While working on a channel management plugin for Drupal Rooms, (beta coming soon!) I had a couple of interesting requirements to satisfy. Many booking service providers use the iCal standard in a way that is generically similar, but we wished to be able to support differences in implementation or capabilities between services. In addition, while we are initially focused on supporting channel management via iCal to cover as many providers as possible, it was important to me to do things generically enough to be able to support different APIs and interactions when pushing and pulling event and booking data without having to do a major re-architecture down the road. (Fingers crossed!)

To almost anyone that has been doing software development in the past decade or two in a language other than Fortran77, (couldn't resist a dig there, sorry Dad!) this quickly sounds ideally suited to an object-oriented architecture. To wit: Generic Importer Class > extended as iCal Importer > extended as Airbnb importer. This should lead to a flexible and extensible architecture, without needing to explicitly enable overridden functionality via the hook system.

However, there are certain challenges to integrating fully with Drupal's APIs when doing OOP. I can recommend this excellent series on Writing Drupal 7 code with an eye towards Drupal 8 for general pointers, and used their technique for implementing page callbacks on this project. The specific issue that confronted me is that drupal_get_form requires that the form ID be a valid function name, used to construct the callback when generating a new form, and I wished to define (and override) configuration forms in class methods, as well as handling form validation and submission. After some digging through the bowels of form.inc, I believe I came up with a reasonably efficient way of replicating drupal_get_form's functionality. The essential idea is that each specific availability source module implements a hook with information about its plugin. (Similar to the approach you may have seen used in the feeds module and some others) In the page callback for the channel management tab on the Rooms unit, we look for enabled implementations and build their forms:

 

 foreach (module_invoke_all('rooms_channel_source') as $source) {

  // Load this importer's class.
  $importer = new $source['handler']['class'];

  // Set the Rooms unit ID and load its specific configuration.
  $importer->config->unit_id = $unit->unit_id;
  $importer->load();

  // Get the importer's form array.
  $form = $importer->config_form();

  // Add a hidden form element with the class name, for use in validation/submission.
  $form['class'] = array(
    '#type' => 'hidden',
    '#value' => $source['handler']['class'],
  );

  // Define a form ID. 
  $form_id = $source['handler']['class'] . '_config_form';

  // Do the setup Drupal requires when not using drupal_get_form().
  $form_state = form_state_defaults();
  if (!isset($form_state['input'])) {
    $form_state['input'] = $form_state['method'] == 'get' ? $_GET : $_POST;
  }

  // Explicitly set the validation and submission handlers to our global
  // functions.
  $form_state['validate_handlers'] = array('rooms_channel_manager_config_form_validate');
  $form_state['submit_handlers'] = array('rooms_channel_manager_config_form_submit');

  // Go through the FAPI preparation and processing stages.
  drupal_prepare_form($form_id, $form, $form_state);
  drupal_process_form($form_id, $form, $form_state);
} 

Most of the above is boilerplate replicating the things that drupal_get_form does, the two pieces to pay particular attention to are:

  1. The $form['class'] element - this hidden element is what gives our form validation and submission wrapper functions the information they need to call the correct class method.
  2. The override of $form_state['submit_handlers'] (and validate_handlers) - this lets the FAPI know which functions to call for validation/submission.
The wrappers themselves are actually quite simple, it's just a matter of instantiating the correct class and calling the method if it exists:
/**
 * Wrapper function - execute submission handler for configuration forms in
 * source classes.
 */
function rooms_channel_manager_config_form_submit($form, &$form_state) {
  $importer = new $form['class']['#value'];
  if (method_exists($importer, 'config_form_submit')) {
    $importer->config_form_submit($form, $form_state);
  }
} 
The net result is that we are able to define a form and handle validation/submission in a class, over-riding a base class and form:
 /** 
 * Override config form with Airbnb-specific configuration.
 */                    
public function config_form() {
  $form = parent::config_form();
  $form[$this->source_name]['ical_url']['#title'] = t('Airbnb iCal link');
  return $form;
}

/**
 * Save iCal import configuration settings.
 */
public function config_form_submit($form, &$form_state) {
  $this->load();               
  if (isset($form_state['values']['unit_id'])) {
    $this->config->unit_id = $form_state['values']['unit_id'];
    $this->config->confirm_bookings = $form_state['values']['confirm_bookings'];
    $this->config->url = $form_state['values']['ical_url'];
    $this->save();             
  }                            
}                               
This certainly feels like more work than calling drupal_get_form, but I think the benefits more than make up for it. It's also worth mentioning that the cool module offers an alternative approach for performing this task and others. I generally like to avoid adding module dependencies for single tasks, but am going to keep it in mind for future projects. To summarize, the basic steps we have followed are:
  1. Define your classes in standard OOP fashion.
  2. Include the relevant form configurations/overrides within your classes.
  3. Load those form configurations from your classes in a page callback. (or ctools content_type plugin, etc.).
  4. Make sure you let the validation / submission handlers know what is up by specifying the class to load.
I hope you'll find this technique useful, and look forward to hearing about possible improvements!

 

Twitter, Facebook