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