Wiki source code of Integrating JavaScript Libraries
Last modified by Vincent Massol on 2023/10/10 10:14
Show last authors
| author | version | line-number | content |
|---|---|---|---|
| 1 | {{box cssClass="floatinginfobox" title="**Contents**"}} | ||
| 2 | {{toc depth="1"/}} | ||
| 3 | {{/box}} | ||
| 4 | |||
| 5 | = Use Case = | ||
| 6 | |||
| 7 | 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**>>http://momentjs.com/]]. It can parse, validate, manipulate, and display dates from JavaScript. | ||
| 8 | |||
| 9 | = Integration Options = | ||
| 10 | |||
| 11 | There are several ways we can integrate Moment.js in XWiki: | ||
| 12 | |||
| 13 | 1. copy moment.js somewhere in /resources((( | ||
| 14 | {{code language="none"}} | ||
| 15 | $xwiki.jsfx.use('path/to/moment.js') | ||
| 16 | {{/code}} | ||
| 17 | ))) | ||
| 18 | 1*. you need file system access | ||
| 19 | 1*. it leads to a custom XWiki WAR and thus upgrade complexity | ||
| 20 | 1*. Extension Manager doesn't support installing resources in the WAR | ||
| 21 | 1. attach moment.js to a wiki page((( | ||
| 22 | {{code language="html"}} | ||
| 23 | <script src="$xwiki.getAttachmentURL('Demo.MomentJS', 'moment.js')" | ||
| 24 | type="text/javascript"></script> | ||
| 25 | {{/code}} | ||
| 26 | ))) | ||
| 27 | 1*. installable as XAR extension but moment.js code is included in the extension sources | ||
| 28 | 1*. can slow/break the blame view on GitHub | ||
| 29 | 1. ((( | ||
| 30 | copy moment.js in a JSX object((( | ||
| 31 | {{code language="none"}} | ||
| 32 | $xwiki.jsx.use('Demo.MomentJS') | ||
| 33 | {{/code}} | ||
| 34 | ))) | ||
| 35 | |||
| 36 | * the library code is still included in the extension sources | ||
| 37 | * 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((( | ||
| 38 | {{code language="none"}} | ||
| 39 | $xwiki.jsx.use('Demo.MomentJSv2_10_3') | ||
| 40 | {{/code}} | ||
| 41 | ))) | ||
| 42 | * but then you need to update your code too which is bad because the dependency version should be part of the configuration. | ||
| 43 | ))) | ||
| 44 | 1. Load moment.js from CDN((( | ||
| 45 | {{code language="html"}} | ||
| 46 | <script src="//cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.3/moment.js" | ||
| 47 | type="text/javascript"></script> | ||
| 48 | {{/code}} | ||
| 49 | ))) | ||
| 50 | 1*. the library code is not included in the extension sources any more | ||
| 51 | 1*. but the version is still specified in the code | ||
| 52 | 1*. and XWiki might be behind a Proxy/Firewall with limited internet access | ||
| 53 | 1. Deploy moment.js as a [[**WebJar**>>extensions:Extension.WebJars Integration]] and load it using [[**RequireJS**>>Documentation.DevGuide.FrontendResources.JavaScriptAPI.WebHome||anchor="HRequireJSandjQueryAPIs"]] and the WebJar Script Service. | ||
| 54 | |||
| 55 | = What is a WebJar? = | ||
| 56 | |||
| 57 | * A JAR (Java Archive) file that packages client-side web libraries | ||
| 58 | * 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. | ||
| 59 | * Check www.webjars.org for more information and the list of available WebJars you can use | ||
| 60 | * Most WebJar are published on Maven Central so you can integrate them in your Maven build | ||
| 61 | * All resource paths must follow this convention: | ||
| 62 | ** ##META-INF/resources/webjars/${**name**}/${**version**}## | ||
| 63 | ** ##META-INF/resources/webjars/jquery/1.11.1/jquery.js## | ||
| 64 | ** ##META-INF/resources/webjars/jstree/3.0.8/themes/default/style.css## | ||
| 65 | |||
| 66 | = How can we use WebJars = | ||
| 67 | |||
| 68 | * Deployed like a normal JAR inside ##WEB-INF/lib## or through Extension Manager | ||
| 69 | * Maven Project Dependency((( | ||
| 70 | {{code language="xml"}} | ||
| 71 | <dependency> | ||
| 72 | <groupId>org.webjars</groupId> | ||
| 73 | <artifactId>jstree</artifactId> | ||
| 74 | <version>3.0.8</version> | ||
| 75 | <scope>runtime</scope> | ||
| 76 | </dependency> | ||
| 77 | {{/code}} | ||
| 78 | ))) | ||
| 79 | * Script Service((( | ||
| 80 | {{code language="html"}} | ||
| 81 | <script href="$services.webjars.url('momentjs', 'min/moment.js')" | ||
| 82 | type="text/javascript" ></script> | ||
| 83 | {{/code}} | ||
| 84 | ))) | ||
| 85 | |||
| 86 | = Why should we use WebJars? = | ||
| 87 | |||
| 88 | * Installable with Extension Manager | ||
| 89 | * Explicit & Transitive Dependencies | ||
| 90 | * Library code is not included in your sources | ||
| 91 | * Versioning and Cache | ||
| 92 | ** The library version is not specified in your source code | ||
| 93 | ** But it is part of the resource URL so there's no need to clear the browser cache after an upgrade((( | ||
| 94 | {{code language="none"}} | ||
| 95 | http://<server>/xwiki/webjars/momentjs/2.10.3/min/moment.min.js | ||
| 96 | {{/code}} | ||
| 97 | ))) | ||
| 98 | * Both minified and non-minified resources are usually available | ||
| 99 | ** You can debug using the non-minified version | ||
| 100 | |||
| 101 | Still, adding the script tag manually is not nice. We have RequireJS for this though. | ||
| 102 | |||
| 103 | = What is RequireJS? = | ||
| 104 | |||
| 105 | * RequireJS is a JavaScript file and module loader | ||
| 106 | * You can organize your code in **modules** that declare **explicitly** their **dependencies** | ||
| 107 | * Modules are loaded / imported asynchronously, with all their transitive dependencies | ||
| 108 | * This is called *Asynchronous Module Definition* (AMD) | ||
| 109 | * Modules can **export** (publish) APIs (e.g. an object or a function) which are "injected" in your code | ||
| 110 | ** Dependency Injection | ||
| 111 | |||
| 112 | = How can we use RequireJS? = | ||
| 113 | |||
| 114 | * Define a new module((( | ||
| 115 | {{code language="js"}} | ||
| 116 | define('climb-mountain', ['boots', 'backpack', 'poles'], function(boots, $bp, poles) { | ||
| 117 | // Prepare the climb tools. | ||
| 118 | /* ... */ | ||
| 119 | |||
| 120 | // Export the API | ||
| 121 | return function(mountainName) { | ||
| 122 | // Climb the specified mountain. | ||
| 123 | }; | ||
| 124 | }); | ||
| 125 | {{/code}} | ||
| 126 | ))) | ||
| 127 | * Use existing modules((( | ||
| 128 | {{code language="js"}} | ||
| 129 | require.config({ | ||
| 130 | paths: { | ||
| 131 | moment: [ | ||
| 132 | '//cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.3/moment.min', | ||
| 133 | "$!services.webjars.url('momentjs', 'min/moment.min')" | ||
| 134 | ], | ||
| 135 | 'climb-mountain': '$xwiki.getURL("Fun.ClimbMountain", "jsx", "language=$xcontext.language")' | ||
| 136 | } | ||
| 137 | }); | ||
| 138 | |||
| 139 | require(['jquery', 'moment', 'climb-mountain'], function($, moment, climb) { | ||
| 140 | climb('Mont Blanc'); | ||
| 141 | }); | ||
| 142 | {{/code}} | ||
| 143 | ))) | ||
| 144 | * Examples from the FAQ: | ||
| 145 | ** [[FAQ.How to integrate React and XWiki]] | ||
| 146 | ** [[FAQ.How to integrate d3js and XWiki]] | ||
| 147 | |||
| 148 | = Why should we use RequireJS? = | ||
| 149 | |||
| 150 | * Clear declaration of dependencies and avoids the use of globals | ||
| 151 | * Module identifiers can be mapped to different paths which allows swapping out implementation | ||
| 152 | ** This is great for creating mocks for unit testing | ||
| 153 | * Encapsulates the module definition. Gives you the tools to avoid polluting the global namespace. | ||
| 154 | * The JavaScript code becomes more modularized | ||
| 155 | * We can use different versions of a lib at the same time | ||
| 156 | |||
| 157 | = Time Ago LiveTable Date: First Version = | ||
| 158 | |||
| 159 | Using a JSX: | ||
| 160 | |||
| 161 | {{code language="js"}} | ||
| 162 | require.config({ | ||
| 163 | paths: { | ||
| 164 | moment: "$services.webjars.url('momentjs', 'min/moment.min')" | ||
| 165 | } | ||
| 166 | }); | ||
| 167 | |||
| 168 | require(['jquery', 'moment', 'xwiki-events-bridge'], function($, moment) { | ||
| 169 | $(document).on('xwiki:livetable:newrow', function(event, data) { | ||
| 170 | var dateString = data.data['doc_date']; | ||
| 171 | var timeAgo = moment(dateString, "YYYY/MM/DD HH:mm").fromNow(); | ||
| 172 | $(data.row).find('td.doc_date').html(timeAgo); | ||
| 173 | }; | ||
| 174 | }); | ||
| 175 | {{/code}} | ||
| 176 | |||
| 177 | = Time Ago LiveTable Date: Second Version = | ||
| 178 | |||
| 179 | Let's make it more generic: | ||
| 180 | |||
| 181 | {{code language="js"}} | ||
| 182 | define(['jquery', 'moment', 'xwiki-events-bridge'], function($, moment) { | ||
| 183 | return function(column, liveTableId, dateFormat) { | ||
| 184 | column = column || 'doc.date'; | ||
| 185 | column = column.replace(/^doc\./, 'doc_'); | ||
| 186 | dateFormat = dateFormat || 'YYYY/MM/DD HH:mm'; | ||
| 187 | var eventName = 'xwiki:livetable:newrow'; | ||
| 188 | if (liveTableId) { | ||
| 189 | eventName = 'xwiki:livetable:' + liveTableId + ':newrow'; | ||
| 190 | } | ||
| 191 | $(document).on(eventName, function(event, data) { | ||
| 192 | var dateString = data.data[column]; | ||
| 193 | var timeAgo = moment(dateString, dateFormat).fromNow(); | ||
| 194 | $(data.row).find('td.' + column).html(timeAgo); | ||
| 195 | }); | ||
| 196 | }; | ||
| 197 | }); | ||
| 198 | {{/code}} | ||
| 199 | |||
| 200 | = How can we package WebJars? = | ||
| 201 | |||
| 202 | Unfortunately there's no dedicated/integrated Maven plugin for packaging a WebJar so we need to mix a couple of standard Maven plugins: | ||
| 203 | |||
| 204 | * We put the resources in ##src/main/resources## as expected for a Maven project | ||
| 205 | ** ##src/main/resources/livetable-timeago.js## | ||
| 206 | * Copy the WebJar resources to the right path before packing the jar((( | ||
| 207 | {{code language="xml"}} | ||
| 208 | <plugin> | ||
| 209 | <artifactId>maven-resources-plugin</artifactId> | ||
| 210 | <executions> | ||
| 211 | <execution> | ||
| 212 | <id>copy-webjar-resources</id> | ||
| 213 | <phase>validate</phase> | ||
| 214 | <goals> | ||
| 215 | <goal>resources</goal> | ||
| 216 | </goals> | ||
| 217 | <configuration> | ||
| 218 | <!-- Follow the specifications regarding the WebJar content path. --> | ||
| 219 | <outputDirectory> | ||
| 220 | ${project.build.outputDirectory}/META-INF/resources/webjars/${project.artifactId}/${project.version} | ||
| 221 | </outputDirectory> | ||
| 222 | </configuration> | ||
| 223 | </execution> | ||
| 224 | </executions> | ||
| 225 | </plugin> | ||
| 226 | {{/code}} | ||
| 227 | ))) | ||
| 228 | * Package the WebJar resources as a JAR((( | ||
| 229 | {{code language="xml"}} | ||
| 230 | <plugin> | ||
| 231 | <groupId>org.apache.maven.plugins</groupId> | ||
| 232 | <artifactId>maven-jar-plugin</artifactId> | ||
| 233 | <configuration> | ||
| 234 | <includes> | ||
| 235 | <!-- Include only the WebJar content --> | ||
| 236 | <include>META-INF/**</include> | ||
| 237 | </includes> | ||
| 238 | </configuration> | ||
| 239 | </plugin> | ||
| 240 | {{/code}} | ||
| 241 | ))) | ||
| 242 | |||
| 243 | = Why should we package WebJars? = | ||
| 244 | |||
| 245 | * See [[Why should we use WebJars?>>Documentation.DevGuide.FrontendResources.IntegratingJavaScriptLibraries.WebHome]] | ||
| 246 | * Group resources by functionality | ||
| 247 | * State dependencies clearly | ||
| 248 | * Apply quality tools | ||
| 249 | ** Static code verification (JSHint) | ||
| 250 | ** Unit and integration tests (Jasmine) | ||
| 251 | ** Minification (YUI Compressor) | ||
| 252 | |||
| 253 | Let's add some quality tools. | ||
| 254 | |||
| 255 | = What is JSHint? = | ||
| 256 | |||
| 257 | [[JSHint>>http://jshint.com/]] is a tool that helps to detect errors and potential problems in your JavaScript code. | ||
| 258 | |||
| 259 | {{code language="none"}} | ||
| 260 | [ERROR] 3,18: This function has too many parameters. (4) | ||
| 261 | [ERROR] 6,50: Missing semicolon. | ||
| 262 | [ERROR] 11,18: Blocks are nested too deeply. (3) | ||
| 263 | [ERROR] 16,5: 'foo' is not defined. | ||
| 264 | {{/code}} | ||
| 265 | |||
| 266 | = How can we use JSHint? = | ||
| 267 | |||
| 268 | {{code language="xml"}} | ||
| 269 | <plugin> | ||
| 270 | <groupId>com.cj.jshintmojo</groupId> | ||
| 271 | <artifactId>jshint-maven-plugin</artifactId> | ||
| 272 | <version>1.3.0</version> | ||
| 273 | <executions> | ||
| 274 | <execution> | ||
| 275 | <goals> | ||
| 276 | <goal>lint</goal> | ||
| 277 | </goals> | ||
| 278 | </execution> | ||
| 279 | </executions> | ||
| 280 | <configuration> | ||
| 281 | <globals>require,define,document</globals> | ||
| 282 | <!-- See http://jshint.com/docs/options/ --> | ||
| 283 | <options>maxparams:3,maxdepth:2,eqeqeq,undef,unused,immed,latedef,noarg,noempty,nonew</options> | ||
| 284 | <directories> | ||
| 285 | <directory>src/main/resources</directory> | ||
| 286 | </directories> | ||
| 287 | </configuration> | ||
| 288 | </plugin> | ||
| 289 | {{/code}} | ||
| 290 | |||
| 291 | = What is Jasmine? = | ||
| 292 | |||
| 293 | * [[Jasmine>>http://jasmine.github.io/]] is a DOM-less simple JavaScript testing framework | ||
| 294 | * It does not rely on browsers, DOM, or any JavaScript framework | ||
| 295 | * It has a Maven plugin | ||
| 296 | * Not as nice as Mockito on Java but still very useful | ||
| 297 | |||
| 298 | {{code language="js"}} | ||
| 299 | describe("A suite", function() { | ||
| 300 | it("contains spec with an expectation", function() { | ||
| 301 | expect(true).toBe(true); | ||
| 302 | }); | ||
| 303 | }); | ||
| 304 | {{/code}} | ||
| 305 | |||
| 306 | = How can we use Jasmine? = | ||
| 307 | |||
| 308 | Let's add an unit test in ##src/test/javascript/livetable-timeago.js##: | ||
| 309 | |||
| 310 | {{code language="js"}} | ||
| 311 | // Mock module dependencies. | ||
| 312 | |||
| 313 | var $ = jasmine.createSpy('$'); | ||
| 314 | define('jquery', [], function() { | ||
| 315 | return $; | ||
| 316 | }); | ||
| 317 | |||
| 318 | var moment = jasmine.createSpy('moment'); | ||
| 319 | define('moment', [], function() { | ||
| 320 | return moment; | ||
| 321 | }); | ||
| 322 | |||
| 323 | define('xwiki-events-bridge', [], {}); | ||
| 324 | |||
| 325 | // Unit tests | ||
| 326 | |||
| 327 | define(['livetable-timeago'], function(timeAgo) { | ||
| 328 | describe('Live Table Time Ago module', function() { | ||
| 329 | it('Change date to time ago using defaults', function() { | ||
| 330 | // Setup mocks. | ||
| 331 | var $doc = jasmine.createSpyObj('$doc', ['on']); | ||
| 332 | var $row = jasmine.createSpyObj('$row', ['find']); | ||
| 333 | var $cell = jasmine.createSpyObj('$cell', ['html']); | ||
| 334 | |||
| 335 | var eventData = { | ||
| 336 | data: {doc_date: '2015/07/19 12:35'}, | ||
| 337 | row: {} | ||
| 338 | }; | ||
| 339 | |||
| 340 | $.andCallFake(function(selector) { | ||
| 341 | if (selector === document) { | ||
| 342 | return $doc; | ||
| 343 | } else if (selector === eventData.row) { | ||
| 344 | return $row; | ||
| 345 | } else if (selector === 'td.doc_date') { | ||
| 346 | return $cell; | ||
| 347 | } | ||
| 348 | }); | ||
| 349 | |||
| 350 | $doc.on.andCallFake(function(eventName, listener) { | ||
| 351 | eventName == 'xwiki:livetable:newrow' && listener(null, eventData); | ||
| 352 | }); | ||
| 353 | |||
| 354 | $row.find.andCallFake(function(selector) { | ||
| 355 | if (selector === 'td.doc_date') { | ||
| 356 | return $cell; | ||
| 357 | } | ||
| 358 | }); | ||
| 359 | |||
| 360 | var momentObj = jasmine.createSpyObj('momentObj', ['fromNow']); | ||
| 361 | moment.andCallFake(function(dateString, dateFormat) { | ||
| 362 | if (dateString === eventData.data.doc_date && dateFormat === 'YYYY/MM/DD HH:mm') { | ||
| 363 | return momentObj; | ||
| 364 | } | ||
| 365 | }); | ||
| 366 | |||
| 367 | var timeAgoDate = '1 day ago'; | ||
| 368 | momentObj.fromNow.andReturn(timeAgoDate); | ||
| 369 | |||
| 370 | // Run the operation. | ||
| 371 | timeAgo(); | ||
| 372 | |||
| 373 | // Verify the results. | ||
| 374 | expect($cell.html).toHaveBeenCalledWith(timeAgoDate); | ||
| 375 | }); | ||
| 376 | }); | ||
| 377 | }); | ||
| 378 | {{/code}} | ||
| 379 | |||
| 380 | We use the Jasmine Maven plugin to run the tests: | ||
| 381 | |||
| 382 | {{code language="xml"}} | ||
| 383 | <plugin> | ||
| 384 | <groupId>com.github.searls</groupId> | ||
| 385 | <artifactId>jasmine-maven-plugin</artifactId> | ||
| 386 | <executions> | ||
| 387 | <execution> | ||
| 388 | <goals> | ||
| 389 | <goal>test</goal> | ||
| 390 | </goals> | ||
| 391 | </execution> | ||
| 392 | </executions> | ||
| 393 | <configuration> | ||
| 394 | <specRunnerTemplate>REQUIRE_JS</specRunnerTemplate> | ||
| 395 | <preloadSources> | ||
| 396 | <source>webjars/require.js</source> | ||
| 397 | </preloadSources> | ||
| 398 | <jsSrcDir>${project.basedir}/src/main/resources</jsSrcDir> | ||
| 399 | <timeout>10</timeout> | ||
| 400 | </configuration> | ||
| 401 | </plugin> | ||
| 402 | {{/code}} | ||
| 403 | |||
| 404 | = Related = | ||
| 405 | |||
| 406 | {{velocity}} | ||
| 407 | #set ($tag = 'javascript') | ||
| 408 | #set ($list = $xwiki.tag.getDocumentsWithTag($tag)) | ||
| 409 | ((( | ||
| 410 | (% class="xapp" %) | ||
| 411 | === $services.localization.render('xe.tag.alldocs', ["//${tag}//"]) === | ||
| 412 | |||
| 413 | #if ($list.size()> 0) | ||
| 414 | {{html}}#displayDocumentList($list false $blacklistedSpaces){{/html}} | ||
| 415 | #else | ||
| 416 | (% class='noitems' %)$services.localization.render('xe.tag.notags') | ||
| 417 | #end | ||
| 418 | ))) | ||
| 419 | {{/velocity}} |