Wiki source code of Integrating JavaScript Libraries
Last modified by Vincent Massol on 2023/10/10
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}} |