fcad-core-dragon 2.0.0-beta.1 → 2.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/.editorconfig +33 -33
  2. package/.eslintignore +29 -29
  3. package/{.eslintrc.js → .eslintrc.cjs} +81 -86
  4. package/CHANGELOG +364 -364
  5. package/README.md +71 -71
  6. package/bk.scss +117 -0
  7. package/package.json +61 -63
  8. package/src/$locales/en.json +143 -179
  9. package/src/$locales/fr.json +105 -181
  10. package/src/assets/data/onboardingMessages.json +47 -47
  11. package/src/components/AppBase.vue +1054 -614
  12. package/src/components/AppBaseButton.vue +87 -63
  13. package/src/components/AppBaseErrorDisplay.vue +438 -420
  14. package/src/components/AppBaseFlipCard.vue +84 -83
  15. package/src/components/AppBaseModule.vue +1673 -1842
  16. package/src/components/AppBasePage.vue +779 -312
  17. package/src/components/AppBasePopover.vue +41 -0
  18. package/src/components/AppCompAudio.vue +234 -0
  19. package/src/components/AppCompBranchButtons.vue +552 -582
  20. package/src/components/AppCompButtonProgress.vue +126 -147
  21. package/src/components/AppCompCarousel.vue +298 -192
  22. package/src/components/AppCompInputCheckBoxNext.vue +195 -0
  23. package/src/components/AppCompInputDropdownNext.vue +159 -0
  24. package/src/components/AppCompInputRadioNext.vue +152 -0
  25. package/src/components/{AppCompInputTextBox.vue → AppCompInputTextNext.vue} +106 -91
  26. package/src/components/AppCompInputTextTableNext.vue +141 -0
  27. package/src/components/AppCompInputTextToFillDropdownNext.vue +230 -0
  28. package/src/components/{AppCompInputTextToFillText.vue → AppCompInputTextToFillNext.vue} +171 -164
  29. package/src/components/AppCompJauge.vue +74 -55
  30. package/src/components/AppCompMenu.vue +413 -209
  31. package/src/components/AppCompMenuItem.vue +228 -174
  32. package/src/components/AppCompNavigation.vue +960 -949
  33. package/src/components/AppCompNoteCall.vue +133 -126
  34. package/src/components/AppCompNoteCredit.vue +292 -164
  35. package/src/components/AppCompPlayBar.vue +1218 -1319
  36. package/src/components/AppCompPlayBarNext.vue +2052 -0
  37. package/src/components/AppCompPlayBarProgress.vue +82 -0
  38. package/src/components/AppCompPopUpNext.vue +503 -0
  39. package/src/components/{AppCompQuiz.vue → AppCompQuizNext.vue} +2904 -2989
  40. package/src/components/AppCompQuizRecall.vue +276 -250
  41. package/src/components/AppCompSVGNext.vue +347 -0
  42. package/src/components/AppCompSettingsMenu.vue +172 -171
  43. package/src/components/AppCompTableOfContent.vue +387 -264
  44. package/src/components/AppCompTranscript.vue +24 -19
  45. package/src/components/AppCompVideoPlayer.vue +368 -336
  46. package/src/components/AppCompViewDisplay.vue +6 -6
  47. package/src/components/BaseModule.vue +72 -67
  48. package/src/composables/useQuiz.js +206 -0
  49. package/src/externalComps/ModuleView.vue +22 -0
  50. package/src/externalComps/SummaryView.vue +91 -0
  51. package/src/main.js +272 -227
  52. package/src/mixins/$mediaMixins.js +819 -0
  53. package/src/mixins/timerMixin.js +155 -156
  54. package/src/module/stores/appStore.js +893 -0
  55. package/src/module/xapi/ADL.js +376 -339
  56. package/src/module/xapi/Crypto/Hasher.js +241 -241
  57. package/src/module/xapi/Crypto/WordArray.js +278 -278
  58. package/src/module/xapi/Crypto/algorithms/BufferedBlockAlgorithm.js +103 -103
  59. package/src/module/xapi/Crypto/algorithms/C_algo.js +315 -319
  60. package/src/module/xapi/Crypto/algorithms/HMAC.js +9 -9
  61. package/src/module/xapi/Crypto/algorithms/SHA1.js +9 -9
  62. package/src/module/xapi/Crypto/encoders/Base.js +105 -105
  63. package/src/module/xapi/Crypto/encoders/Base64.js +99 -99
  64. package/src/module/xapi/Crypto/encoders/Hex.js +61 -61
  65. package/src/module/xapi/Crypto/encoders/Latin1.js +61 -61
  66. package/src/module/xapi/Crypto/encoders/Utf8.js +45 -45
  67. package/src/module/xapi/Crypto/index.js +53 -53
  68. package/src/module/xapi/Statement/activity.js +47 -47
  69. package/src/module/xapi/Statement/agent.js +55 -55
  70. package/src/module/xapi/Statement/group.js +26 -26
  71. package/src/module/xapi/Statement/index.js +259 -259
  72. package/src/module/xapi/Statement/statement.js +253 -253
  73. package/src/module/xapi/Statement/statementRef.js +23 -23
  74. package/src/module/xapi/Statement/substatement.js +22 -22
  75. package/src/module/xapi/Statement/verb.js +36 -36
  76. package/src/module/xapi/activitytypes.js +17 -17
  77. package/src/module/xapi/launch.js +157 -157
  78. package/src/module/xapi/utils.js +167 -167
  79. package/src/module/xapi/verbs.js +294 -294
  80. package/src/module/xapi/wrapper.js +1963 -1890
  81. package/src/module/xapi/xapiStatement.js +444 -444
  82. package/src/plugins/bus.js +8 -3
  83. package/src/plugins/gsap.js +14 -17
  84. package/src/plugins/helper.js +308 -295
  85. package/src/plugins/i18n.js +44 -31
  86. package/src/plugins/idb.js +219 -212
  87. package/src/plugins/save.js +37 -37
  88. package/src/plugins/scorm.js +287 -287
  89. package/src/plugins/xapi.js +11 -11
  90. package/src/public/index.html +33 -21
  91. package/src/router/index.js +43 -41
  92. package/src/router/routes.js +312 -337
  93. package/src/shared/generalfuncs.js +210 -188
  94. package/src/shared/validators.js +1069 -249
  95. package/vite.config.js +27 -0
  96. package/.prettierrc.js +0 -5
  97. package/babel.config.js +0 -3
  98. package/src/components/AppBaseDragChoice.vue +0 -91
  99. package/src/components/AppBaseDropZone.vue +0 -112
  100. package/src/components/AppCompBif.vue +0 -120
  101. package/src/components/AppCompDragAndDrop.vue +0 -339
  102. package/src/components/AppCompInputAssociation.vue +0 -332
  103. package/src/components/AppCompInputCheckBox.vue +0 -227
  104. package/src/components/AppCompInputDropdown.vue +0 -184
  105. package/src/components/AppCompInputRadio.vue +0 -169
  106. package/src/components/AppCompInputTextTable.vue +0 -155
  107. package/src/components/AppCompInputTextToFillDropdown.vue +0 -255
  108. package/src/components/AppCompMediaPlayer.vue +0 -397
  109. package/src/components/AppCompPopUp.vue +0 -522
  110. package/src/components/AppCompPopover.vue +0 -27
  111. package/src/components/AppCompSVG.vue +0 -309
  112. package/src/mixins/$pageMixins.js +0 -459
  113. package/src/mixins/$quizMixins.js +0 -456
  114. package/src/module/store.js +0 -895
  115. package/src/plugins/timeManager.js +0 -77
  116. package/src/routes_bckp.js +0 -313
  117. package/src/routes_static.js +0 -344
  118. package/vue.config.js +0 -83
@@ -1,614 +1,1054 @@
1
- <!--
2
- @ Description: This is a root component to create the App
3
- @ What it does: Many things
4
- @ Must be used.
5
- -->
6
- <template>
7
- <div id="App-base" fluid :class="{ iPad: resizeiPad }">
8
- <transition name="bounce" mode="in-out">
9
- <div
10
- v-if="showBuildInfo && !buildInfoClicked"
11
- id="build-info"
12
- @click="buildInfoClicked = true"
13
- >
14
- <span>{{ getModuleInfo.courseID.toUpperCase() }}</span>
15
- <span>FCAD {{ this.$helper.getFcadVersion() }}</span>
16
- <span>{{ this.$helper.getBuildTime() }}</span>
17
- </div>
18
- </transition>
19
-
20
- <router-view class="box" />
21
- <app-icons />
22
- </div>
23
- </template>
24
- <script>
25
- import { mapGetters } from 'vuex'
26
-
27
- export default {
28
- props: {
29
- appConfig: {
30
- type: Object,
31
- default: () => {
32
- return {
33
- lang: 'fr' // set language of App: default French
34
- }
35
- }
36
- }
37
- },
38
-
39
- data() {
40
- return {
41
- randKey: null,
42
- isLandScape: null,
43
- initialDeviceOrentation: null,
44
- appIsFullScreen: false,
45
- resizeiPad: false,
46
- buildInfoClicked: false
47
- }
48
- },
49
- computed: {
50
- ...mapGetters([
51
- 'getCurrentBrowser',
52
- 'getIsMobile',
53
- 'getDeviceType',
54
- 'getModuleInfo',
55
- 'getUserInteraction',
56
- 'getConnectionInfo'
57
- ]),
58
- getwidth() {
59
- return window.innerWidth
60
- },
61
- getheight() {
62
- return window.innerHeight
63
- },
64
- showBuildInfo() {
65
- return (
66
- process.env.NODE_ENV === 'production' &&
67
- window.location.host === 'projets.cegepadistance.ca'
68
- )
69
- }
70
- },
71
- watch: {
72
- getConnectionInfo: {
73
- //in development environment (localhost), don't wait for the axios call
74
-
75
- immediate: process.env.NODE_ENV === 'development',
76
- handler() {
77
- /**
78
- * Attach listener to detect when user navigates to a new page, switches tabs, closes the tab, minimizes or closes the browser, or, on mobile,
79
- * switches from the browser to a different app To save user data to LRS/LMS.
80
- * As of 06/2021 MDN Web Doc advise preferring the 'visibilitychange' event over 'unload/beforeunload'
81
- * event to send data over a serveur.
82
- * Ref:https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event
83
- * https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon
84
- */
85
-
86
- if (this.getIsMobile) {
87
- document.addEventListener(
88
- 'visibilitychange',
89
- () => {
90
- if (document.visibilityState === 'hidden') {
91
- this.executeCloseEventTriggered()
92
- }
93
- },
94
- false
95
- )
96
- } else {
97
- //attached listener to terminate LMS|LRS communication
98
- if (
99
- this.getModuleInfo.packageType === 'xapi' &&
100
- this.getConnectionInfo &&
101
- this.getConnectionInfo.actor &&
102
- this.getConnectionInfo.remote
103
- ) {
104
- window.addEventListener('beforeunload', (e) => {
105
- this.executeCloseEventTriggered()
106
- })
107
- } else
108
- window.addEventListener(
109
- 'beforeunload',
110
- this.executeCloseEventTriggered
111
- )
112
- }
113
- // initialize scorm Commuinication with LMS || xAPI LRS
114
- if (this.getModuleInfo.packageType === 'scorm') this.$scorm.Initialize()
115
- else if (
116
- this.getModuleInfo.packageType === 'xapi' &&
117
- this.getConnectionInfo &&
118
- this.getConnectionInfo.actor &&
119
- this.getConnectionInfo.remote
120
- ) {
121
- const {
122
- auth,
123
- endpoint,
124
- registration = this.$xapi.ruuid()
125
- } = this.getConnectionInfo
126
-
127
- const config = {
128
- auth,
129
- endpoint,
130
- registration,
131
- activity_platform: `SIPI_organizationId`
132
- }
133
-
134
- this.$xapi._configLRS(config) // configure and launch LRS
135
- this.fetchSettingsFromServer()
136
- }
137
- }
138
- }
139
- },
140
-
141
- created() {
142
- this.setProgress()
143
- window.versionFCAD = this.$helper.getFcadVersionString()
144
- //check if this is running in a mobile environment and register state in the store
145
- const mobileDetect = require('mobile-detect')
146
-
147
- const md = new mobileDetect(window.navigator.userAgent)
148
- md.mobile()
149
- ? this.$store.dispatch('setMobileState', true)
150
- : this.$store.dispatch('setMobileState', false)
151
- const currentBrowser = this.getBrowser() //get current browser vendor
152
-
153
- // register the current browser in the store
154
- this.$store.dispatch('setCurrentBrowser', currentBrowser)
155
-
156
- // register device type running the App in the store (ios, Android or Deskop)
157
- this.$store.dispatch('setDeviceType', this.detectDevice())
158
- this.fetchSettingsFromServer()
159
- },
160
- mounted() {
161
- // set the language of the app
162
- this.setLocale(this.appConfig.lang)
163
- this.$bus.$on('set-comp-status', (name, status) =>
164
- this.updateTracker(name, status)
165
- )
166
-
167
- this.$bus.$on('set-user-progress', this.setProgress())
168
- },
169
- beforeDestroy() {
170
- this.$bus.$off('set-comp-status')
171
- },
172
- methods: {
173
- /**
174
- * @description set the desired language for the app default is french
175
- * @param {String} [lang=fr]
176
- */
177
- setLocale(lang) {
178
- if (!lang) lang = 'fr'
179
- else {
180
- lang = lang.toLowerCase()
181
- if (lang === 'français' || lang === 'francais' || lang === 'french')
182
- lang = 'fr'
183
- else if (lang === 'english' || lang === 'anglais') lang = 'en'
184
- else lang = lang.substring(0, 2).toLowerCase()
185
- this.$i18n.locale = lang
186
- }
187
- },
188
- getScormState() {
189
- return this.$scorm.GetValue('cmi.suspend_data')
190
- },
191
- getBrowser() {
192
- let browser
193
- const test = (regexp) => regexp.test(window.navigator.userAgent) //defining the testing fonction
194
-
195
- switch (true) {
196
- case test(/edg/i):
197
- browser = 'Edge'
198
- break
199
- case test(/trident/i):
200
- browser = 'IE'
201
- break
202
- case test(/firefox|fxios/i):
203
- browser = 'Firefox'
204
- break
205
- case test(/opr\//i):
206
- browser = 'Opera'
207
- break
208
- case test(/ucbrowser/i):
209
- browser = 'UC Browser'
210
- break
211
- case test(/samsungbrowser/i):
212
- browser = 'Samsung Browser'
213
- break
214
- case test(/chrome|chromium|crios/i):
215
- if (navigator.brave && navigator.brave.isBrave()) browser = 'Brave'
216
- // as of 2020-11 Brave adds a Class brave in the navigator object
217
- else browser = 'Chrome'
218
- break
219
- case test(/safari/i):
220
- browser = 'Safari'
221
- break
222
- default:
223
- browser = 'Other'
224
- break
225
- }
226
- // Return browser initiale
227
- return browser
228
- },
229
-
230
- detectDevice() {
231
- let device = null
232
- if (
233
- navigator.appVersion.includes('iPad') ||
234
- navigator.appVersion.includes('iPhone')
235
- )
236
- device = 'iOSDevice'
237
- else if (navigator.appVersion.includes('Android'))
238
- device = 'AndroidDevice'
239
- else device = 'Desktop'
240
-
241
- return device
242
- },
243
-
244
- fetchSettingsFromServer() {
245
- if (this.getModuleInfo.packageType !== 'xapi') return
246
- if (!this.getConnectionInfo || !this.getConnectionInfo.remote) return
247
-
248
- const activity_id = this.getModuleInfo.courseID
249
- ? this.getConnectionInfo.activity_id.replace(
250
- `/${this.getModuleInfo.id}`,
251
- ''
252
- )
253
- : this.getConnectionInfo.activity_id
254
-
255
- //Get existing record for user preferred settings
256
- const applicationSettings = this.$xapi._getPreferredSettings(
257
- this.getConnectionInfo.actor.mbox.replace('mailto:', ''),
258
- activity_id
259
- )
260
-
261
- if (applicationSettings.length && applicationSettings[0].userSettings)
262
- //save result to store
263
- this.$store.dispatch(
264
- 'setApplicationSettings',
265
- applicationSettings[0].userSettings
266
- )
267
-
268
- //Get existing record for play bar settings
269
- const pbValues = this.$xapi._getPlaybarValues(
270
- this.getConnectionInfo.actor.mbox.replace('mailto:', ''),
271
- activity_id
272
- )
273
-
274
- if (pbValues.length) {
275
- //save result to store
276
- this.$store.dispatch('setMediaVolume', pbValues[0].volume)
277
- this.$store.dispatch('setMediaSubtitles', pbValues[0].subtitlesEnabled)
278
- }
279
- },
280
-
281
- executeCloseEventTriggered() {
282
- if (
283
- this.getModuleInfo.packageType === 'scorm' &&
284
- this.$scorm.initialized
285
- ) {
286
- this.$bus.$emit('save-to-scorm') // emit event to save to scorm before closing the app
287
- this.$scorm.Finish()
288
- }
289
- //Xapi context
290
- else if (
291
- this.getModuleInfo.packageType === 'xapi' &&
292
- this.getConnectionInfo &&
293
- this.getConnectionInfo.actor &&
294
- this.getConnectionInfo.remote
295
- ) {
296
- // Communicate ending of activity
297
- this.$bus.$emit('fire-exit-event', null, true)
298
- this.routeChangeCounter = 0 //reset counter after saving
299
- }
300
- },
301
- setProgress() {
302
- this.updateTracker('appBase', 'loading')
303
- const packageType = this.getModuleInfo.packageType
304
-
305
- switch (packageType) {
306
- case 'scorm':
307
- // Get saved data from suspend_data or from localStorage to update the store:
308
- // LMS is connected
309
- if (this.$scorm.initialized) {
310
- // chacked if there is a existing record in the LMS
311
- if (this.$scorm.GetValue('cmi.suspend_data') !== '') {
312
- const scormRecord = this.$scorm
313
- .GetValue('cmi.suspend_data')
314
- .replace(/\\/g, '')
315
- const userProgress = JSON.parse(scormRecord).userData
316
- const routeHistory = JSON.parse(scormRecord).routeHistory
317
-
318
- this.$store.dispatch('setUserMetaData', userProgress)
319
- this.$store.dispatch('setRouteHistory', routeHistory) // update store recored with existing record
320
- this.updateTracker('appBase', 'ready')
321
- }
322
-
323
- // No LMS use LocalStorage record
324
- } else {
325
- this.$idb.openDB().then(() => {
326
- this.$idb.getFromDB(this.getModuleInfo.idbID).then((res) => {
327
- if (res && res.$record) {
328
- const {
329
- routeHistory,
330
- userSettings,
331
- ...userData
332
- } = res.$record
333
- this.$store.dispatch('setUserMetaData', userData) // update store record with existing record
334
- this.$store.dispatch('setRouteHistory', routeHistory) // update store record with existing route info
335
- if (userSettings)
336
- this.$store.dispatch('setApplicationSettings', userSettings) // update store record with existing user settings
337
- this.updateTracker('appBase', 'ready')
338
- }
339
- })
340
- })
341
- }
342
-
343
- break
344
-
345
- case 'xapi':
346
- {
347
- if (
348
- this.getConnectionInfo &&
349
- this.getConnectionInfo.actor &&
350
- this.getConnectionInfo.remote
351
- ) {
352
- //Try to get the user progress from LRS
353
- const userProgress = this.$xapi._getProgress(
354
- this.getConnectionInfo.actor.mbox.replace('mailto:', ''),
355
- this.getConnectionInfo.activity_id
356
- )
357
- if (userProgress) {
358
- const { routeHistory, ...userData } = userProgress
359
- // console.log('Progress from Server: ', {
360
- // userData,
361
- // routeHistory
362
- // })
363
- this.$store.dispatch('setUserMetaData', userData) // update store record with existing record
364
- this.$store.dispatch('setRouteHistory', routeHistory) // update store record with existing record
365
- }
366
- this.updateTracker('appBase', 'ready')
367
- } else {
368
- //Get existing records for user data in local store
369
- this.$idb.openDB().then(() => {
370
- this.$idb
371
- .getFromDB(this.getModuleInfo.idbID)
372
- .then((res) => {
373
- if (res && res.$record) {
374
- const {
375
- routeHistory,
376
- userSettings,
377
- ...userData
378
- } = res.$record
379
-
380
- this.$store.dispatch('setUserMetaData', userData) // update store record with existing record
381
- this.$store.dispatch('setRouteHistory', routeHistory) // update store record with existing route info
382
-
383
- if (userSettings) {
384
- this.$store.dispatch(
385
- 'setApplicationSettings',
386
- userSettings
387
- ) // update store record with existing user setting
388
- }
389
- }
390
- })
391
- .then(() => this.updateTracker('appBase', 'ready'))
392
- })
393
- }
394
- }
395
-
396
- break
397
- }
398
- },
399
- updateTracker(name, status) {
400
- this.$store.dispatch('updateCompStatusTracker', { name, status })
401
- }
402
- }
403
- }
404
- </script>
405
- <style lang="scss">
406
- #App-base {
407
- width: 100%;
408
- height: 100vh;
409
-
410
- #build-info {
411
- opacity: 0.9;
412
- position: fixed;
413
- right: 0;
414
- bottom: 0;
415
- color: #fff;
416
- text-shadow: -1px 1px 1px rgba(0, 0, 0, 0.4);
417
- background-color: hotpink;
418
- font-family: serif, Impact, Arial;
419
- font-size: 11px;
420
- padding: 3px;
421
- cursor: pointer;
422
- z-index: 999;
423
- span {
424
- text-align: center;
425
- display: block;
426
- }
427
- }
428
-
429
- .module {
430
- width: 100%;
431
- height: 100%;
432
- position: relative;
433
- display: flex;
434
- flex-direction: row;
435
- align-items: stretch;
436
-
437
- .navbar {
438
- position: absolute;
439
- top: 0;
440
- left: 0;
441
- z-index: 10;
442
- display: flex;
443
- flex-direction: column;
444
- flex-wrap: wrap;
445
- align-content: start;
446
- width: 67px;
447
- height: 100%;
448
- }
449
-
450
- .module-wrapper {
451
- width: 100%;
452
- position: relative;
453
-
454
- #wrapper-content {
455
- width: 100%;
456
- position: relative;
457
- padding-right: 0 !important;
458
- padding-left: 0 !important;
459
-
460
- &.active {
461
- overflow: hidden;
462
- }
463
-
464
- .box {
465
- width: 100%;
466
- height: 95%;
467
-
468
- .app-page {
469
- width: 100%;
470
- margin-top: 60px;
471
-
472
- .row {
473
- width: 100%;
474
- margin-right: 0;
475
- margin-left: 0;
476
- }
477
- }
478
- }
479
- }
480
-
481
- .app-comp-table-of-content {
482
- display: -webkit-flex;
483
- display: flex;
484
- flex-direction: column;
485
- flex-wrap: wrap;
486
- position: absolute;
487
- top: 0px;
488
- min-height: 100%;
489
- z-index: 1;
490
- }
491
- }
492
-
493
- .app-nav {
494
- .md-controller {
495
- display: -webkit-flex;
496
- display: flex;
497
- flex-direction: row;
498
- align-items: baseline;
499
- //width: $widthPlayer;
500
- width: 50%;
501
-
502
- .ctrl-play {
503
- display: -webkit-flex;
504
- display: flex;
505
- flex-direction: row;
506
- align-items: baseline;
507
- //width: $widthPlayer;
508
- width: 50%;
509
-
510
- #progress-bar {
511
- display: block;
512
- //width: $widthProgressBar;
513
- width: 150px;
514
- //height: $heigthProgressBar;
515
- height: 10px;
516
- position: relative;
517
- overflow: hidden;
518
-
519
- #progress {
520
- display: block;
521
- height: 100%;
522
- background-color: red;
523
- }
524
-
525
- #progress-shaddow {
526
- display: block;
527
- height: 100%;
528
- position: absolute;
529
- top: 0px;
530
- left: 0px;
531
- z-index: -1;
532
- }
533
- }
534
- }
535
-
536
- .ctrl-subtitle {
537
- //width: $widthCtrSubtitle;
538
- width: 10%;
539
- }
540
-
541
- .ctrl-sound {
542
- display: -webkit-flex;
543
- display: flex;
544
- flex-direction: row;
545
- align-items: baseline;
546
- justify-content: space-between;
547
- //width: $widthCtrlSound;
548
- width: 40%;
549
- }
550
- }
551
- }
552
- }
553
- }
554
-
555
- .macWarn-overlay {
556
- position: absolute;
557
- top: 0px;
558
- left: 0px;
559
- z-index: 99999;
560
- width: 100%;
561
- height: 100vh;
562
- display: none;
563
- background-color: rgba(0, 0, 0, 0.92);
564
- opacity: 0;
565
- &.warningPortait {
566
- display: block;
567
- opacity: 1;
568
- }
569
-
570
- .macWarn-box {
571
- width: 50%;
572
- position: absolute;
573
-
574
- top: calc(45% - 15% / 2);
575
- left: 25%;
576
-
577
- h1 {
578
- text-align: center;
579
- color: #fff !important;
580
- }
581
-
582
- p {
583
- text-align: center;
584
- font-size: 1.3rem;
585
- color: #fff !important;
586
- }
587
-
588
- #btn-warning-ok {
589
- display: block;
590
- margin: 10px auto;
591
- }
592
- }
593
- }
594
- .fade-enter-active,
595
- .fade-leave-active {
596
- transition: opacity 0.3s ease;
597
- }
598
- .fade-enter, .fade-leave-to
599
- /* .component-fade-leave-active below version 2.1.8 */ {
600
- opacity: 0;
601
- }
602
-
603
- .app-icons-svg {
604
- width: 30px;
605
- height: 30px;
606
- fill: #fff;
607
- stroke: #fff;
608
- cursor: pointer;
609
- }
610
-
611
- .svg-hidden {
612
- display: none;
613
- }
614
- </style>
1
+ <!--
2
+ @ Description: This is a root component to create the App
3
+ @ What it does:
4
+ - validate the data for the App configuration
5
+ - Send the xapi statement
6
+ - Manage the fetching and setting of data from serveur
7
+ - Example of use: <app-base :app-config="$data"></app-base>
8
+ @ Must be used.
9
+ -->
10
+ <template>
11
+ <div id="App-base" fluid :class="{ iPad: resizeiPad }">
12
+ <template v-if="error.length">
13
+ <v-row>
14
+ <v-col>
15
+ <app-base-error-display
16
+ :errors-list="error"
17
+ error-type="appConfig"
18
+ error-title="Configuration de l'application"
19
+ />
20
+ </v-col>
21
+ </v-row>
22
+ </template>
23
+ <template v-else>
24
+ <transition name="bounce" mode="in-out">
25
+ <div
26
+ v-if="showBuildInfo && !buildInfoClicked"
27
+ id="build-info"
28
+ @click="buildInfoClicked = true"
29
+ >
30
+ <span>{{ getModuleInfo.courseID.toUpperCase() }}</span>
31
+ <span>FCAD {{ $helper.getFcadVersion() }}</span>
32
+ <span>{{ $helper.getBuildTime() }}</span>
33
+ </div>
34
+ </transition>
35
+
36
+ <router-view class="box" />
37
+
38
+ <app-icons-next :extra-icons="userExtraIcons" />
39
+ </template>
40
+
41
+ <v-overlay
42
+ id="overlay_loading"
43
+ :model-value="!appReady"
44
+ class="align-center justify-center"
45
+ scrim="white"
46
+ blur="3px"
47
+ opacity="0.8"
48
+ >
49
+ <div class="text-center grp-spinners">
50
+ <v-progress-circular
51
+ :size="50"
52
+ :width="3"
53
+ color="#003552"
54
+ bg-color="#d3effe"
55
+ indeterminate
56
+ ></v-progress-circular>
57
+
58
+ <span class="sr-only">
59
+ {{ $t('message.loading_state_msg') }}
60
+ </span>
61
+ </div>
62
+ </v-overlay>
63
+ </div>
64
+ </template>
65
+ <script>
66
+ import { mapState, mapActions } from 'pinia'
67
+ import { useAppStore } from '../module/stores/appStore.js'
68
+ import { timerMixin } from '../mixins/timerMixin'
69
+ import { validateAppContent } from '../shared/validators'
70
+ import AppBaseErrorDisplay from './AppBaseErrorDisplay.vue'
71
+ import mobileDetect from 'mobile-detect'
72
+ export default {
73
+ components: { AppBaseErrorDisplay },
74
+ mixins: [timerMixin],
75
+ props: {
76
+ appConfig: {
77
+ type: Object,
78
+ required: true,
79
+ validator: (value) => {
80
+ if (import.meta.env.DEV) return validateAppContent(value).length === 0
81
+ }
82
+ }
83
+ },
84
+ setup() {
85
+ const store = useAppStore()
86
+ return { store }
87
+ },
88
+
89
+ data() {
90
+ return {
91
+ randKey: null,
92
+ isLandScape: null,
93
+ initialDeviceOrentation: null,
94
+ appIsFullScreen: false,
95
+ resizeiPad: false,
96
+ buildInfoClicked: false,
97
+ error: []
98
+ }
99
+ },
100
+ computed: {
101
+ ...mapState(useAppStore, [
102
+ 'getCurrentBrowser',
103
+ 'getIsMobile',
104
+ 'getDeviceType',
105
+ 'getModuleInfo',
106
+ 'getAppStatus',
107
+ 'getUserInteraction',
108
+ 'getConnectionInfo',
109
+ 'getDataFromServer',
110
+ 'getAllActivities',
111
+ 'getMediaPlaybarValues',
112
+ 'getAppConfigs',
113
+ 'getErrorMenu'
114
+ ]),
115
+ getwidth() {
116
+ return window.innerWidth
117
+ },
118
+ getheight() {
119
+ return window.innerHeight
120
+ },
121
+
122
+ showBuildInfo() {
123
+ return (
124
+ import.meta.env.PROD &&
125
+ window.location.host === 'projets.cegepadistance.ca'
126
+ )
127
+ },
128
+ appReady() {
129
+ let readyState = this.getAppStatus === 'ready' ? true : false
130
+
131
+ return readyState
132
+ },
133
+ displayLang() {
134
+ let lang = false
135
+ const displayList = ['en-US', 'fr-FR', 'es-ES'] // list of xapi verbs language display
136
+
137
+ if (this.getModuleInfo.packageType === 'xapi') {
138
+ lang = displayList.find((l) => l.includes(this.$i18n.locale))
139
+ }
140
+ return lang
141
+ },
142
+ userExtraIcons() {
143
+ const icons = this.$helper.getSettingsFromStore('extra_icons')
144
+ ? this.$helper.getSettingsFromStore('extra_icons')
145
+ : null
146
+ return icons
147
+ }
148
+ },
149
+ watch: {
150
+ getConnectionInfo: {
151
+ deep: true,
152
+ //in development environment (localhost), don't wait for the axios call
153
+ immediate: import.meta.env.DEV,
154
+ handler() {
155
+ /**
156
+ * Attach listener to detect when user navigates to a new page, switches tabs, closes the tab, minimizes or closes the browser, or, on mobile,
157
+ * switches from the browser to a different app To save user data to LRS/LMS.
158
+ * As of 06/2021 MDN Web Doc advise preferring the 'visibilitychange' event over 'unload/beforeunload'
159
+ * event to send data over a serveur.
160
+ * Ref:https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event
161
+ * https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon
162
+ */
163
+
164
+ if (!this.getConnectionInfo) return
165
+ if (this.getIsMobile) {
166
+ document.addEventListener(
167
+ 'visibilitychange',
168
+ () => {
169
+ if (document.visibilityState === 'hidden') {
170
+ this.executeCloseEventTriggered()
171
+ }
172
+ },
173
+ false
174
+ )
175
+ }
176
+ // initialize scorm Commuinication with LMS || xAPI LRS
177
+ if (this.getModuleInfo.packageType === 'scorm') this.$scorm.Initialize()
178
+ else if (
179
+ this.getModuleInfo.packageType === 'xapi' &&
180
+ this.getConnectionInfo &&
181
+ this.getConnectionInfo.actor &&
182
+ this.getConnectionInfo.remote
183
+ ) {
184
+ const {
185
+ auth,
186
+ endpoint,
187
+ registration = this.$xapi.ruuid()
188
+ } = this.getConnectionInfo
189
+
190
+ const config = {
191
+ auth,
192
+ endpoint,
193
+ registration,
194
+ activity_platform: `SIPI_organizationId`
195
+ }
196
+
197
+ this.$xapi._configLRS(config) // configure and launch LRS
198
+
199
+ setTimeout(() => {
200
+ this.fetchDataFromServer().then(() => {
201
+ setTimeout(() => {
202
+ this.setProgress()
203
+ }, 200)
204
+ })
205
+ }, 200)
206
+ } else this.setProgress()
207
+ }
208
+ }
209
+ },
210
+
211
+ created() {
212
+ if (import.meta.env.DEV) {
213
+ this.checkForErrors()
214
+ }
215
+ //Declare loading state if no error was detected
216
+
217
+ if (!this.error.length) {
218
+ this.updateTracker('appBase', 'loading')
219
+ this.setAppConfigs(this.appConfig)
220
+ }
221
+
222
+ window.versionFCAD = this.$helper.getFcadVersionString()
223
+ //check if this is running in a mobile environment and register state in the store
224
+ const md = new mobileDetect(window.navigator.userAgent)
225
+ md.mobile() !== null
226
+ ? this.setMobileState(true)
227
+ : this.setMobileState(false)
228
+ const currentBrowser = this.getBrowser() //get current browser vendor
229
+
230
+ // register the current browser in the store
231
+ this.setCurrentBrowser(currentBrowser)
232
+
233
+ // register device type running the App in the store (ios, Android or Deskop)
234
+ this.setDeviceType(this.detectDevice())
235
+
236
+ this.$bus.$on('set-comp-status', this.updateTracker)
237
+ this.$bus.$on('send-xapi-statement', this.sendXapiStatements)
238
+ this.$bus.$on('reset-userdata', this.resetUserData)
239
+ this.$bus.$on('fire-exit-event', this.endLesson)
240
+ this.$bus.$on('reset-focus-on', this.resetFocus)
241
+ this.$bus.$on('move-to-target', this.moveTo)
242
+ },
243
+ beforeMount() {
244
+ window.addEventListener(
245
+ 'beforeunload',
246
+ (e) => {
247
+ this.executeCloseEventTriggered()
248
+ // e.preventDefault()
249
+ // e.returnValue = true
250
+ },
251
+ true
252
+ )
253
+ },
254
+
255
+ mounted() {
256
+ // set the language of the app
257
+ this.setLocale(this.getAppConfigs.lang)
258
+ },
259
+ beforeUnmount() {
260
+ this.$bus.$off('set-comp-status', this.updateTracker)
261
+ this.$bus.$off('reset-userdata', this.resetUserData)
262
+ this.$bus.$off('fire-exit-event', this.endLesson)
263
+ this.$bus.$off('reset-focus-on', this.resetFocus)
264
+ this.$bus.$off('send-xapi-statement', this.sendXapiStatements)
265
+ this.$bus.$off('move-to-target', this.moveTo)
266
+
267
+ if (this.getAppConfigs.remote) this.unsubscribeToSetConfig() //stop watching changing in store to save to local storage
268
+ },
269
+ methods: {
270
+ ...mapActions(useAppStore, [
271
+ 'setDeviceType',
272
+ 'updateDataFetchFromServer',
273
+ 'setUserMetaData',
274
+ 'setRouteHistory',
275
+ 'setApplicationSettings',
276
+ 'setMediaPlaybarValues',
277
+ 'updateCompStatusTracker',
278
+ 'setAppConfigs',
279
+ 'setMobileState',
280
+ 'setCurrentBrowser'
281
+ ]),
282
+ /**
283
+ * @description set the desired language for the app default is french
284
+ * @param {String} [lang=fr]
285
+ */
286
+
287
+ setLocale(lang) {
288
+ if (!lang) lang = 'fr'
289
+ else {
290
+ lang = lang.toLowerCase()
291
+ if (lang === 'français' || lang === 'francais' || lang === 'french')
292
+ lang = 'fr'
293
+ else if (lang === 'english' || lang === 'anglais') lang = 'en'
294
+ else lang = lang.substring(0, 2).toLowerCase()
295
+ this.$i18n.locale = lang
296
+ }
297
+ },
298
+
299
+ getScormState() {
300
+ return this.$scorm.GetValue('cmi.suspend_data')
301
+ },
302
+
303
+ getBrowser() {
304
+ let browser
305
+ const test = (regexp) => regexp.test(window.navigator.userAgent) //defining the testing fonction
306
+
307
+ switch (true) {
308
+ case test(/edg/i):
309
+ browser = 'Edge'
310
+ break
311
+ case test(/trident/i):
312
+ browser = 'IE'
313
+ break
314
+ case test(/firefox|fxios/i):
315
+ browser = 'Firefox'
316
+ break
317
+ case test(/opr\//i):
318
+ browser = 'Opera'
319
+ break
320
+ case test(/ucbrowser/i):
321
+ browser = 'UC Browser'
322
+ break
323
+ case test(/samsungbrowser/i):
324
+ browser = 'Samsung Browser'
325
+ break
326
+ case test(/chrome|chromium|crios/i):
327
+ if (navigator.brave && navigator.brave.isBrave()) browser = 'Brave'
328
+ // as of 2020-11 Brave adds a Class brave in the navigator object
329
+ else browser = 'Chrome'
330
+ break
331
+ case test(/safari/i):
332
+ browser = 'Safari'
333
+ break
334
+ default:
335
+ browser = 'Other'
336
+ break
337
+ }
338
+ // Return browser initiale
339
+ return browser
340
+ },
341
+
342
+ detectDevice() {
343
+ let device = null
344
+ if (
345
+ navigator.appVersion.includes('iPad') ||
346
+ navigator.appVersion.includes('iPhone')
347
+ )
348
+ device = 'iOSDevice'
349
+ else if (navigator.appVersion.includes('Android'))
350
+ device = 'AndroidDevice'
351
+ else device = 'Desktop'
352
+
353
+ return device
354
+ },
355
+
356
+ async fetchDataFromServer() {
357
+ this.updateTracker('appBase_fetch', 'loading')
358
+ if (this.getModuleInfo.packageType !== 'xapi') return
359
+ if (!this.getConnectionInfo || !this.getConnectionInfo.remote) return
360
+
361
+ const progress = await this.$xapi._getProgress(
362
+ this.getConnectionInfo.actor.mbox.replace('mailto:', ''),
363
+ this.getConnectionInfo.activity_id
364
+ )
365
+ const { routeHistory, ...userProgress } = progress
366
+
367
+ const completedState = await this.$xapi._getLessonStatus(
368
+ this.getConnectionInfo.actor.mbox.replace('mailto:', ''),
369
+ this.getConnectionInfo.activity_id
370
+ )
371
+
372
+ //=======UPCOMING: Uncomment lines bellow when these properties are integrated ========
373
+ // Get existing record for user preferred settings
374
+ // const applicationSettings = await this.$xapi._getPreferredSettings(
375
+ // this.getConnectionInfo.actor.mbox.replace('mailto:', ''),
376
+ // this.getConnectionInfo.activity_id
377
+ // )
378
+
379
+ //======= End UPCOMING: Uncomment when this property are integrated ========
380
+ //Get existing record for play bar settings. Play bar info is from the activity Parent ID and not activity id
381
+ const _url = new URL(this.getConnectionInfo.activity_id)
382
+ const parentID = `${_url.origin}/${this.getModuleInfo.courseID}` // redefining activity id for statement
383
+ const playbarValues = await this.$xapi._getPlaybarValues(
384
+ this.getConnectionInfo.actor.mbox.replace('mailto:', ''),
385
+ parentID
386
+ )
387
+ const lessonPosition = await this.$xapi._getLessonPosition(
388
+ this.getConnectionInfo.actor.mbox.replace('mailto:', ''),
389
+ this.getConnectionInfo.activity_id
390
+ )
391
+
392
+ //Update the App Store data
393
+ this.updateDataFetchFromServer({
394
+ userProgress,
395
+ routeHistory,
396
+ lessonPosition,
397
+ completedState,
398
+ playbarValues
399
+ })
400
+ // console.log('DATA FROM SERVER', {
401
+ // userProgress,
402
+ // routeHistory,
403
+ // lessonPosition,
404
+ // completedState,
405
+ // playbarValues
406
+ // })
407
+
408
+ this.updateTracker('appBase_fetch', 'ready')
409
+ },
410
+
411
+ executeCloseEventTriggered() {
412
+ if (
413
+ this.getModuleInfo.packageType === 'scorm' &&
414
+ this.$scorm.initialized
415
+ ) {
416
+ this.$bus.$emit('save-to-scorm') // emit event to save to scorm before closing the app
417
+ this.$scorm.Finish()
418
+ }
419
+ //Xapi context
420
+ else if (
421
+ this.getModuleInfo.packageType === 'xapi' &&
422
+ this.getConnectionInfo &&
423
+ this.getConnectionInfo.actor &&
424
+ this.getConnectionInfo.remote
425
+ ) {
426
+ this.endLesson(null, true)
427
+ }
428
+ },
429
+
430
+ async setProgress() {
431
+ const packageType = this.getModuleInfo.packageType
432
+
433
+ switch (packageType) {
434
+ case 'scorm':
435
+ // Get saved data from suspend_data or from localStorage to update the store:
436
+ // LMS is connected
437
+ if (this.$scorm.initialized) {
438
+ // chacked if there is a existing record in the LMS
439
+ if (this.$scorm.GetValue('cmi.suspend_data') !== '') {
440
+ const scormRecord = this.$scorm
441
+ .GetValue('cmi.suspend_data')
442
+ .replace(/\\/g, '')
443
+ const userProgress = JSON.parse(scormRecord).userData
444
+ const routeHistory = JSON.parse(scormRecord).routeHistory
445
+
446
+ this.setUserMetaData(userProgress)
447
+ this.setRouteHistory(routeHistory) // update store recored with existing record
448
+ }
449
+
450
+ // No LMS use LocalStorage record
451
+ } else {
452
+ this.$idb.openDB().then(() => {
453
+ this.$idb.getFromDB(this.getModuleInfo.idbID).then((res) => {
454
+ if (res && res.$record) {
455
+ const { routeHistory, userSettings, ...userData } =
456
+ res.$record
457
+ this.setUserMetaData(userData) // update store record with existing record
458
+ this.setRouteHistory(routeHistory) // update store record with existing route info
459
+ if (userSettings) this.setApplicationSettings(userSettings) // update store record with existing user settings
460
+ }
461
+ })
462
+ })
463
+ }
464
+
465
+ break
466
+
467
+ case 'xapi':
468
+ {
469
+ if (
470
+ this.getConnectionInfo &&
471
+ this.getConnectionInfo.actor &&
472
+ this.getConnectionInfo.remote
473
+ ) {
474
+ if (!this.getDataFromServer) return
475
+ //Try to get the user progress from LRS
476
+ const { playbarValues, routeHistory, userProgress } =
477
+ this.getDataFromServer
478
+
479
+ this.setUserMetaData(userProgress) // update store record with existing record
480
+ this.setRouteHistory(routeHistory) // update store record with existing record
481
+
482
+ if (playbarValues) this.setMediaPlaybarValues(playbarValues) // update store record with existing record
483
+ //Should update the playbar values
484
+ } else {
485
+ //Get existing records for user data in local store
486
+ this.$idb.openDB().then(() => {
487
+ this.$idb.getFromDB(this.getModuleInfo.idbID).then((res) => {
488
+ if (res && res.$record) {
489
+ const { routeHistory, userSettings, ...userData } =
490
+ res.$record
491
+
492
+ this.setUserMetaData(userData) // update store record with existing record
493
+ this.setRouteHistory(routeHistory) // update store record with existing route info
494
+
495
+ if (userSettings) {
496
+ this.setApplicationSettings(userSettings) // update store record with existing user setting
497
+ }
498
+ }
499
+ })
500
+ })
501
+ }
502
+ }
503
+
504
+ break
505
+ }
506
+ this.updateTracker('appBase', 'ready')
507
+ },
508
+ updateTracker(name, status) {
509
+ this.updateCompStatusTracker({ name, status })
510
+ },
511
+ //============================Multiple Satements Sending at once======================================
512
+ /**
513
+ * @description Send a custom statements to the lrs. Receive some custom params and build a statement to send
514
+ * @param {Array} stmts- array of stamtents objects that will be send to the server
515
+ * @param {Function} cb
516
+ */
517
+ async sendXapiStatements(stmts, cb = null, withFetch = true) {
518
+ cb = cb || null
519
+ if (!stmts) return
520
+ let stmtsArray = []
521
+
522
+ stmts.constructor === Object
523
+ ? stmtsArray.push(stmts)
524
+ : (stmtsArray = [...stmts])
525
+
526
+ const crsParams = this.getModuleInfo
527
+
528
+ if (
529
+ this.getConnectionInfo &&
530
+ this.getConnectionInfo.actor &&
531
+ this.getConnectionInfo.remote
532
+ ) {
533
+ const stmtsQueue = []
534
+
535
+ stmtsArray.forEach((stmtObj) => {
536
+ const {
537
+ id,
538
+ result = null,
539
+ definition,
540
+ objectType,
541
+ type,
542
+ description,
543
+ verb,
544
+ extensions,
545
+ duration,
546
+ completion
547
+ } = stmtObj
548
+
549
+ //define the activity id
550
+ let activityId
551
+ //=========================== activity ID of Object ==========================================
552
+ /*
553
+ * Define the acitivity id of the stmt according to following:
554
+ * The statement is sent at component/ element level : there is Id and Id is not null (id of the element)
555
+ * The statement is sent at module level. there is no id
556
+ * the Statement must be sent at the course level: There course_id exist and id is the course_id
557
+ */
558
+
559
+ if (id && id !== crsParams.courseID)
560
+ activityId = `${this.getConnectionInfo.activity_id}/${id}`
561
+ else if (crsParams.courseID && id === crsParams.courseID)
562
+ activityId = this.getConnectionInfo.activity_id.replace(
563
+ `/${crsParams.id}`,
564
+ ''
565
+ )
566
+ else activityId = `${this.getConnectionInfo.activity_id}`
567
+
568
+ //define the statement object
569
+ let stmt = {
570
+ actor: this.getConnectionInfo.actor,
571
+ verb: (() => {
572
+ if (verb && this.$xapi.verbs[verb.trim()])
573
+ return this.$xapi.verbs[verb.trim()]
574
+ else {
575
+ /**
576
+ * Determine the verb to use by:
577
+ * Checking if the activity is already included in the data fetch from server.
578
+ * If not, fetch directly from the server
579
+ * There is a found, verb is 'RESUMED'
580
+ * There is no found, verb is 'INITIALIZED'
581
+ */
582
+
583
+ //Regex to test that id contains list of word
584
+ const regex =
585
+ /(menu|activite_(\d)*|A(\d){2}|conclusion|introduction)$/gm
586
+
587
+ switch (true) {
588
+ case regex.test(activityId): {
589
+ //ID is of activity (menu, conclusion, activite, intro )
590
+ const activityStr = activityId.split('/').toReversed()[0]
591
+
592
+ if (!this.getDataFromServer)
593
+ return this.$xapi.verbs.initialized
594
+ const { userProgress } = this.getDataFromServer
595
+ let activity_ref
596
+ //Define the activity reference
597
+ if (['menu', 'introduction', 'A00'].includes(activityStr))
598
+ activity_ref = 'A00'
599
+ else if (['conclusion', 'A99'].includes(activityStr))
600
+ activity_ref = 'A99'
601
+ else if (activityStr.includes('activite_')) {
602
+ const aNum = activityStr.split('_')[1]
603
+ activity_ref = aNum.length > 1 ? `A${aNum}` : `A0${aNum}`
604
+ } else activity_ref = activityStr // should be AXX
605
+
606
+ if (userProgress && userProgress[activity_ref])
607
+ return this.$xapi.verbs.resumed
608
+ else return this.$xapi.verbs.initialized
609
+ }
610
+ default: {
611
+ //ID his of Lesson or doesn't exist relate (menu, conclusion, activite, intro )
612
+ if (
613
+ this.$xapi._getAgent(
614
+ this.getConnectionInfo.actor.mbox.replace(
615
+ 'mailto:',
616
+ ''
617
+ ),
618
+ activityId
619
+ )
620
+ )
621
+ return this.$xapi.verbs.resumed
622
+ else return this.$xapi.verbs.initialized
623
+ }
624
+ }
625
+ }
626
+ })(),
627
+ object: {
628
+ id: activityId,
629
+ definition: {
630
+ name: {
631
+ [this.displayLang]: `${definition}`
632
+ },
633
+ description: {
634
+ [this.displayLang]: `${description}`,
635
+ type: type || 'http://activitystrea.ms/schema/1.0/page'
636
+ }
637
+ },
638
+ objectType: objectType || 'Activity'
639
+ },
640
+ context: {
641
+ contextActivities: {}
642
+ }
643
+ }
644
+ //===================== contextActivity parent =====================//
645
+ /*
646
+ Define parent in the contextActivity
647
+ When:
648
+ 1- when we have the id (parent === module)
649
+ 2- when with have no id but we have a course_id (parent is cours_id)
650
+ */
651
+ const activityParent = (() => {
652
+ if (crsParams.courseID && !id)
653
+ return {
654
+ id: this.getConnectionInfo.activity_id.replace(
655
+ `/${crsParams.id}`,
656
+ ''
657
+ ),
658
+ objectType: objectType || 'Activity'
659
+ }
660
+ else if (id && id !== crsParams.courseID)
661
+ return {
662
+ id: `${this.getConnectionInfo.activity_id}`,
663
+ objectType: objectType || 'Activity'
664
+ }
665
+ else return null
666
+ })()
667
+
668
+ // Add the parent key of the context activity
669
+ if (activityParent)
670
+ stmt.context.contextActivities['parent'] = [activityParent]
671
+
672
+ //===================== contextActivity grouping =====================//
673
+
674
+ //Defining the Grouping of the context activity
675
+ const activityGrouping = (() => {
676
+ if (
677
+ activityParent &&
678
+ crsParams.courseID &&
679
+ activityParent.id.includes(crsParams.id)
680
+ )
681
+ return {
682
+ id: this.getConnectionInfo.activity_id.replace(
683
+ `/${crsParams.id}`,
684
+ ''
685
+ )
686
+ }
687
+ else return null
688
+ })()
689
+ //Adding the grouping of the context activity
690
+ if (activityGrouping)
691
+ stmt.context.contextActivities['grouping'] = [activityGrouping]
692
+
693
+ //add result data to statement
694
+ if (result) stmt['result'] = result
695
+
696
+ // Add duration info to statement when activity is complete
697
+ if (['completed', 'suspended', 'terminated'].includes(verb)) {
698
+ if (!stmt.result) stmt.result = {} // check if exist
699
+
700
+ let d
701
+
702
+ duration
703
+ ? (d = `PT${duration.split(':')[0]}H${duration.split(':')[1]}M${
704
+ duration.split(':')[2]
705
+ }S`)
706
+ : (d = `PT${this.activityDuration.split(':')[0]}H${
707
+ this.activityDuration.split(':')[1]
708
+ }M${this.activityDuration.split(':')[2]}S`)
709
+
710
+ stmt.result['duration'] = d
711
+ //Set the completion status of the result
712
+ if (completion) stmt.result['completion'] = completion
713
+ }
714
+
715
+ // ===================== Extension of the Object definition =====================//
716
+ if (
717
+ extensions &&
718
+ extensions.constructor === Array &&
719
+ extensions.length > 0
720
+ ) {
721
+ //Validate each entry given in the extension Array
722
+ extensions.forEach((e) => {
723
+ //entry must be of type Object
724
+ if (e.constructor !== Object)
725
+ throw new Error(`'${e}' is not a valid value. Must be Object`)
726
+
727
+ //Entry Must have id and content keys
728
+ const validKey = ['id', 'content']
729
+ Object.keys(e).forEach((key) => {
730
+ if (!validKey.includes(key))
731
+ throw new Error(`Not valid key '${key}' for entry ${e}`)
732
+
733
+ //id must be a String
734
+ if (key === 'id' && e[key] && e[key].constructor !== String)
735
+ throw new Error(`'${key}' must be of type String`)
736
+ })
737
+
738
+ stmt.object.definition['extensions'] = {
739
+ ...stmt.object.definition['extensions'],
740
+ [`${this.getConnectionInfo.activity_id}/${e.id}`]: e.content
741
+ }
742
+ })
743
+ }
744
+
745
+ //============================================================
746
+ stmtsQueue.push(stmt)
747
+ })
748
+
749
+ this.$xapi._sendStatements(stmtsQueue, cb, withFetch)
750
+ }
751
+ },
752
+
753
+ /**
754
+ * @param {Function} cb
755
+ * @param {Bool} option - set if must use Fetch API or not. default = false
756
+ */
757
+ endLesson(cb, option = true) {
758
+ cb = cb || null
759
+ let text
760
+ //Defining the text to display for stmt description and definition
761
+ switch (this.$i18n.locale) {
762
+ case 'fr':
763
+ if (this.getModuleInfo.courseID)
764
+ text = `Le ${this.getModuleInfo.id} de ${this.getModuleInfo.courseID}`
765
+ else text = `Le ${this.getModuleInfo.id}`
766
+ break
767
+ case 'en':
768
+ if (this.getModuleInfo.courseID)
769
+ text = `The ${this.getModuleInfo.id} of ${this.getModuleInfo.courseID}`
770
+ else text = `The ${this.getModuleInfo.id}`
771
+ break
772
+ }
773
+
774
+ // Retrieve only user data in lessons front its interaction that we want to send to the LRS.
775
+ // Note: User Settings are sent on a different URI and statement
776
+ const { isFistTime, userSettings, ...lessonsData } =
777
+ this.getUserInteraction
778
+
779
+ const stmt = {
780
+ verb: 'suspended',
781
+ definition: text,
782
+ description: text,
783
+ extensions: [
784
+ {
785
+ id: 'ending-point',
786
+ content: this.$route.name
787
+ },
788
+ {
789
+ id: 'user-data',
790
+ content: {
791
+ routeHistory: this.getRouteHistory,
792
+ ...lessonsData
793
+ }
794
+ }
795
+ ],
796
+ duration: this.lessonDuration
797
+ }
798
+
799
+ const endStmt = { ...stmt }
800
+ endStmt.verb = 'terminated'
801
+ //================================STATEMENT FOR THE PLAYBAR ===============================================
802
+ //Creating custom statement
803
+ const stmtPlaybar = {
804
+ id: (() => {
805
+ if (this.getModuleInfo.courseID) return this.getModuleInfo.courseID
806
+ else return null
807
+ })(),
808
+ verb: 'played',
809
+ definition: text,
810
+ description: text,
811
+ extensions: [
812
+ {
813
+ id: 'playbar-values',
814
+ content: {
815
+ ...this.getMediaPlaybarValues()
816
+ }
817
+ }
818
+ ]
819
+ }
820
+ //================================STATEMENT FOR THE PLAYBAR ===============================================
821
+
822
+ this.sendXapiStatements([stmtPlaybar, stmt, endStmt], null, option) //send xapi statement
823
+
824
+ if (this.timerState === 'started') setTimeout(() => this.stopTimer(), 0) //clear the timer
825
+
826
+ if (cb) cb()
827
+ },
828
+
829
+ /** @description Method to reset the progression status of user
830
+ * the function reset first the user data in the app store then
831
+ * reset the idbStore if APP is running locally or prepare a statement with new userData Object to be sent over the LRS
832
+ * for the connected userAgent, activity if APP is Running online
833
+ * @event 'send-xapi-statement' emit to AppBaseModule.vue that will be executed by sendXapiStatement(s)
834
+ */
835
+ async resetUserData() {
836
+ this.$bus.$emit('set-comp-status', 'appBase', 'loading')
837
+ this.setUserMetaData({}) // resetting store record with existing for user data
838
+ this.setRouteHistory([]) // resetting store record for all last visited pages
839
+
840
+ this.$bus.$emit('stop-timer')
841
+ if (this.getModuleInfo.packageType !== 'xapi') return
842
+
843
+ if (!this.getConnectionInfo || this.getConnectionInfo.remote == false)
844
+ return this.$idb.deleteDataInDB(this.getModuleInfo.idbID) //Must call the idb delete methode to reset indexDB store
845
+
846
+ //Send a completion statement for the current activity
847
+ let aTitle = `${this.$t('text.activity')} ${this.getConnectionInfo.activity_id}`
848
+
849
+ //Custom text for description and definition of the stament to be sent
850
+ let text
851
+ this.$i18n.locale == 'fr'
852
+ ? (text = `L'${aTitle} de ${this.getModuleInfo.id}`)
853
+ : (text = `The ${aTitle} of ${this.getModuleInfo.id}`)
854
+
855
+ /*Dispatch a send statement event to method AppBaseModule sendXapiStatement(s)
856
+ Content values are:
857
+ User data URI: "host_address/course_ID/Lesson_ID/user-data" ex: "http://localhost:8080/330N01FD6001/m1l1/ // user-data" and
858
+ User Bookmark URI: "host_address/course_ID/Lesson_ID/ending-point" ex: ""http://localhost:8080/330N01FD6001/m1l1/ending-point": "menu"
859
+ */
860
+ const baseStmt = {
861
+ definition: text,
862
+ description: text
863
+ }
864
+ const exitStmt = {
865
+ ...baseStmt,
866
+ verb: 'exited'
867
+ }
868
+
869
+ const endStmt = {
870
+ ...baseStmt,
871
+ verb: 'terminated',
872
+ extensions: [
873
+ {
874
+ id: 'ending-point',
875
+ content: this.$route.name
876
+ },
877
+ {
878
+ id: 'user-data',
879
+ content: new Object()
880
+ }
881
+ ],
882
+ duration: null,
883
+ completion: false // Resetting completion state value to
884
+ }
885
+
886
+ this.sendXapiStatements([exitStmt, endStmt])
887
+
888
+ this.$bus.$emit('set-comp-status', 'appBase', 'ready')
889
+
890
+ // Creating a asynchronous fecth method that would resolve after a 2 second
891
+ const fetchData = async () => {
892
+ return new Promise((resolve) => {
893
+ setTimeout(
894
+ () =>
895
+ resolve(
896
+ this.$xapi._getProgress(
897
+ this.getConnectionInfo.actor.mbox.replace('mailto:', ''),
898
+ this.getConnectionInfo.activity_id
899
+ )
900
+ ),
901
+ 2000
902
+ )
903
+ })
904
+ }
905
+
906
+ // Make request over user current data after sending statement to assure that the resetting worked
907
+ const usrData = await fetchData()
908
+ this.$bus.$emit('reset-complete')
909
+ console.log('😄 USER STATUS: ', {
910
+ User: this.getConnectionInfo.actor.mbox.replace('mailto:', ''),
911
+ ActivityID: this.getConnectionInfo.activity_id,
912
+ Record: usrData
913
+ })
914
+ },
915
+
916
+ /**
917
+ * @description- Scroll directly to the target* containt
918
+ * target content can be any Node defined by the ID
919
+ * @param {String} target = id of HTMLElement. if target don't existe will scroll to wrapper-content
920
+ * @param {Obj} opt = scroll behavior to apply default { top: 0, left: 0, behavior: 'auto' }
921
+ */
922
+
923
+ moveTo(target, opt = { top: 0, left: 0, behavior: 'auto' }) {
924
+ if (target.constructor !== String)
925
+ throw new Error(
926
+ '⚠️ Not supported value for @target. Must be of type String'
927
+ )
928
+
929
+ let skipTo = document.querySelector(`#wrapper-content`) //default definition of main element
930
+
931
+ if (target) {
932
+ let targetEl = document.querySelector(`#${target}`) // search for node element specified as main
933
+
934
+ if (targetEl) skipTo = targetEl
935
+ }
936
+
937
+ if (skipTo) opt.top = skipTo.offsetTop + opt.top // Set scroll top from target top
938
+
939
+ if (skipTo) skipTo.setAttribute('tabIndex', -1) //Allowing accessibility control with keyboard
940
+ window.scrollTo(opt)
941
+ if (skipTo) this.resetFocus(skipTo) //focus on the target
942
+ },
943
+
944
+ /**
945
+ * @description Reset the focus the element
946
+ * @param {HTMLElement} e - element that will get focus
947
+ */
948
+ resetFocus(e) {
949
+ if (e) e.focus()
950
+ },
951
+ /**
952
+ * @description Mothods to validate that application settings are correctly
953
+ * Opens error component when configurations are not correct
954
+ *
955
+ */
956
+ checkForErrors() {
957
+ let errMessage
958
+
959
+ if (validateAppContent(this.appConfig, true).length) {
960
+ return (this.error = validateAppContent(this.appConfig, true))
961
+ }
962
+ const { list: activitiesList } = this.getAllActivities()
963
+ let { no_menu, is_single_activity } = this.appConfig
964
+
965
+ let noMenu = no_menu == undefined ? false : no_menu
966
+
967
+ let isSingleActivity =
968
+ is_single_activity == undefined ? false : is_single_activity
969
+
970
+ let err
971
+ if (this.getErrorMenu) err = true
972
+ else err = false
973
+
974
+ let consoleMsg = ''
975
+ //error if There is more than one activity when is_single_activity
976
+ switch (true) {
977
+ case isSingleActivity && activitiesList.size > 1:
978
+ errMessage = `La configuration choisie ne permet pas d'avoir plus d'une activité dans l'application. \n Vous devez soit désactiver l'option 💲<b>is_single_activity</b> ou retirer TOUTES les autres activités`
979
+
980
+ consoleMsg = `Cannot have more than 1 activity with this settings configuration. Either disable 💲is_single_activity or DELETE ALL others activities`
981
+ this.error.push(errMessage)
982
+ break
983
+
984
+ case isSingleActivity && !noMenu:
985
+ errMessage = `Le MENU n'est pas disponible avec la configuration choisie.\n Vous devez soit désactiver l'option 💲<b>no_menu</b> ou l'option 💲<b>is_single_activity</b>`
986
+ consoleMsg = `Cannot have MENU with current settings configuration. Either set 💲no_menu:false or 💲is_single_activity:false`
987
+ this.error.push(errMessage)
988
+ break
989
+
990
+ case err:
991
+ errMessage = `Il y une erreur dans votre fichier menu.setting.js. \n ouvrez votre console pour voir l'erreur et corriger la dans menu.setting.js `
992
+ consoleMsg = this.getErrorMenu
993
+ this.error.push(errMessage)
994
+ break
995
+ }
996
+
997
+ if (errMessage)
998
+ return console.warn(
999
+ `%c WARNING!>>> ${consoleMsg}`,
1000
+ 'background: orange; color: white; display: block; border-radius:5px; margin:5px;'
1001
+ )
1002
+ }
1003
+ }
1004
+ }
1005
+ </script>
1006
+ <style lang="scss">
1007
+ #App-base {
1008
+ width: 100%;
1009
+ height: 100%;
1010
+ min-height: 100vh;
1011
+
1012
+ #overlay_loading {
1013
+ position: fixed !important;
1014
+ width: 100%;
1015
+ height: 100%;
1016
+ top: 0;
1017
+ left: 0;
1018
+ z-index: 9999;
1019
+ }
1020
+
1021
+ #build-info {
1022
+ opacity: 0.9;
1023
+ position: fixed;
1024
+ right: 0;
1025
+ bottom: 0;
1026
+ color: #fff;
1027
+ text-shadow: -1px 1px 1px rgba(0, 0, 0, 0.4);
1028
+ background-color: hotpink;
1029
+ font-family: serif, Impact, Arial;
1030
+ font-size: 11px;
1031
+ padding: 3px;
1032
+ cursor: pointer;
1033
+ z-index: 999;
1034
+ span {
1035
+ text-align: center;
1036
+ display: block;
1037
+ }
1038
+ }
1039
+
1040
+ .box {
1041
+ width: 100%;
1042
+ height: 100%;
1043
+ position: relative;
1044
+ }
1045
+ }
1046
+ .grp-spinners {
1047
+ span {
1048
+ margin: 0px 2px;
1049
+ }
1050
+ .v-progress-circular {
1051
+ margin: 0.8rem !important;
1052
+ }
1053
+ }
1054
+ </style>