silverstripe-framework/docs/en/topics/javascript.md

23 KiB

JavaScript

Important: Parts of this guide apply to the SilverStripe 2.4 release, particularly around the jQuery.entwine library.

This page describes best practices for developing with JavaScript in SilverStripe. This includes work in the CMS interface, form widgets and custom project code. It is geared towards our "library of choice", jQuery, but most practices can be applied to other libraries as well.

File Inclusion

SilverStripe-driven code should use the Requirements class to manage clientside dependencies like CSS and JavaScript files, rather than including <script> and <link> tags in your templates. This has the advantage that a registry of requirements can be built up from different places outside of the main controller, for example included FormField instances.

See requirements documentation.

jQuery, jQuery UI and jQuery.entwine: Our libraries of choice

We predominantly use jQuery as our abstraction library for DOM related programming, within the SilverStripe CMS and certain framework aspects.

For richer interactions such as drag'n'drop, and more complicated interface elements like tabs or accordions, SilverStripe CMS uses jQuery UI on top of jQuery.

For any custom code developed with jQuery, you have four choices to structure it: Custom jQuery Code, a jQuery Plugin, a jQuery UI Widget, or a jQuery.entwine behaviour. We'll detail below where each solution is appropriate.

Important: Historically we have been using PrototypeJS, which is now discouraged. SilverStripe as a framework doesn't impose a choice of library. It tries to generate meaningful markup which you can alter with other JavaScript libraries as well. Only the CMS itself and certain form widgets require jQuery to function correctly. You can also use jQuery in parallel with other libraries, see here.

Custom jQuery Code

jQuery allows you to write complex behaviour in a couple of lines of JavaScript. Smaller features which aren't likely to be reused can be custom code without further encapsulation. For example, a button rollover effect doesn't require a full plugin. See "How jQuery Works" for a good introduction.

You should write all your custom jQuery code in a closure. This will prevent jQuery from conflicting from any prototype code or any other framework code.

:::javascript
(function($) {
	$(document).ready(function(){
		// your code here.
	})
})(jQuery);

jQuery Plugins

A jQuery Plugin is essentially a method call which can act on a collection of DOM elements. It is contained within the jQuery.fn namespace, and attaches itself automatically to all jQuery collections. The basics for are outlined in the official jQuery Plugin Authoring documentation.

There a certain documented patterns for plugin development, most importantly:

  • Claim only a single name in the jQuery namespace
  • Accept an options argument to control plugin behavior
  • Provide public access to default plugin settings
  • Provide public access to secondary functions (as applicable)
  • Keep private functions private
  • Support the Metadata Plugin

Example: A plugin to highlight a collection of elements with a configurable foreground and background colour (abbreviated example from learningjquery.com).

:::js
// create closure
(function($) {
  // plugin definition
  $.fn.hilight = function(options) {
    // build main options before element iteration
    var opts = $.extend({}, $.fn.hilight.defaults, options);
    // iterate and reformat each matched element
    return this.each(function() {
      $this = $(this);
      // build element specific options
      var o = $.meta ? $.extend({}, opts, $this.data()) : opts;
      // update element styles
      $this.css({
        backgroundColor: o.background,
        color: o.foreground
      });
    });
  };
  // plugin defaults
  $.fn.hilight.defaults = {
    foreground: "red",
    background: "yellow"
  };
// end of closure
})(jQuery);

Usage:

:::js
(function($) {
  // Highlight all buttons with default colours
  jQuery(':button').highlight();

  // Highlight all buttons with green background
  jQuery(':button').highlight({background: "green"});
  
  // Set all further highlight() calls to have a green background
  $.fn.hilight.defaults.background = "green";
})(jQuery);

jQuery UI Widgets

UI Widgets are jQuery Plugins with a bit more structure, targeted towards interactive elements. They require jQuery and the core libraries in jQuery UI, so are generally more heavyweight if jQuery UI isn't already used elsewhere.

Main advantages over simpler jQuery plugins are:

  • Exposing public methods on DOM elements (incl. pseudo-private methods)
  • Exposing configuration and getters/setters on DOM elements
  • Constructor/Destructor hooks
  • Focus management and mouse interaction

See the official developer guide and other tutorials to get started.

Example: Highlighter

:::js
(function($) {
  $.widget("ui.myHighlight", {
    getBlink: function () { 
      return this._getData('blink'); 
    },
    setBlink: function (blink) {
      this._setData('blink', blink);
      if(blink) this.element.wrapInner('<blink></blink>');
      else this.element.html(this.element.children().html());
    },
    _init: function() { 
      // grab the default value and use it
      this.element.css('background',this.options.background); 
      this.element.css('color',this.options.foreground); 
      this.setBlink(this.options.blink);
    } 
  });
  // For demonstration purposes, this is also possible with jQuery.css()
  $.ui.myHighlight.getter = "getBlink";
  $.ui.myHighlight.defaults = {
    foreground: "red",
    background: "yellow",
    blink: false
  };
})(jQuery);

Usage:

:::js
(function($) {
  // call with default options
  $(':button').myHighlight();

  // call with custom options
  $(':button').myHighlight({background: "green"});

  // set defaults for all future instances
  $.ui.myHighlight.defaults.background = "green";

  // Adjust property after initialization
  $(':button').myHighlight('setBlink', true);

  // Get property
  $(':button').myHighlight('getBlink');
})(jQuery);

entwine: Defining Behaviour and Public APIs

jQuery.entwine is a third-party plugin, from its documentation: "A basic desire for jQuery programming is some sort of OO or other organisational method for code. For your consideration, we provide a library for entwineUI style programming. In entwineUI you attach behavioral code to DOM objects. entwine extends this concept beyond what is provided by other libraries to provide a very easy to use system with class like, ploymorphic, namespaced properties."

Use jQuery.entwine when your code is likely to be customized by others, for example for most work in the CMS interface. It is also suited for more complex applications beyond a single-purpose plugin.

Example: Highlighter

:::js
(function($) {
  $(':button').entwine({
    Foreground: 'red',
    Background: 'yellow',
    highlight: function() {
      this.css('background', this.getBackground());
      this.css('color', this.getForeground());
    }
  });
})(jQuery);

Usage:

:::js
(function($) {
  // call with default options
  $(':button').entwine().highlight();
  
  // set options for existing and new instances
  $(':button').entwine().setBackground('green');
  
  // get property
  $(':button').entwine().getBackground();
})(jQuery);

This is a deliberately simple example, the strength of jQuery.entwine over simple jQuery plugins lies in its public properties, namespacing, as well as its inheritance based on CSS selectors. Please see the project documentation for more complete examples.

Architecture and Best Practices

Keep things simple

Resist the temptation to build "cathedrals" of complex interrelated components. In general, you can get a lot done in jQuery with a few lines of code. Your jQuery code will normally end up as a series of event handlers applied with jQuery.live() or jQuery.entwine, rather than a complex object graph.

Don't claim global properties

Global properties are evil. They are accesible by other scripts, might be overwritten or mis-used. A popular case is the $ shortcut in different libraries: in PrototypeJS it stands for document.getElementByID(), in jQuery for jQuery().

:::js
// you can't rely on '$' being defined outside of the closure
(function($) {
  var myPrivateVar; // only available inside the closure
  // inside here you can use the 'jQuery' object as '$'
})(jQuery);

You can run [jQuery.noConflict()](http://docs.jquery.com/Core/jQuery.noConflict) to avoid namespace clashes. NoConflict mode is enabled by default in the SilverStripe CMS javascript.

Initialize at document.ready

You have to ensure that DOM elements you want to act on are loaded before using them. jQuery provides a wrapper around the window.onload and document.ready events.

:::js
// DOM elements might not be available here
$(document).ready(function() {
  // The DOM is fully loaded here
});

See jQuery FAQ: Launching Code on Document Ready.

Bind events "live"

jQuery supports automatically reapplying event handlers when new DOM elements get inserted, mostly through Ajax calls. This "live binding" saves you from reapplying this step manually.

Caution: Only applies to certain events, see the jQuery.live() documentation.

Example: Add a 'loading' classname to all pressed buttons

:::js
// manual binding, only applies to existing elements
$('input[[type=submit]]').bind('click', function() {
  $(this).addClass('loading');
});

// live binding, applies to any inserted elements as well
$('input[[type=submit]]').live(function() {
  $(this).addClass('loading');
});

See jQuery FAQ: Why do my events stop working after an AJAX request.

Assume Element Collections

jQuery is based around collections of DOM elements, the library functions typically handle multiple elements (where it makes sense). Encapsulate your code by nesting your jQuery commands inside a jQuery().each() call.

Example: ComplexTableField implements a paginated table with a pop-up for displaying

:::js
$('div.ComplexTableField').each(function() {
  // This is the over code for the tr elements inside a ComplexTableField.
  $(this).find('tr').hover(
    // ...
  );
});

Use plain HTML and jQuery.data() to store data

The DOM can make javascript configuration and state-keeping a lot easier, without having to resort to javascript properties and complex object graphs.

Example: Simple form change tracking to prevent submission of unchanged data

Through CSS properties

:::js
$('form :input').bind('change', function(e) {
  $(this.form).addClass('isChanged');
});
$('form').bind('submit', function(e) {
  if($(this).hasClass('isChanged')) return false;
});

Through jQuery.data()

:::js
$('form :input').bind('change', function(e) {
  $(this.form).data('isChanged', true);
});
$('form').bind('submit', function(e) {
  alert($(this).data('isChanged'));
  if($(this).data('isChanged')) return false;
});

See interactive example on jsbin.com

You can also use the jQuery.metadata Plugin to serialize data into properties of DOM elements. This is useful if you want to encode element-specific data in markup, for example when rendering a form element through the SilverStripe templating engine.

Example: Restricted numeric value field

:::ss
<input type="text" class="restricted-text {min:4,max:10}" />


:::js
$('.restricted-text').bind('change', function(e) {
  if(
    e.target.value < $(this).metadata().min
    || e.target.value > $(this).metadata().max
  ) {
    alert('Invalid value');
    return false;
  }
});

See interactive example on jsbin.com

Return HTML/JSON and HTTPResponse class for AJAX responses

Ajax responses will sometimes need to update existing DOM elements, for example refresh a set of search results. Returning plain HTML is generally a good default behaviour, as it allows you to keep template rendering in one place (in SilverStripe PHP code), and is easy to deal with in JavaScript.

If you need to process or inspect returned data, consider extracting it from the loaded HTML instead (through id/class attributes, or the jQuery.metadata plugin). For returning status messages, please use the HTTP status-codes.

Only return evaluated JavaScript snippets if unavoidable. Most of the time you can just pass data around, and let the clientside react to changes appropriately without telling it directly through JavaScript in AJAX responses. Don't use the [api:Form] SilverStripe class, which is built solely around this inflexible concept.

Example: Autocomplete input field loading page matches through AJAX

Template:

:::ss
<ul>
<% control Results %>
  <li id="Result-$ID">$Title</li>
<% end_control %>
</ul>

PHP:

:::php
class MyController {
  function autocomplete($request) {
    $SQL_title = Convert::raw2sql($request->getVar('title'));
    $results = DataObject::get("Page", "Title = '$SQL_title'");
    if(!$results) return new HTTPResponse("Not found", 404);
    
    // Use HTTPResponse to pass custom status messages
    $this->response->setStatusCode(200, "Found " . $results->Count() . " elements");
    
    // render all results with a custom template
    $vd = new ViewableData();
    return $vd->customise(array(
      "Results" => $results
    ))->renderWith('AutoComplete');
  }
}

HTML

:::ss
<form action"#">
  <div class="autocomplete {url:'MyController/autocomplete'}">
    <input type="text" name="title" />
    <div class="results" style="display: none;">
  </div>
  <input type="submit" value="action_autocomplete" />
</form>

JavaScript:

:::js
$('.autocomplete input').live('change', function() {
  var resultsEl = $(this).siblings('.results');
  resultsEl.load(
    // get form action, using the jQuery.metadata plugin
    $(this).parent().metadata().url,
    // submit all form values
    $(this.form).serialize(),
    // callback after data is loaded
    function(data, status) {
      resultsEl.show();
      // get all record IDs from the new HTML
      var ids = jQuery('.results').find('li').map(function() { 
        return $(this).attr('id').replace(/Record\-/,''); 
      });
    }
  );
});

Although they are the minority of cases, there are times when a simple HTML fragment isn't enough. For example, if you have server side code that needs to trigger the update of a couple of elements in the CMS left-hand tree, it would be inefficient to send back the HTML of entire tree. SilverStripe can serialize to and from JSON (see the [api:Convert] class), and jQuery deals very well with it through jQuery.getJSON(), as long as the HTTP content-type is properly set.

The philosophy behind this javascript guide is component driven development: your javascript should be structured as a set of components that communicate. Event handlers are a great way of getting components to community, as long as two-way communication isn't required. Set up a number of custom event names that your component will trigger. List them in the component documentation comment.

jQuery can bind to DOM events and trigger them through custom code. It can also trigger custom events, and supports namespaced events.

Example: Trigger custom 'validationfailed' event on form submission for each empty element

:::js
$('form').bind('submit', function(e) {
  // $(this) refers to form
  $(this).find(':input').each(function() {
    // $(this) in here refers to input field
    if(!$(this).val()) $(this).trigger('validationfailed');
  });
  return false;
});

// listen to custom event on each <input> field
$('form :input').bind('validationfailed',function(e) {
  // $(this) refers to input field
  alert($(this).attr('name'));
});

See interactive example on jsbin.com.

Don't use event handlers in the following situations:

  • If two-way communication is required, for example, calling an method in another component, which returns data that you then use. Event handlers can't have return values.
  • If specific execution order is required. Event handlers are executed in parallel, which makes it difficult to know the exact order in which code in different threads will execute. If the execution order is likely to cause problems, it is better to use a code structure that is executed sequentially. An example might be two events modifying the same piece of the DOM.

Use callbacks to allow customizations

Callbacks are similar to events in that other components can ask your component to execute a piece of code. The advantage is that they lack the two problems listed in bullets just above. The disadvantage of callbacks is that you need to define an custom API for configuring the callbacks; whereas, event observation is a jQuery provided API that leaves components very loosely coupled.

Use jQuery.entwine to define APIs as necessary

By default, most of your JavaScript methods will be hidden in closures like a jQuery plugin, and are not accessible from the outside. As a best practice, each jQuery plugin should only expose one method to initialize and configure it. If you need more public methods, consider using either a jQuery UI Widget, or define your behaviour as jQuery.entwine rules (see above).

Write Documentation

Documentation in JavaScript usually resembles the JavaDoc standard, although there is no agreed standard. Due to the flexibility of the language it can be hard to generate automated documentation, particularly with the predominant usage of closure constructs in jQuery and jQuery.entwine.

To generate documentation for SilverStripe code, use JSDoc toolkit (see reference of supported tags). For more class-oriented JavaScript, take a look at the jsdoc cookbook. The @lends and @borrows properties are particularly useful for documenting jQuery-style code.

JSDoc-toolkit is a commandline utility, see usage.

Example: jQuery.entwine

:::js
/**

 * Available Custom Events:
 * <ul>
 * <li>ajaxsubmit</li>
 * <li>validate</li>
 * <li>loadnewpage</li>
 * </ul>
 * 
 * @class Main LeftAndMain interface with some control panel and an edit form.
 * @name ss.LeftAndMain
 */
$('.LeftAndMain').entwine('ss', function($){
  return/** @lends ss.LeftAndMain */ {
    /**

     * Reference to some property
     * @type Number
     */
    MyProperty: 123,
      
    /**

     * Renders the provided data into an unordered list.
     * 
     * @param {Object} data
     * @param {String} status
     * @return {String} HTML unordered list
     */
    publicMethod: function(data, status) {
      return '<ul>'
        + /...
        + '</ul>';
    },
    
    /**

     * Won't show in documentation, but still worth documenting.
     * 
     * @return {String} Something else.
     */
    _privateMethod: function() {
      // ...
    }
  };
]]);

Unit Testing

It is important to verify that your code actually does what it says, and the best way to ensure this are automated tests. For jQuery, we use two different tools with different uses: unit testing with QUnit (also used by the jQuery team for the core libraries), and behaviour driven testing with JSpec. There are overlaps between the two solutions, if in doubt start with JSpec, as it provides a much more powerful testing framework.

Example: QUnit test (from jquery.com):

:::js
test("a basic test example", function() {
  ok( true, "this test is fine" );
  var value = "hello";
  equals( "hello", value, "We expect value to be hello" );
});

Example: JSpec Shopping cart test (from visionmedia.github.com)

describe 'ShoppingCart'
  before_each 
    cart = new ShoppingCart
  end
  describe 'addProduct'
    it 'should add a product'
      cart.addProduct('cookie') 
      cart.addProduct('icecream') 
      cart.should.have 2, 'products'   
    end
  end
end

Javascript in the CMS

The CMS has a number of Observer-pattern hooks you can access: (The elements which are notified are listed in brackets.)

  • Close -- when 'folder' in SiteTree is closed. (form?)
  • BeforeSave -- after user clicks 'Save', before AJAX save-request (#Form_EditForm)
  • PageLoaded -- after new SiteTree page is loaded. (#Form_EditForm)
  • PageSaved -- after AJAX save-request is successful (#Form_EditForm)
  • SelectionChanged -- when new item is chosen from SiteTree (#sitetree)

Here's an example of hooking the 'PageLoaded' and 'BeforeSave' methods:

:::javascript
/*
* Observe the SiteTree 'PageLoaded' event, called whenever a SiteTree page is
* opened or reloaded in the CMS.
*
* Also observe 'BeforeSave' which is called when the Save button is pressed,
* before the AJAX call to save the page is sent.
*/
Behaviour.register({
	'#Form_EditForm' : {
		initialize : function() {
			this.observeMethod('PageLoaded', this.pageLoaded);
			this.observeMethod('BeforeSave', this.beforeSave);
			this.pageLoaded(); // call pageload initially too.
		},

		pageLoaded : function() {
			alert("You loaded a page");
		},

		beforeSave: function() {
			alert("You clicked save");
		}
	} // #Form_EditForm
});

See 'onload' javascript in the CMS

Break the rules!

The guidelines are not intended to be hard and fast rules; they cover the most common cases but not everything. Don't be afraid to experiment with using other approaches.