Sunday 13 January 2013

Drupal module challenge


During the holidays I've stumbled upon this website: moduleoff.com. It offers regular module development quests for various prices. For the first one it was Nexus 7 tablet, so I applied :) Now I'd like to explain my solution.


The problem was the following:

"It's a pretty common use case. You need a button on a node that says something like "Mark as Complete" or "Set as Today" which updates a date field. In this challenge, you'll need to add a link to the view page of every article node that will update a date field to 'now' using an AJAX request. The date field should be updated in the database as well as on the currently displayed node, without a page load."

I fired up a Drupal 7 site and created an info file:

name = Set It Now
description = "Provides call to action to set date field values to 'now'."
package = Date/Time
core = 7.x

dependencies[] = date


I had only one dependency to Date module. So what's next? There will be a link after each date field on an entity. To make it work we have a nice hook called hook_field_attach_view_alter.

/**
 * Implements hook_field_attach_view_alter().
 */ 
function set_it_now_field_attach_view_alter(&$output, $context) {
}


Now I realized I only want to show the link to users having a dedicated permission, so I created one:

// Permission to view the call to action link.
define('SET_IT_NOW_VIEW_PERMISSION', 'view set it now link');

/**
 * Implements hook_permission().
 */ 
function set_it_now_permission() {
  return array(
    SET_IT_NOW_VIEW_PERMISSION => array(
      'title' => t('View set-it-now link'),
      'description' => t('User can see and use link to set the a date field to \'now\''),
    ),
  );
}


Now I can stop adding my link when the user has no permission to do so inside set_it_now_field_attach_view_alter():

  // Don't show link if permission is not granted.
  if (!user_access(SET_IT_NOW_VIEW_PERMISSION)) {
    return;
  }


Now comes the fun part. I have to check all rendered field, check if it's a date field and add my extra link element. It's a bit tricky since the rendered item is a single string. Into set_it_now_field_attach_view_alter:

  $field_names = element_children($output);
  // Check all fields on the entity for date fields.
  foreach ($field_names as $field_name) {
    if (in_array($output[$field_name]['#field_type'], array('datetime', 'date', 'datestamp'))) {
      $field_definition = field_info_field($field_name);
      $is_to_date = !empty($field_definition['settings']['todate']);

      // Loop through each delta.
      foreach ($output[$field_name]['#items'] as $delta => $item) {
        static $entity_info;
        if (!isset($entity_info)) {
          list($entity_id, $entity_vid, $bundle_type) = entity_extract_ids($context['entity_type'], $context['entity']);
        }
        // Alter the output by adding the call to action.
        $output[$field_name][$delta]['#markup'] = theme('set_it_now_link', array(
          'entity_type' => $context['entity_type'],
          'entity_id' => $entity_id,
          'field_name' => $field_name,
          'delta' => $delta,
          'view_mode' => $context['view_mode'],
          'is_to_date' => $is_to_date,
          'original' => $output[$field_name][$delta]['#markup'],
        ));
      }
    }
  }


Now the problem is that the rendered output is a single string and I have to attach my call to action. I created a template - it's convenient anyway to have something overridable and at the same time I can separate it from the attach logic. The theme has to know about the original value and some field/entity related properties, let's create the hook_theme:

/**
 * Implements hook_theme().
 */ 
function set_it_now_theme($existing, $type, $theme, $path) {
  return array(
    'set_it_now_link' => array(
      'variables' => array(
        'entity_type' => NULL,
        'entity_id' => NULL,
        'field_name' => NULL,
        'delta' => NULL,
        'view_mode' => NULL,
        'is_to_date' => NULL,
        'original' => NULL,
      ),
    ),
  );
}


The theme is:

/**
 * Theme function of 'set_it_now_link'.
 *
 * @param $variables
 *  Variables array.
 * @return string
 * @see set_it_now_theme()
 */ 
function theme_set_it_now_link($variables) {

}


The link should contain all the necessary parameters to change the right field's value:

  // Add standard Drupal ajax handler.
  drupal_add_library('system', 'drupal.ajax');

  // Link to change the first date.
  $link = l(
    t('[set it now]'),
    "set_it_now/{$variables['entity_type']}/{$variables['entity_id']}/{$variables['field_name']}/{$variables['delta']}/{$variables['view_mode']}/" . SET_IT_NOW_DATE_FROM . "/nojs",
    array(
      'query' => drupal_get_destination(),
      'attributes' => array('class' => 'use-ajax'),
    )
  );


I added the Drupal Ajax functionality so we can use it both with and without JS. When we render the output we have to take care of the ajax upadte. I found the best to wrap the output and tag it with a very specific class:

  // Mark the field wrapper so it can be replaced easily.
  $class = 'set-it-now-' . $variables['entity_type'] . '-' . $variables['entity_id'] . '-' . $variables['field_name'] . '-' . $variables['delta'] . '-' . $variables['view_mode'];

  return '<span class="' . $class . '">' . $variables['original'] . $link . $link_to . '</span>';


Now we can create the menu for the call:

/**
 * Implements hook_menu().
 */ 
function set_it_now_menu() {
  $items = array(
    /**
     * Setting action callback.
     *
     * Params:
     *  #1 entity type
     *  #2 entity id
     *  #3 field name
     *  #4 delta
     *  #5 view mode
     *  #6 from or to date value
     *  #7 nojs/ajax.
     */ 
    'set_it_now/%/%/%/%/%/%/%' => array(
      'type' => MENU_CALLBACK,
      'access arguments' => array(SET_IT_NOW_VIEW_PERMISSION),
      'page callback' => 'set_it_now_set_date',
      'page arguments' => array(1, 2, 3, 4, 5, 6, 7),
      'delivery callback' => 'ajax_deliver',
    ),
  );

  return $items;
}


One trick here is using the delivery callback attribute so Drupal knows how to handle. Let's create the callback:

/**
 * Page callback for setting the Date field ('set_it_now/%/%/%/%/%').
 *
 * @param $entity_type
 *  Type string of the entity that has the Date field.
 * @param $entity_id
 *  ID integer of the entity.
 * @param $field_name
 *  Name of the Date field.
 * @param $delta
 *  Delta integer of field item.
 * @param $view_mode
 *  Entity display view mode string.
 * @param $date_field_value
 *  Flag showing first or second field value it is.
 * @param $nojs
 *  Indicator string to show if the request is ajax.
 * @return array|void
 *  Commands to replace the HTML if ajax. Reload the page otherwise.
 * @see set_it_now_menu()
 */ 
function set_it_now_set_date($entity_type, $entity_id, $field_name, $delta, $view_mode = 'full', $date_field_value = SET_IT_NOW_DATE_FROM, $nojs = 'nojs') {

}


We need to gather some information about the entity and the field to provide the proper values:

  // Get the entity object.
  $entities = entity_load($entity_type, array($entity_id));
  $entity = $entities[$entity_id];

  // Get field definition.
  $field_definition = field_info_field($field_name);


Then we have to get the field (I cycle through all the language instances - I'm not sure if that's really important but it's convenient) and set the proper value - depending to the field type. When it's all set we can save it. We can use facade functions like node_save() or check if the entity module installed and use entity_save():

  // Set value for all languages.
  foreach ($entity->{$field_name} as $lang => $item) {
    $value_name = $date_field_value == SET_IT_NOW_DATE_FROM ? 'value' : 'value2';
    switch ($field_definition['type']) {
      case 'datetime':
        $entity->{$field_name}[$lang][$delta][$value_name] = format_date(time(), 'custom', 'Y-m-d H:i:s');
        break;
      case 'date':
        $entity->{$field_name}[$lang][$delta][$value_name] = format_date(time(), 'custom', 'Y-m-d\TH:i:s');
        break;
      case 'datestamp':
        $entity->{$field_name}[$lang][$delta][$value_name] = time();
        break;
      default:
        continue;
    }

    // Save and update entity.
    if ($entity_type == 'node') {
      node_save($entity);
    }
    elseif (module_exists('entity')) {
      entity_save($entity_type, $entity);
    }
    else {
      drupal_set_message(t('Date value cannot be changed. It is attached to an unknown entity type. Please install the Entity module.'), 'warning');
    }
  }


If all done we can first check the request type. If it was ajax we prepare a command array and let it go:

  // If request was ajax then respond with a replacement command.
  if ($nojs == 'ajax') {
    $field_view = field_view_field($entity_type, $entity, $field_name, $view_mode);

    $commands = array();
    $selector = 'span.set-it-now-' . $entity_type . '-' . $entity_id . '-' . $field_name . '-' . $delta . '-' . $view_mode;
    $commands[] = ajax_command_replace($selector, $field_view[$delta]['#markup']);
    return array(
      '#type' => 'ajax',
      '#commands' => $commands,
    );
  }


Or using the destination in the url we just redirect it to the origin:

  // Non JavaScript request will redirect the page.
  return drupal_goto();


And that's all. I didn't paste all the code here, but you can check it in my sandbox repo.

Also - what a shame - there is a demo video about it:



That challenge was won by Jake Bell as you can see on the page. I've checked his solution and to be honest that's just brilliant :) Congratulations!

---

I'll soon write a blogpost about other programming challenges. Let me know if you met any other similar site.

Peter

4 comments:

  1. Peter,

    Thanks so much for your submission. Your submission was solid and definitely above a lot of the other competitiors. We hope you'll come back for future challenges!

    Mike Kadin
    The Module Off

    ReplyDelete
  2. Hi Mike :) Thanks so much. I really like the initiative. It let's you to compare your solution to other talented developers' work. For me and most probably for others it's really valuable.
    I'll be definitely doing some in the future too :)
    Peter

    ReplyDelete
  3. I can see only one drawback at this time of the night: your solution seems to have a CSRF in it: it changes the DB without checking if there was a proper link displayed at all (hiding this behind a permission check is not enough). I would add a token to that link to get rid of this attack vector, and check the validity of this token before changing the DB.

    ReplyDelete
  4. Hi Laszlo,
    Ups, you're very right, thank you very much! I guess you're referring to the url tokens: http://api.drupal.org/api/drupal/includes%21common.inc/function/drupal_valid_token/7, right?
    Thanks :)

    ReplyDelete

Note: only a member of this blog may post a comment.