JavaScript Best Practices

Neil Drumm

Follow along at delocalizedham.com/d4d-stanford-2010, j/k are next/previous

Demo: API.drupal.org autocomplete

Lazily load assets & actions as needed

$('#edit-search', this).attr('autocomplete', 'off').one('focus', function () {
  var $this = $(this);
  // Prefetch list of objects for this branch.
  $.getJSON(Drupal.settings.apiAutoCompletePath, function (data) {
    // Attach to autocomplete.
    $this.autocomplete(data, {
      sort: function (a, b) {
        return a.value.length - b.value.length;
      },
      matchContains: true,
      max: 200,
      scroll: true,
      scrollHeight: 360,
      width: 300
    }).result(function () {
      $this.get(0).form.submit();
    }).focus();
  });
});

From api.js

Use AJAX sparingly

$('#edit-search', this).attr('autocomplete', 'off').one('focus', function () {
  var $this = $(this);
  // Prefetch list of objects for this branch.
  $.getJSON(Drupal.settings.apiAutoCompletePath, function (data) {
    // Attach to autocomplete.
    $this.autocomplete(data, {
      sort: function (a, b) {
        return a.value.length - b.value.length;
      },
      matchContains: true,
      max: 200,
      scroll: true,
      scrollHeight: 360,
      width: 300
    }).result(function () {
      $this.get(0).form.submit();
    }).focus();
  });
});

From api.js

Use jQuery libraries as appropriate

$('#edit-search', this).attr('autocomplete', 'off').one('focus', function () {
  var $this = $(this);
  // Prefetch list of objects for this branch.
  $.getJSON(Drupal.settings.apiAutoCompletePath, function (data) {
    // Attach to autocomplete.
    $this.autocomplete(data, {
      sort: function (a, b) {
        return a.value.length - b.value.length;
      },
      matchContains: true,
      max: 200,
      scroll: true,
      scrollHeight: 360,
      width: 300
    }).result(function () {
      $this.get(0).form.submit();
    }).focus();
  });
});

From api.js

Pay attention to details

$('#edit-search', this).attr('autocomplete', 'off').one('focus', function () {
  var $this = $(this);
  // Prefetch list of objects for this branch.
  $.getJSON(Drupal.settings.apiAutoCompletePath, function (data) {
    // Attach to autocomplete.
    $this.autocomplete(data, {
      sort: function (a, b) {
        return a.value.length - b.value.length;
      },
      matchContains: true,
      max: 200,
      scroll: true,
      scrollHeight: 360,
      width: 300
    }).result(function () {
      $this.get(0).form.submit();
    }).focus();
  });
});

From api.js

How many problems can you find?

options = {
  matchContains: TRUE,
  max: 200,
  width: 300,
}

Use JSLint

options = {
  matchContains: TRUE,
  max: 200,
  width: 300,
}

/*jslint white: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, 
bitwise: true, regexp: true, newcap: true, immed: true, indent: 2 */

4 problems found automatically

var options = {
  matchContains: true,
  max: 200,
  width: 300 
};

/*jslint white: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, 
bitwise: true, regexp: true, newcap: true, immed: true, indent: 2 */

Demo: Drupal.org dashboard page rename

Use Drupal.settings for additions

$dashboardActiveSpan.find('>a.edit').click(function() {
  // Hide edit link, show edit form.
  $dashboardActiveSpan.find('>a').hide().end().append(Drupal.settings.dashboardPageEditForm);
  $dashboardEditForm = $('#dashboard-page-edit-form').find('div.delete').hide().end();

  // Correct title in case it has already been edited.
  $dashboardEditForm.find('#edit-edit-title')
    .attr('value', $dashboardActiveSpan.find('>a.edit').text());

  // Allow AHAH form submission, but make changes active instantly.
  Drupal.attachBehaviors($dashboardEditForm);
  $dashboardEditFormSubmit = $dashboardEditForm.find('#edit-edit-submit').click(function() {
    $dashboardActiveSpan.find('>a.edit').html(
      Drupal.checkPlain($dashboardEditForm.find('#edit-edit-title').attr('value'))
       + '<span class="edit-icon"></span>'
    );
    dashboardRemoveEditPageForm();
  });

From dashboard.js

Use Drupal.settings for additions

drupal_add_js(array(
  'dashboardPage' => $page->path,
  'dashboardToken' => drupal_get_token('dashboard ' . $page->page_id),
  'dashboardPageEditForm' => drupal_get_form('dashboard_page_edit_form', $page),
),'setting');

From dashboard.page.inc

Use AHAH to save work

$form['edit_submit'] = array(
  '#type' => 'submit',
  '#value' => t('Save'),
  '#ahah' => array(
    'event' => 'click',
    'path' => 'dashboard/'. $page->path .'/rename',
  ),
);
function dashboard_page_rename($page) {
  drupal_get_form('dashboard_page_edit_form', $page);
  drupal_json(array('status' => TRUE, 'data' => ''));
}

From dashboard.page.inc

Use AHAH to save work

$form['edit_submit'] = array(
  '#type' => 'submit',
  '#value' => t('Save'),
  '#ahah' => array(
    'event' => 'click',
    'path' => 'dashboard/'. $page->path .'/rename',
  ),
);
function dashboard_page_rename($page) {
  drupal_get_form('dashboard_page_edit_form', $page);
  drupal_json(array('status' => TRUE, 'data' => ''));
}

From dashboard.page.inc

Use AHAH, but not all of it

$dashboardActiveSpan.find('>a.edit').click(function() {
  // Hide edit link, show edit form.
  $dashboardActiveSpan.find('>a').hide().end().append(Drupal.settings.dashboardPageEditForm);
  $dashboardEditForm = $('#dashboard-page-edit-form').find('div.delete').hide().end();

  // Correct title in case it has already been edited.
  $dashboardEditForm.find('#edit-edit-title')
    .attr('value', $dashboardActiveSpan.find('>a.edit').text());

  // Allow AHAH form submission, but make changes active instantly.
  Drupal.attachBehaviors($dashboardEditForm);
  $dashboardEditFormSubmit = $dashboardEditForm.find('#edit-edit-submit').click(function() {
    $dashboardActiveSpan.find('>a.edit').html(
      Drupal.checkPlain($dashboardEditForm.find('#edit-edit-title').attr('value'))
       + '<span class="edit-icon"></span>'
    );
    dashboardRemoveEditPageForm();
  });

From dashboard.js

Think about security

$dashboardActiveSpan.find('>a.edit').click(function() {
  // Hide edit link, show edit form.
  $dashboardActiveSpan.find('>a').hide().end().append(Drupal.settings.dashboardPageEditForm);
  $dashboardEditForm = $('#dashboard-page-edit-form').find('div.delete').hide().end();

  // Correct title in case it has already been edited.
  $dashboardEditForm.find('#edit-edit-title')
    .attr('value', $dashboardActiveSpan.find('>a.edit').text());

  // Allow AHAH form submission, but make changes active instantly.
  Drupal.attachBehaviors($dashboardEditForm);
  $dashboardEditFormSubmit = $dashboardEditForm.find('#edit-edit-submit').click(function() {
    $dashboardActiveSpan.find('>a.edit').html(
      Drupal.checkPlain($dashboardEditForm.find('#edit-edit-title').attr('value'))
       + '<span class="edit-icon"></span>'
    );
    dashboardRemoveEditPageForm();
  });

From dashboard.js

Use tokens

  1. drupal_add_js(array(
      'dashboardPage' => $page->path,
      'dashboardToken' => drupal_get_token('dashboard ' . $page->page_id),
      'dashboardPageEditForm' => drupal_get_form('dashboard_page_edit_form', $page),
    ),'setting');
  2. jQuery.post(Drupal.settings.basePath
     + 'dashboard/' + Drupal.settings.dashboardPage + '/remove-widget', {
      token: Drupal.settings.dashboardToken,
      widget_id: $widget.attr('id').replace(/^widget-/, '')
    });
  3. function dashboard_valid_token($page) {
      return drupal_valid_token($_POST['token'], 'dashboard '. $page->page_id);
    }

From dashboard.page.inc, dashboard.js, and dashboard.module

Generalize when possible

Drupal.behaviors.mapPopup = function (context) {
  $('a.map-popup:not(mapPopupProcessed)', context).addClass('mapPopupProcessed')
  .each(function () {
    var $this = $(this);
    var $content = $('#' + $this.attr('id').replace(/-link$/, ''));
    var options = $.extend({}, Drupal.mapPopupDefaults);
    if (typeof Drupal.settings.mapPopups[$content.attr('id')] !== 'undefined') {
      $.extend(options, Drupal.settings.mapPopups[$content.attr('id')]);
    }
    if (options.clone) {
      $content = $content.clone().appendTo("body");
    }
    $this.data('mapPopUp', $content.show().dialog(options));
  })
  .click(function () {
    $(this).data('mapPopUp').dialog('open');
    return false;
  });
};

Will be used in MAPLight.org’s upcoming redesign

Generalize when possible

<a href="non-js-fallback" class="map-popup" id="my-dialog-link">Click to open</a>

<div id="my-dialog">Dialog content</div>

Add flexibility as needed

Drupal.behaviors.mapPopup = function (context) {
  $('a.map-popup:not(mapPopupProcessed)', context).addClass('mapPopupProcessed')
  .each(function () {
    var $this = $(this);
    var $content = $('#' + $this.attr('id').replace(/-link$/, ''));
    var options = $.extend({}, Drupal.mapPopupDefaults);
    if (typeof Drupal.settings.mapPopups[$content.attr('id')] !== 'undefined') {
      $.extend(options, Drupal.settings.mapPopups[$content.attr('id')]);
    }
    if (options.clone) {
      $content = $content.clone().appendTo("body");
    }
    $this.data('mapPopUp', $content.show().dialog(options));
  })
  .click(function () {
    $(this).data('mapPopUp').dialog('open');
    return false;
  });
};

Will be used in MAPLight.org’s upcoming redesign

Use Firebug timing and profiling

Drupal.attachBehaviors = function(context) {
  context = context || document;
  if (Drupal.jsEnabled) {
    // Execute all of them.
    jQuery.each(Drupal.behaviors, function() {
      console.log(this);
      console.time('behavior');
      this(context);
      console.timeEnd('behavior');
    });
  }
};

Drupal’s drupal.js

Use Firebug timing and profiling

Drupal.behaviors.mapExpandable = function (context) {
  console.profile();
  $('.map-expandable:not(.mapExpandableProcessed)', context).each(function () {
    $(this).addClass('mapExpandableProcessed').one('mouseenter', function () {
      $(this).children('.prompt').find('a.show-content').click(function () {
        $(this).parents('.map-expandable').eq(0)
          .children('.prompt').hide().end()
          .children('.content').show();
        return false;
      }).end().end()
      .children('.content').find('a.hide-content').click(function () {
        $(this).parents('.map-expandable').eq(0)
          .children('.prompt').show().end()
          .children('.content').hide();
        return false;
      });
    });
  });
  console.profileEnd();
};

Will be used in MAPLight.org’s upcoming redesign

Use Firebug timing and profiling

Drupal.behaviors.mapExpandable = function (context) {
  console.profile();
  $('div.map-expandable:not(.mapExpandableProcessed)', context).each(function () {
    $(this).addClass('mapExpandableProcessed').one('mouseenter', function () {
      $(this).children('.prompt').find('a.show-content').click(function () {
        $(this).parents('.map-expandable').eq(0)
          .children('.prompt').hide().end()
          .children('.content').show();
        return false;
      }).end().end()
      .children('.content').find('a.hide-content').click(function () {
        $(this).parents('.map-expandable').eq(0)
          .children('.prompt').show().end()
          .children('.content').hide();
        return false;
      });
    });
  });
  console.profileEnd();
};

Will be used in MAPLight.org’s upcoming redesign

Try jQuery Lint

Questions?

Neil Drumm
delocalizedham.com
drumm@delocalizedham.com

References

How I did the j/k paging