Integrating JavaScript Libraries in XWiki

Version 4.2 by Oana Florea on 2019/09/13
Warning: For security reasons, the document is displayed in restricted mode as it is not the current version. There may be differences and errors due to this.

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');
    });
  • Examples from the FAQ:

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>

Related

Failed to execute the [velocity] macro. Cause: [The execution of the [velocity] script macro is not allowed in [xwiki:Documentation.DevGuide.FrontendResources.IntegratingJavaScriptLibraries.WebHome]. Check the rights of its last author or the parameters if it's rendered from another script.]. Click on this message for details.

Get Connected