Integrating JavaScript Libraries in XWiki

Last modified by Pascal Bastien on 2016/06/22 16:09

Agenda

Use Case

Suppose you want to display the Date column from the Document Index live table as time ago. So instead of showing "2015/07/17 15:44" you would like to display "2 days ago". Of course, you can do this from the server side, but for the purpose of this tutorial we will achieve this using a JavaScript library called Moment.js. It can parse, validate, manipulate, and display dates from JavaScript.

Integration Options

There are several ways we can integrate Moment.js in XWiki:

  1. copy moment.js somewhere in /resources
    $xwiki.jsfx.use('path/to/moment.js')
    • you need file system access
    • it leads to a custom XWiki WAR and thus upgrade complexity
    • Extension Manager doesn't support installing resources in the WAR
  2. attach moment.js to a wiki page
    <script src="$xwiki.getAttachmentURL('Demo.MomentJS', 'moment.js'"
     type="text/javascript"></script>

      • installable as XAR extension but moment.js code is included in the extension sources
      • can slow/break the blame view on GitHub
  3. copy moment.js in a JSX object
    $xwiki.jsx.use('Demo.MomentJS')

      • the library code is still included in the extension sources
      • when you upgrade the library version you need to ask your users to clear the browser cache or you need to put the library version in the document name
        $xwiki.jsx.use('Demo.MomentJSv2_10_3')
        • but then you need to update your code too which is bad because the dependency version should be part of the configuration.
  4. Load moment.js from CDN
    <script src="//cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.3/moment.js"
     type="text/javascript"></script>
    • the library code is not included in the extension sources any more
    • but the version is still specified in the code
    • and XWiki might be behind a Proxy/Firewall with limited internet access
  5. Deploy moment.js as a WebJar and load it using RequireJS and the WebJar Script Service.

What is a WebJar?

  • A JAR (Java Archive) file that packages client-side web libraries
  • It can contain any resource file that is usable from the client side: JavaScript, CSS, HTML, client-side templates (e.g. Mustache, Handlebars), JSON, etc.
  • Check www.webjars.org for more information and the list of available WebJars you can use
  • Most WebJar are published on Maven Central so you can integrate them in your Maven build
  • All resource paths must follow this convention:
    • META-INF/resources/webjars/${name}/${version}
    • META-INF/resources/webjars/jquery/1.11.1/jquery.js
    • META-INF/resources/webjars/jstree/3.0.8/themes/default/style.css

How can we use WebJars

  • Deployed like a normal JAR inside WEB-INF/lib or through Extension Manager
  • Maven Project Dependency
    <dependency>
     <groupId>org.webjars</groupId>
     <artifactId>jstree</artifactId>
     <version>3.0.8</version>
     <scope>runtime</scope>
    </dependency>
  • Script Service
    <script href="$services.webjars.url('momentjs', 'min/moment.js')"
     type="text/javascript" />

Why should we use WebJars?

  • Installable with Extension Manager
  • Explicit & Transitive Dependencies
  • Library code is not included in your sources
  • Versioning and Cache
    • The library version is not specified in your source code
    • But it is part of the resource URL so there's no need to clear the browser cache after an upgrade
      http://<server>/xwiki/webjars/momentjs/2.10.3/min/moment.min.js
  • Both minified and non-minified resources are usually available
    • You can debug using the non-minified version

Still, adding the script tag manually is not nice. We have RequireJS for this though.

What is RequireJS?

  • RequireJS is a JavaScript file and module loader
  • You can organize your code in modules that declare explicitly their dependencies
  • Modules are loaded / imported asynchronously, with all their transitive dependencies
  • This is called *Asynchronous Module Definition* (ADM)
  • Modules can export (publish) APIs (e.g. an object or a function) which are "injected" in your code
    • Dependency Injection

How can we use RequireJS?

  • Define a new module
    define('climb-mountain', ['boots', 'backpack', 'poles'], function(boots, $bp, poles) {
     // Prepare the climb tools.
     /* ... */

     // Export the API
     return function(mountainName) {
       // Climb the specified mountain.
     };
    });
  • Use existing modules
    require.config({
      paths: {
        moment: [
         '//cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.3/moment.min',
         "$!services.webjars.url('momentjs', 'min/moment.min')"
        ],
       'climb-mountain': '$xwiki.getURL("Fun.ClimbMountain", "jsx", "language=$xcontext.language")'
      }
    });

    require(['jquery', 'moment', 'climb-mountain'], function($, moment, climb) {
      climb('Mont Blanc');
    });

Why should we use RequireJS?

  • Clear declaration of dependencies and avoids the use of globals
  • Module identifiers can be mapped to different paths which allows swapping out implementation
    • This is great for creating mocks for unit testing
  • Encapsulates the module definition. Gives you the tools to avoid polluting the global namespace.
  • The JavaScript code becomes more modularized
  • We can use different versions of a lib at the same time

Time Ago LiveTable Date: First Version

Using a JSX:

require.config({
  paths: {
    moment: "$services.webjars.url('momentjs', 'min/moment.min')"
  }
});

require(['jquery', 'moment', 'xwiki-events-bridge'], function($, moment) {
  $(document).on('xwiki:livetable:newrow', function(event, data) {
   var dateString = data.data['doc_date'];
   var timeAgo = moment(dateString, "YYYY/MM/DD HH:mm").fromNow();
    $(data.row).find('td.doc_date').html(timeAgo);
  };
});

Time Ago LiveTable Date: Second Version

Let's make it more generic:

define(['jquery', 'moment', 'xwiki-events-bridge'], function($, moment) {
 return function(column, liveTableId, dateFormat) {
    column = column || 'doc.date';
    column = column.replace(/^doc\./, 'doc_');
    dateFormat = dateFormat || 'YYYY/MM/DD HH:mm';
   var eventName = 'xwiki:livetable:newrow';
   if (liveTableId) {
      eventName = 'xwiki:livetable:' + liveTableId + ':newrow';
    }
    $(document).on(eventName, function(event, data) {
     var dateString = data.data[column];
     var timeAgo = moment(dateString, dateFormat).fromNow();
      $(data.row).find('td.' + column).html(timeAgo);
    });
  };
});

How can we package WebJars?

Unfortunately there's no dedicated/integrated Maven plugin for packaging a WebJar so we need to mix a couple of standard Maven plugins:

  • We put the resources in src/main/resources as expected for a Maven project
    • src/main/resources/livetable-timeago.js
  • Copy the WebJar resources to the right path before packing the jar
    <plugin>
     <artifactId>maven-resources-plugin</artifactId>
     <executions>
       <execution>
         <id>copy-webjar-resources</id>
         <phase>validate</phase>
         <goals>
           <goal>resources</goal>
         </goals>
         <configuration>
           <!-- Follow the specifications regarding the WebJar content path. -->
           <outputDirectory>
    ${project.build.outputDirectory}/META-INF/resources/webjars/${project.artifactId}/${project.version}
           </outputDirectory>
         </configuration>
       </execution>
     </executions>
    </plugin>
  • Package the WebJar resources as a JAR
    <plugin>
     <groupId>org.apache.maven.plugins</groupId>
     <artifactId>maven-jar-plugin</artifactId>
     <configuration>
       <includes>
         <!-- Include only the WebJar content -->
         <include>META-INF/**</include>
       </includes>
     </configuration>
    </plugin>

Why should we package WebJars?

  • See Why should we use WebJars?
  • Group resources by functionality
  • State dependencies clearly
  • Apply quality tools
    • Static code verification (JSHint)
    • Unit and integration tests (Jasmine)
    • Minification (YUI Compressor)

Let's add some quality tools.

What is JSHint?

JSHint is a tool that helps to detect errors and potential problems in your JavaScript code.

[ERROR] 3,18: This function has too many parameters. (4)
[ERROR] 6,50: Missing semicolon.
[ERROR] 11,18: Blocks are nested too deeply. (3)
[ERROR] 16,5: 'foo' is not defined.

How can we use JSHint?

<plugin>
 <groupId>com.cj.jshintmojo</groupId>
 <artifactId>jshint-maven-plugin</artifactId>
 <version>1.3.0</version>
 <executions>
   <execution>
     <goals>
       <goal>lint</goal>
     </goals>
   </execution>
 </executions>
 <configuration>
   <globals>require,define,document</globals>
   <!-- See http://jshint.com/docs/options/ -->
   <options>maxparams:3,maxdepth:2,eqeqeq,undef,unused,immed,latedef,noarg,noempty,nonew</options>
   <directories>
     <directory>src/main/resources</directory>
   </directories>
 </configuration>
</plugin>

What is Jasmine?

  • Jasmine is a DOM-less simple JavaScript testing framework
  • It does not rely on browsers, DOM, or any JavaScript framework
  • It has a Maven plugin
  • Not as nice as Mockito on Java but still very useful
describe("A suite", function() {
  it("contains spec with an expectation", function() {
    expect(true).toBe(true);
  });
});

How can we use Jasmine?

Let's add an unit test in src/test/javascript/livetable-timeago.js:

// Mock module dependencies.

var $ = jasmine.createSpy('$');
define('jquery', [], function() {
 return $;
});

var moment = jasmine.createSpy('moment');
define('moment', [], function() {
 return moment;
});

define('xwiki-events-bridge', [], {});

// Unit tests

define(['livetable-timeago'], function(timeAgo) {
  describe('Live Table Time Ago module', function() {
    it('Change date to time ago using defaults', function() {
     // Setup mocks.
     var $doc = jasmine.createSpyObj('$doc', ['on']);
     var $row = jasmine.createSpyObj('$row', ['find']);
     var $cell = jasmine.createSpyObj('$cell', ['html']);

     var eventData = {
        data: {doc_date: '2015/07/19 12:35'},
        row: {}
      };

      $.andCallFake(function(selector) {
       if (selector === document) {
         return $doc;
        } else if (selector === eventData.row) {
         return $row;
        } else if (selector === 'td.doc_date') {
         return $cell;
        }
      });

      $doc.on.andCallFake(function(eventName, listener) {
        eventName == 'xwiki:livetable:newrow' && listener(null, eventData);
      });

      $row.find.andCallFake(function(selector) {
       if (selector === 'td.doc_date') {
         return $cell;
        }
      });

     var momentObj = jasmine.createSpyObj('momentObj', ['fromNow']);
      moment.andCallFake(function(dateString, dateFormat) {
       if (dateString === eventData.data.doc_date && dateFormat === 'YYYY/MM/DD HH:mm') {
         return momentObj;
        }
      });

     var timeAgoDate = '1 day ago';
      momentObj.fromNow.andReturn(timeAgoDate);

     // Run the operation.
     timeAgo();

     // Verify the results.
     expect($cell.html).toHaveBeenCalledWith(timeAgoDate);
    });
  });
});

We use the Jasmine Maven plugin to run the tests:

<plugin>
 <groupId>com.github.searls</groupId>
 <artifactId>jasmine-maven-plugin</artifactId>
 <executions>
   <execution>
     <goals>
       <goal>test</goal>
     </goals>
   </execution>
 </executions>
 <configuration>
   <specRunnerTemplate>REQUIRE_JS</specRunnerTemplate>
   <preloadSources>
     <source>webjars/require.js</source>
   </preloadSources>
   <jsSrcDir>${project.basedir}/src/main/resources</jsSrcDir>
   <timeout>10</timeout>
 </configuration>
</plugin>
Tags:
Created by Marius Dumitru Florea on 2015/07/28 14:23
    

Get Connected