fcad-core-dragon 2.1.0 → 2.1.2

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 (160) hide show
  1. package/.editorconfig +7 -7
  2. package/.gitlab-ci.yml +124 -0
  3. package/.prettierrc +11 -11
  4. package/.vscode/extensions.json +8 -8
  5. package/.vscode/settings.json +46 -16
  6. package/CHANGELOG +520 -520
  7. package/README.md +57 -57
  8. package/documentation/.vitepress/config.js +114 -114
  9. package/documentation/api-examples.md +49 -49
  10. package/documentation/composants/app-base-button.md +58 -58
  11. package/documentation/composants/app-base-error-display.md +59 -59
  12. package/documentation/composants/app-base-popover.md +68 -68
  13. package/documentation/composants/app-comp-audio.md +75 -75
  14. package/documentation/composants/app-comp-branch-buttons.md +111 -111
  15. package/documentation/composants/app-comp-button-progress.md +53 -53
  16. package/documentation/composants/app-comp-carousel.md +53 -53
  17. package/documentation/composants/app-comp-container.md +53 -53
  18. package/documentation/composants/app-comp-input-checkbox-next.md +42 -42
  19. package/documentation/composants/app-comp-input-dropdown-next.md +34 -34
  20. package/documentation/composants/app-comp-input-radio-next.md +39 -39
  21. package/documentation/composants/app-comp-input-text-next.md +35 -35
  22. package/documentation/composants/app-comp-input-text-table-next.md +34 -34
  23. package/documentation/composants/app-comp-input-text-to-fill-dropdown-next.md +53 -53
  24. package/documentation/composants/app-comp-input-text-to-fill-next.md +31 -31
  25. package/documentation/composants/app-comp-jauge.md +31 -31
  26. package/documentation/composants/app-comp-menu-item.md +55 -55
  27. package/documentation/composants/app-comp-menu.md +29 -29
  28. package/documentation/composants/app-comp-navigation.md +41 -41
  29. package/documentation/composants/app-comp-note-call.md +53 -53
  30. package/documentation/composants/app-comp-note-credit.md +53 -53
  31. package/documentation/composants/app-comp-play-bar-next.md +53 -53
  32. package/documentation/composants/app-comp-pop-up-next.md +93 -93
  33. package/documentation/composants/app-comp-quiz-next.md +235 -235
  34. package/documentation/composants/app-comp-quiz-recall.md +53 -53
  35. package/documentation/composants/app-comp-svg-next.md +53 -53
  36. package/documentation/composants/app-comp-table-of-content.md +50 -50
  37. package/documentation/composants/app-comp-video-player.md +82 -82
  38. package/documentation/composants.md +46 -46
  39. package/documentation/composants_critiques/ModelPageComposant.md +53 -53
  40. package/documentation/composants_critiques/app-base-module.md +43 -43
  41. package/documentation/composants_critiques/app-base-page.md +48 -48
  42. package/documentation/composants_critiques/app-base.md +311 -311
  43. package/documentation/composants_critiques/main.md +15 -15
  44. package/documentation/demarrage.md +50 -50
  45. package/documentation/deploiement.md +57 -57
  46. package/documentation/index.md +33 -33
  47. package/documentation/markdown-examples.md +85 -85
  48. package/documentation/public/vite.svg +14 -14
  49. package/documentation/public/vuejs.svg +1 -1
  50. package/documentation/public/vuetify.svg +5 -5
  51. package/eslint.config.js +60 -60
  52. package/junit-report.xml +182 -0
  53. package/package.json +66 -59
  54. package/playwright/index.html +12 -0
  55. package/playwright/index.js +21 -0
  56. package/playwright-ct.config.js +95 -0
  57. package/src/$locales/en.json +157 -157
  58. package/src/$locales/fr.json +120 -120
  59. package/src/assets/data/onboardingMessages.json +47 -47
  60. package/src/components/AppBase.vue +1171 -1169
  61. package/src/components/AppBaseButton.vue +90 -95
  62. package/src/components/AppBaseErrorDisplay.vue +438 -438
  63. package/src/components/AppBaseFlipCard.vue +84 -84
  64. package/src/components/AppBaseModule.vue +1639 -1634
  65. package/src/components/AppBasePage.vue +3 -2
  66. package/src/components/AppBasePopover.vue +41 -41
  67. package/src/components/AppBaseSkeleton.vue +66 -66
  68. package/src/components/AppCompAudio.vue +261 -256
  69. package/src/components/AppCompBranchButtons.vue +508 -508
  70. package/src/components/AppCompButtonProgress.vue +137 -132
  71. package/src/components/AppCompCarousel.vue +342 -336
  72. package/src/components/AppCompContainer.vue +29 -29
  73. package/src/components/AppCompInputCheckBoxNx.vue +325 -323
  74. package/src/components/AppCompInputDropdownNx.vue +302 -299
  75. package/src/components/AppCompInputRadioNx.vue +287 -284
  76. package/src/components/AppCompInputTextNx.vue +156 -153
  77. package/src/components/AppCompInputTextTableNx.vue +205 -202
  78. package/src/components/AppCompInputTextToFillDropdownNx.vue +343 -340
  79. package/src/components/AppCompInputTextToFillNx.vue +316 -313
  80. package/src/components/AppCompJauge.vue +81 -81
  81. package/src/components/AppCompMenu.vue +6 -2
  82. package/src/components/AppCompMenuItem.vue +246 -240
  83. package/src/components/AppCompNavigation.vue +977 -972
  84. package/src/components/AppCompNoteCall.vue +167 -161
  85. package/src/components/AppCompNoteCredit.vue +496 -491
  86. package/src/components/AppCompPlayBarNext.vue +2290 -2288
  87. package/src/components/AppCompPopUpNext.vue +508 -504
  88. package/src/components/AppCompQuizNext.vue +515 -510
  89. package/src/components/AppCompQuizRecall.vue +355 -350
  90. package/src/components/AppCompSVGNext.vue +346 -346
  91. package/src/components/AppCompSettingsMenu.vue +177 -172
  92. package/src/components/AppCompTableOfContent.vue +433 -427
  93. package/src/components/AppCompVideoPlayer.vue +377 -377
  94. package/src/components/AppCompViewDisplay.vue +6 -6
  95. package/src/components/BaseModule.vue +55 -55
  96. package/src/composables/useIdleDetector.js +56 -56
  97. package/src/composables/useQuiz.js +89 -89
  98. package/src/composables/useTimer.js +172 -172
  99. package/src/directives/nvdaFix.js +53 -53
  100. package/src/externalComps/ModuleView.vue +22 -22
  101. package/src/externalComps/SummaryView.vue +91 -91
  102. package/src/main.js +493 -476
  103. package/src/module/stores/appStore.js +960 -947
  104. package/src/module/xapi/ADL.js +520 -520
  105. package/src/module/xapi/Crypto/Hasher.js +241 -241
  106. package/src/module/xapi/Crypto/WordArray.js +278 -278
  107. package/src/module/xapi/Crypto/algorithms/BufferedBlockAlgorithm.js +103 -103
  108. package/src/module/xapi/Crypto/algorithms/C_algo.js +315 -315
  109. package/src/module/xapi/Crypto/algorithms/HMAC.js +9 -9
  110. package/src/module/xapi/Crypto/algorithms/SHA1.js +9 -9
  111. package/src/module/xapi/Crypto/encoders/Base.js +105 -105
  112. package/src/module/xapi/Crypto/encoders/Base64.js +99 -99
  113. package/src/module/xapi/Crypto/encoders/Hex.js +61 -61
  114. package/src/module/xapi/Crypto/encoders/Latin1.js +61 -61
  115. package/src/module/xapi/Crypto/encoders/Utf8.js +45 -45
  116. package/src/module/xapi/Crypto/index.js +53 -53
  117. package/src/module/xapi/Statement/activity.js +47 -47
  118. package/src/module/xapi/Statement/agent.js +55 -55
  119. package/src/module/xapi/Statement/group.js +26 -26
  120. package/src/module/xapi/Statement/index.js +259 -259
  121. package/src/module/xapi/Statement/statement.js +253 -253
  122. package/src/module/xapi/Statement/statementRef.js +23 -23
  123. package/src/module/xapi/Statement/substatement.js +22 -22
  124. package/src/module/xapi/Statement/verb.js +36 -36
  125. package/src/module/xapi/activitytypes.js +17 -17
  126. package/src/module/xapi/launch.js +157 -157
  127. package/src/module/xapi/utils.js +167 -167
  128. package/src/module/xapi/verbs.js +294 -294
  129. package/src/module/xapi/wrapper.js +1895 -1895
  130. package/src/module/xapi/xapiStatement.js +444 -444
  131. package/src/plugins/analytics.js +34 -34
  132. package/src/plugins/bus.js +12 -8
  133. package/src/plugins/gsap.js +17 -15
  134. package/src/plugins/helper.js +355 -358
  135. package/src/plugins/i18n.js +27 -26
  136. package/src/plugins/idb.js +227 -227
  137. package/src/plugins/save.js +37 -37
  138. package/src/plugins/scorm.js +287 -287
  139. package/src/plugins/xapi.js +11 -11
  140. package/src/public/index.html +33 -33
  141. package/src/router/index.js +57 -57
  142. package/src/router/routes.js +312 -312
  143. package/src/shared/generalfuncs.js +344 -344
  144. package/src/shared/validators.js +1018 -1018
  145. package/tests/component/AppBaseButton.spec.js +53 -0
  146. package/tests/component/pinia.spec.js +24 -0
  147. package/{src/components/tests__ → tests/unit}/AppBaseButton.spec.js +53 -53
  148. package/tests/unit/AppCompInputCheckBoxNx.spec.js +59 -0
  149. package/tests/unit/AppCompInputDropdownNx.spec.js +51 -0
  150. package/tests/unit/AppCompInputRadioNx.spec.js +59 -0
  151. package/tests/unit/AppCompInputTextNx.spec.js +44 -0
  152. package/tests/unit/AppCompInputTextTableNx.spec.js +77 -0
  153. package/tests/unit/AppCompInputTextToFillDropdownNx.spec.js +60 -0
  154. package/tests/unit/AppCompInputTextToFillNx.spec.js +45 -0
  155. package/tests/unit/AppCompQuizNext.spec.js +114 -0
  156. package/tests/unit/AppCompVideoPlayer.spec.js +177 -0
  157. package/{src/components/tests__ → tests/unit}/useTimer.spec.js +91 -91
  158. package/vitest.config.js +28 -19
  159. package/vitest.setup.js +28 -0
  160. package/src/components/AppBaseButton.test.js +0 -21
@@ -1,1169 +1,1171 @@
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
- aria-hidden="true"
29
- @click="buildInfoClicked = true"
30
- >
31
- <span>{{ getModuleInfo.courseID.toUpperCase() }}</span>
32
- <span>FCAD {{ $helper.getFcadVersion() }}</span>
33
- <span>template {{ $helper.getTemplateVersion() }}</span>
34
- <span>{{ $helper.getBuildTime() }}</span>
35
- </div>
36
- </transition>
37
-
38
- <router-view class="box" />
39
- <div v-if="appDebugMode" class="timer">
40
- <!-- <div class="timer"> -->
41
- <div>Act: {{ appTimer.ISOTimeParser(activityDuration) }}</div>
42
- <span>&nbsp;|&nbsp;</span>
43
- <div>Les: {{ appTimer.ISOTimeParser(lessonDuration) }}</div>
44
- </div>
45
- <app-icons-next :extra-icons="userExtraIcons" />
46
- </template>
47
-
48
- <v-overlay
49
- id="overlay_loading"
50
- :model-value="!appReady && initialLoading"
51
- class="align-center justify-center"
52
- scrim="white"
53
- opacity="0.9"
54
- :persistent="true"
55
- >
56
- <div class="text-center grp-spinners">
57
- <v-icon id="icon_loading" icon="mdi-dots-circle" size="x-large" />
58
-
59
- <span class="sr-only">
60
- {{ $t('message.loading_state_msg') }}
61
- </span>
62
- </div>
63
- </v-overlay>
64
- </div>
65
- </template>
66
- <script>
67
- import { mapState, mapActions } from 'pinia'
68
- import { useAppStore } from '../module/stores/appStore.js'
69
- import { Timer } from '../composables/useTimer.js'
70
- import { IdleDetector } from '../composables/useIdleDetector.js'
71
- import { validateAppContent } from '../shared/validators'
72
- import { version as fcadVersion } from '../../package.json'
73
- import { computed, reactive } from 'vue'
74
- import mobileDetect from 'mobile-detect'
75
-
76
- export default {
77
- provide() {
78
- return {
79
- lessonDuration: computed(() => this.lessonDuration), //exposing the lesson Duration variable to all components
80
- elapsedIdleTime: computed(() => this.idleDetector.getElapsedTime()), //exposing idle detector elapse time to all components
81
- appTimer: this.appTimer //exposing the app timer instance to all components
82
- }
83
- },
84
- props: {
85
- appConfig: {
86
- type: Object,
87
- required: true,
88
- validator: (value) => {
89
- if (import.meta.env.DEV) return validateAppContent(value).length === 0
90
- }
91
- }
92
- },
93
- setup() {
94
- const store = useAppStore()
95
- const appTimer = reactive(new Timer('ativityTimer')) // Making Timer instance reactive to be able to track changes
96
- const idleDetector = reactive(new IdleDetector()) // Making detector instance reactive.
97
- return { store, appTimer, idleDetector }
98
- },
99
-
100
- data() {
101
- return {
102
- randKey: null,
103
- isLandScape: null,
104
- initialDeviceOrentation: null,
105
- appIsFullScreen: false,
106
- resizeiPad: false,
107
- buildInfoClicked: false,
108
- error: [],
109
- initialLoading: true,
110
- f5KeyPressed: false,
111
- lessonDuration: 0,
112
- idleTimeout: 5 * 60000, //5 mins: this is the time of inactivity before considering the user is idle
113
- starterTimeout: null
114
- }
115
- },
116
- computed: {
117
- ...mapState(useAppStore, [
118
- 'getCurrentBrowser',
119
- 'getIsMobile',
120
- 'getDeviceType',
121
- 'getModuleInfo',
122
- 'getAppStatus',
123
- 'getUserInteraction',
124
- 'getConnectionInfo',
125
- 'getAllActivities',
126
- 'getMediaPlaybarValues',
127
- 'getAppConfigs',
128
- 'getErrorMenu',
129
- 'getRouteHistory',
130
- 'getBookmarkEnabled',
131
- 'getAppDebugMode',
132
- 'getApplicationSettings'
133
- ]),
134
- appDebugMode() {
135
- return this.getAppDebugMode
136
- },
137
- activityDuration() {
138
- return this.appTimer.getTime() // return the time duration in seconds
139
- },
140
-
141
- getwidth() {
142
- return window.innerWidth
143
- },
144
- getheight() {
145
- return window.innerHeight
146
- },
147
-
148
- showBuildInfo() {
149
- return (
150
- import.meta.env.PROD &&
151
- window.location.host === 'projets.cegepadistance.ca'
152
- )
153
- },
154
- appReady() {
155
- let readyState = this.getAppStatus === 'ready' ? true : false
156
- return readyState
157
- },
158
-
159
- displayLang() {
160
- let lang = false
161
- const displayList = ['en-US', 'fr-FR', 'es-ES'] // list of xapi verbs language display
162
-
163
- if (this.getModuleInfo.packageType === 'xapi') {
164
- lang = displayList.find((l) => l.includes(this.$i18n.locale))
165
- }
166
- return lang
167
- },
168
- userExtraIcons() {
169
- const icons = this.$helper.getSettingsFromStore('extra_icons')
170
- ? this.$helper.getSettingsFromStore('extra_icons')
171
- : null
172
- return icons
173
- } /**
174
- * @description Set default value for bookmark
175
- */,
176
- bookmarkActive() {
177
- return this.getBookmarkEnabled
178
- }
179
- },
180
- watch: {
181
- initialLoading: {
182
- deep: true,
183
- handler(newVal) {
184
- if (newVal !== false) {
185
- return
186
- }
187
- const eventParams = {
188
- fcad_version: fcadVersion,
189
- course_id: this.getAppConfigs.crs_id,
190
- lesson_id: this.getAppConfigs.id
191
- }
192
- this.$analytics.sendEvent('user_meta', eventParams)
193
- }
194
- },
195
- 'store.$state.userDataLoaded': {
196
- handler(newValue) {
197
- if (!newValue) return this.updateTracker('appBase', 'loading')
198
- this.updateTracker('appBase', 'ready')
199
- },
200
- immediate: true
201
- },
202
- appReady: {
203
- handler(newValue) {
204
- if (newValue) {
205
- this.setInitialLoadingDone()
206
- }
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.initializeApp(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
- this.$analytics.init(this.getAppConfigs.analytics_id, this.$router)
243
- this.$bus.$on('start-timer', this.startAppTimer)
244
- this.$bus.$on('stop-app-timer', this.stopAppTimer)
245
- this.$bus.$on('start-idle-detector', this.setIdleDetector)
246
- this.$bus.$on('stop-idle-detector', this.unsetIdleDetector)
247
- },
248
- beforeMount() {
249
- window.addEventListener(
250
- 'beforeunload',
251
- (e) => {
252
- this.executeCloseEventTriggered()
253
- // e.preventDefault()
254
- // e.returnValue = true
255
- },
256
- true
257
- )
258
- },
259
-
260
- mounted() {
261
- // set the language of the app
262
- this.setLocale(this.getAppConfigs.lang)
263
- window.addEventListener('keydown', this.handleF5KeyPressed)
264
- window.addEventListener('beforeunload', this.handleBeforeUnload)
265
- },
266
- beforeUnmount() {
267
- this.$bus.$off('set-comp-status', this.updateTracker)
268
- this.$bus.$off('reset-userdata', this.resetUserData)
269
- this.$bus.$off('fire-exit-event', this.endLesson)
270
- this.$bus.$off('reset-focus-on', this.resetFocus)
271
- this.$bus.$off('send-xapi-statement', this.sendXapiStatements)
272
- this.$bus.$off('move-to-target', this.moveTo)
273
- this.$bus.$off('start-idle-detector', this.setIdleDetector)
274
- this.$bus.$off('stop-idle-detector', this.unsetIdleDetector)
275
- this.$bus.$off('start-timer', this.startAppTimer)
276
- this.$bus.$off('stop-app-timer', this.stopAppTimer)
277
- window.removeEventListener('keydown', this.handleF5KeyPressed)
278
- window.removeEventListener('beforeunload', this.handleBeforeUnload)
279
- if (this.getAppConfigs.remote && this.unsubscribeToSetConfig)
280
- this.unsubscribeToSetConfig() //stop watching changing in store to save to local storage
281
- },
282
- methods: {
283
- ...mapActions(useAppStore, [
284
- 'setDeviceType',
285
- 'updateDataFetchFromServer',
286
- 'setUserMetaData',
287
- 'setRouteHistory',
288
- 'setApplicationSettings',
289
- 'setMediaPlaybarValues',
290
- 'updateCompStatusTracker',
291
- 'initializeApp',
292
- 'setMobileState',
293
- 'setCurrentBrowser'
294
- ]),
295
- /**
296
- * @description start the app timer instance
297
- * Check if the timer is already started, if yes it add the activity duration
298
- * to the lesson duration and restart the timer
299
- */
300
-
301
- startAppTimer() {
302
- if (this.$route.name === 'module') return // don't start timer when in menu
303
- if (this.appTimer.getTimerState() == 'started') {
304
- this.lessonDuration += this.activityDuration //
305
- this.appTimer.stop() // Start the activity timer
306
- }
307
-
308
- //Delay actually one second before starting timers
309
- this.starterTimeout = setTimeout(() => {
310
- this.appTimer.start() // Start the activity timer
311
- this.setIdleDetector() //start the idle time detector
312
- }, 500)
313
- },
314
-
315
- /**
316
- * @description stop the app timer instance
317
- * The function add the activity duration to the lesson duration and stop the timer
318
- */
319
- stopAppTimer() {
320
- this.lessonDuration += this.activityDuration
321
- this.appTimer.stop() // stop timer
322
- clearTimeout(this.starterTimeout)
323
- },
324
-
325
- /**
326
- * @description set the idle detector to start detecting idle time
327
- * Attach DOM event listeners to reset the idle timer
328
- * The idle detector will start after 1 second after the app is loaded
329
- * The idle detector will pause the app timer when idle time is reached
330
- */
331
-
332
- setIdleDetector() {
333
- setTimeout(() => {
334
- this.idleDetector.startIdleTimer(
335
- () => this.appTimer.pause(),
336
- this.idleTimeout
337
- )
338
- document.addEventListener('mousedown', this.resetIdleTimer, false) //set eventlisteners
339
- document.addEventListener('mousemove', this.resetIdleTimer, false) //set eventlisteners
340
- document.addEventListener('keypress', this.resetIdleTimer, false) //set eventlisteners
341
- document.addEventListener('touchmove', this.resetIdleTimer, false) //set eventlisteners
342
- }, 800)
343
- },
344
- /**
345
- * @description unset the idle detector
346
- * stop the idle timer and remove DOM event to stop listening the idle timer
347
- */
348
- unsetIdleDetector() {
349
- this.idleDetector.stopIdleTimer()
350
- document.removeEventListener('mousedown', this.resetIdleTimer, false)
351
- document.removeEventListener('mousemove', this.resetIdleTimer, false)
352
- document.removeEventListener('keypress', this.resetIdleTimer, false)
353
- document.removeEventListener('touchmove', this.resetIdleTimer, false)
354
- },
355
- /**
356
- * @description reset the idle timer
357
- * The function stop the idle timer and restart it
358
- * start also the app timer if it was paused
359
- *
360
- */
361
- resetIdleTimer() {
362
- if (this.$route.name == 'menu' || this.$route.name == 'module') return // don't reset idle timer when in menu
363
- this.idleDetector.stopIdleTimer()
364
-
365
- if (this.appTimer.getTimerState() == 'stopped') this.appTimer.start()
366
- this.idleDetector.startIdleTimer(
367
- () => this.appTimer.pause(),
368
- this.idleTimeout
369
- )
370
- },
371
-
372
- setInitialLoadingDone() {
373
- this.initialLoading = false
374
- },
375
- /**
376
- * @description set the desired language for the app default is french
377
- * @param {String} [lang=fr]
378
- */
379
-
380
- setLocale(lang) {
381
- if (!lang) lang = 'fr'
382
- else {
383
- lang = lang.toLowerCase()
384
- if (lang === 'français' || lang === 'francais' || lang === 'french')
385
- lang = 'fr'
386
- else if (lang === 'english' || lang === 'anglais') lang = 'en'
387
- else lang = lang.substring(0, 2).toLowerCase()
388
- this.$i18n.locale = lang
389
- //Set vuetify locale
390
- this.$vuetify.locale.current = lang
391
- }
392
- },
393
-
394
- getScormState() {
395
- return this.$scorm.GetValue('cmi.suspend_data')
396
- },
397
-
398
- getBrowser() {
399
- let browser
400
- const test = (regexp) => regexp.test(window.navigator.userAgent) //defining the testing fonction
401
-
402
- switch (true) {
403
- case test(/edg/i):
404
- browser = 'Edge'
405
- break
406
- case test(/trident/i):
407
- browser = 'IE'
408
- break
409
- case test(/firefox|fxios/i):
410
- browser = 'Firefox'
411
- break
412
- case test(/opr\//i):
413
- browser = 'Opera'
414
- break
415
- case test(/ucbrowser/i):
416
- browser = 'UC Browser'
417
- break
418
- case test(/samsungbrowser/i):
419
- browser = 'Samsung Browser'
420
- break
421
- case test(/chrome|chromium|crios/i):
422
- if (navigator.brave && navigator.brave.isBrave()) browser = 'Brave'
423
- // as of 2020-11 Brave adds a Class brave in the navigator object
424
- else browser = 'Chrome'
425
- break
426
- case test(/safari/i):
427
- browser = 'Safari'
428
- break
429
- default:
430
- browser = 'Other'
431
- break
432
- }
433
- // Return browser initiale
434
- return browser
435
- },
436
-
437
- detectDevice() {
438
- let device = null
439
- if (
440
- navigator.appVersion.includes('iPad') ||
441
- navigator.appVersion.includes('iPhone')
442
- )
443
- device = 'iOSDevice'
444
- else if (navigator.appVersion.includes('Android'))
445
- device = 'AndroidDevice'
446
- else device = 'Desktop'
447
-
448
- return device
449
- },
450
-
451
- executeCloseEventTriggered() {
452
- if (
453
- this.getModuleInfo.packageType === 'scorm' &&
454
- this.$scorm.initialized
455
- ) {
456
- this.$bus.$emit('save-to-scorm', 'disconnect') // emit event to save to scorm before closing the app
457
- }
458
- //Xapi context
459
- else if (
460
- this.getModuleInfo.packageType === 'xapi' &&
461
- this.getConnectionInfo &&
462
- this.getConnectionInfo.actor &&
463
- this.getConnectionInfo.remote
464
- ) {
465
- this.endLesson(null, true)
466
- }
467
- },
468
-
469
- updateTracker(name, status) {
470
- this.updateCompStatusTracker({ name, status })
471
- },
472
- //============================Multiple Satements Sending at once======================================
473
- /**
474
- * @description Send a custom statements to the lrs. Receive some custom params and build a statement to send
475
- * @param {Array} stmts- array of stamtents objects that will be send to the server
476
- * @param {Function} cb
477
- */
478
- async sendXapiStatements(stmts, cb = null, withFetch = true) {
479
- cb = cb || null
480
- if (!stmts) return
481
- let stmtsArray = []
482
-
483
- stmts.constructor === Object
484
- ? stmtsArray.push(stmts)
485
- : (stmtsArray = [...stmts])
486
-
487
- const crsParams = this.getModuleInfo
488
-
489
- if (
490
- this.getConnectionInfo &&
491
- this.getConnectionInfo.actor &&
492
- this.getConnectionInfo.remote
493
- ) {
494
- const stmtsQueue = []
495
-
496
- stmtsArray.forEach((stmtObj) => {
497
- const {
498
- id,
499
- result = null,
500
- definition,
501
- objectType,
502
- type,
503
- description,
504
- verb,
505
- extensions,
506
- duration,
507
- completion
508
- } = stmtObj
509
-
510
- //define the activity id
511
- let activityId
512
- //=========================== activity ID of Object ==========================================
513
- /*
514
- * Define the acitivity id of the stmt according to following:
515
- * The statement is sent at component/ element level : there is Id and Id is not null (id of the element)
516
- * The statement is sent at module level. there is no id
517
- * the Statement must be sent at the course level: There course_id exist and id is the course_id
518
- */
519
- if (id && id !== crsParams.courseID)
520
- activityId = `${this.getConnectionInfo.activity_id}/${id}`
521
- else if (crsParams.courseID && id === crsParams.courseID)
522
- activityId = this.getConnectionInfo.activity_id.replace(
523
- `/${crsParams.id}`,
524
- ''
525
- )
526
- else activityId = `${this.getConnectionInfo.activity_id}`
527
-
528
- //define the statement object
529
- let stmt = {
530
- actor: this.getConnectionInfo.actor,
531
- verb: (() => {
532
- if (verb && this.$xapi.verbs[verb.trim()])
533
- return this.$xapi.verbs[verb.trim()]
534
- else {
535
- /**
536
- * Determine the verb to use by:
537
- * Checking if the activity is already included in the data fetch from server.
538
- * If not, fetch directly from the server
539
- * There is a found, verb is 'RESUMED'
540
- * There is no found, verb is 'INITIALIZED'
541
- */
542
-
543
- //Regex to test that id contains list of word
544
- const regex =
545
- /(menu|activite_(\d)*|A(\d){2}|conclusion|introduction)$/gm
546
-
547
- switch (true) {
548
- case regex.test(activityId): {
549
- //ID is of activity (menu, conclusion, activite, intro )
550
- const activityStr = activityId.split('/').toReversed()[0]
551
-
552
- if (!this.getUserInteraction[activityId])
553
- return this.$xapi.verbs.initialized
554
-
555
- let activity_ref
556
- //Define the activity reference
557
- if (['menu', 'introduction', 'A00'].includes(activityStr))
558
- activity_ref = 'A00'
559
- else if (['conclusion', 'A99'].includes(activityStr))
560
- activity_ref = 'A99'
561
- else if (activityStr.includes('activite_')) {
562
- const aNum = activityStr.split('_')[1]
563
- activity_ref = aNum.length > 1 ? `A${aNum}` : `A0${aNum}`
564
- } else activity_ref = activityStr // should be AXX
565
-
566
- if (
567
- this.getUserInteraction &&
568
- this.getUserInteraction[activity_ref]
569
- )
570
- return this.$xapi.verbs.resumed
571
- else return this.$xapi.verbs.initialized
572
- }
573
- default: {
574
- //ID of Lesson or doesn't exist relate (menu, conclusion, activite, intro )
575
- if (
576
- this.$xapi._getAgent(
577
- this.getConnectionInfo.actor.mbox.replace(
578
- 'mailto:',
579
- ''
580
- ),
581
- activityId
582
- )
583
- )
584
- return this.$xapi.verbs.resumed
585
- else return this.$xapi.verbs.initialized
586
- }
587
- }
588
- }
589
- })(),
590
- object: {
591
- id: activityId,
592
- definition: {
593
- name: {
594
- [this.displayLang]: `${definition}`
595
- },
596
- description: {
597
- [this.displayLang]: `${description}`,
598
- type: type || 'http://activitystrea.ms/schema/1.0/page'
599
- }
600
- },
601
- objectType: objectType || 'Activity'
602
- },
603
- context: {
604
- contextActivities: {}
605
- }
606
- }
607
- //===================== contextActivity parent =====================//
608
- /*
609
- Define parent in the contextActivity
610
- When:
611
- 1- when we have the id (parent === module)
612
- 2- when with have no id but we have a course_id (parent is cours_id)
613
- */
614
- const activityParent = (() => {
615
- if (crsParams.courseID && !id)
616
- return {
617
- id: this.getConnectionInfo.activity_id.replace(
618
- `/${crsParams.id}`,
619
- ''
620
- ),
621
- objectType: objectType || 'Activity'
622
- }
623
- else if (id && id !== crsParams.courseID)
624
- return {
625
- id: `${this.getConnectionInfo.activity_id}`,
626
- objectType: objectType || 'Activity'
627
- }
628
- else return null
629
- })()
630
-
631
- // Add the parent key of the context activity
632
- if (activityParent)
633
- stmt.context.contextActivities['parent'] = [activityParent]
634
-
635
- //===================== contextActivity grouping =====================//
636
-
637
- //Defining the Grouping of the context activity
638
- const activityGrouping = (() => {
639
- if (
640
- activityParent &&
641
- crsParams.courseID &&
642
- activityParent.id.includes(crsParams.id)
643
- )
644
- return {
645
- id: this.getConnectionInfo.activity_id.replace(
646
- `/${crsParams.id}`,
647
- ''
648
- )
649
- }
650
- else return null
651
- })()
652
- //Adding the grouping of the context activity
653
- if (activityGrouping)
654
- stmt.context.contextActivities['grouping'] = [activityGrouping]
655
-
656
- //add result data to statement
657
- if (result) stmt['result'] = result
658
-
659
- // Add duration info to statement when activity is complete
660
- if (['completed', 'suspended', 'terminated'].includes(verb)) {
661
- if (!stmt.result) stmt.result = {} // check if exist
662
-
663
- let d
664
-
665
- duration
666
- ? (d = `PT${duration.split(':')[0]}H${duration.split(':')[1]}M${
667
- duration.split(':')[2]
668
- }S`)
669
- : (d = `PT${
670
- this.appTimer
671
- .ISOTimeParser(this.activityDuration)
672
- .split(':')[0]
673
- }H${
674
- this.appTimer
675
- .ISOTimeParser(this.activityDuration)
676
- .split(':')[1]
677
- }M${
678
- this.appTimer
679
- .ISOTimeParser(this.activityDuration)
680
- .split(':')[2]
681
- }S`)
682
-
683
- stmt.result['duration'] = d
684
- //Set the completion status of the result
685
- if (completion) stmt.result['completion'] = completion
686
- }
687
-
688
- if (
689
- extensions &&
690
- extensions.constructor === Array &&
691
- extensions.length > 0
692
- ) {
693
- // ===================== Extension of the Object definition =====================//
694
- //Validate each entry given in the extension Array
695
- extensions.forEach((e) => {
696
- //entry must be of type Object
697
- if (e.constructor !== Object)
698
- throw new Error(`'${e}' is not a valid value. Must be Object`)
699
-
700
- //Entry Must have id and content keys
701
- const validKey = ['id', 'content']
702
- Object.keys(e).forEach((key) => {
703
- if (!validKey.includes(key))
704
- throw new Error(`Not valid key '${key}' for entry ${e}`)
705
-
706
- //id must be a String
707
- if (key === 'id' && e[key] && e[key].constructor !== String)
708
- throw new Error(`'${key}' must be of type String`)
709
- })
710
-
711
- stmt.object.definition['extensions'] = {
712
- ...stmt.object.definition['extensions'],
713
- [`${activityId}/${e.id}`]: e.content
714
- }
715
- })
716
- }
717
-
718
- //============================================================
719
- stmtsQueue.push(stmt)
720
- })
721
- this.$xapi._sendStatements(stmtsQueue, cb, withFetch)
722
- }
723
- },
724
-
725
- /**
726
- * @param {Function} cb
727
- * @param {Bool} option - set if must use Fetch API or not. default = false
728
- */
729
- endLesson(cb, option = true) {
730
- cb = cb || null
731
- let text
732
-
733
- //Defining the text to display for stmt description and definition
734
- switch (this.$i18n.locale) {
735
- case 'fr':
736
- if (this.getModuleInfo.courseID)
737
- text = `Le ${this.getModuleInfo.id} de ${this.getModuleInfo.courseID}`
738
- else text = `Le ${this.getModuleInfo.id}`
739
- break
740
- case 'en':
741
- if (this.getModuleInfo.courseID)
742
- text = `The ${this.getModuleInfo.id} of ${this.getModuleInfo.courseID}`
743
- else text = `The ${this.getModuleInfo.id}`
744
- break
745
- }
746
-
747
- // Retrieve only user data in lessons front its interaction that we want to send to the LRS.
748
- // Note: User Settings are sent on a different URI and statement
749
- const { isFistTime, userSettings, ...lessonsData } =
750
- this.getUserInteraction
751
-
752
- const stmt = {
753
- verb: 'suspended',
754
- definition: text,
755
- description: text,
756
- extensions: [
757
- {
758
- id: 'ending-point',
759
- content: (() =>
760
- this.$route.name == 'menu'
761
- ? this.$helper.getRoutesFromVueRouter().meta.children[0]
762
- ._namedRoute
763
- : this.$route.name)()
764
- },
765
- {
766
- id: 'user-data',
767
- content: {
768
- routeHistory: (() => {
769
- const history = this.getRouteHistory.toReversed() //get the route history from the last
770
-
771
- history[0] = this.$route.meta // change the last recored route to the current route
772
-
773
- return history.toReversed()
774
- })(),
775
- ...lessonsData
776
- }
777
- }
778
- ],
779
- duration: this.appTimer.ISOTimeParser(this.lessonDuration)
780
- }
781
-
782
- const endStmt = { ...stmt }
783
- endStmt.verb = 'terminated'
784
- //================================STATEMENT FOR THE PLAYBAR ===============================================
785
- //Creating custom statement
786
- const stmtPlaybar = {
787
- verb: 'played',
788
- definition: text,
789
- description: text,
790
- extensions: [
791
- {
792
- id: 'playbar-values',
793
- content: {
794
- ...this.getMediaPlaybarValues()
795
- }
796
- }
797
- ]
798
- }
799
- //================================STATEMENT FOR THE SETTINGS ===============================================
800
-
801
- //Creating custom statement
802
- const stmtUserSettings = {
803
- id: (() => {
804
- if (!this.getModuleInfo.courseID) return null
805
- return this.getModuleInfo.courseID
806
- })(),
807
- verb: 'preferred',
808
- definition: text,
809
- description: text,
810
- extensions: [
811
- {
812
- id: 'application-settings',
813
- content: {
814
- ...this.getApplicationSettings
815
- }
816
- // content: {
817
- // bookmark: false
818
- // } //for testing
819
- }
820
- ]
821
- }
822
- //================================END STATEMENT ===============================================
823
-
824
- this.sendXapiStatements(
825
- [stmtPlaybar, stmtUserSettings, stmt, endStmt],
826
- null,
827
- option
828
- ) //send xapi statement
829
-
830
- if (this.appTimer.getTimerState() === 'started')
831
- setTimeout(() => this.stopAppTimer(), 0) //clear the timer
832
-
833
- if (cb) cb()
834
- },
835
-
836
- /** @description Method to reset the progression status of user
837
- * the function reset first the user data in the app store then
838
- * reset the idbStore if APP is running locally or prepare a statement with new userData Object to be sent over the LRS
839
- * for the connected userAgent, activity if APP is Running online
840
- * @event 'send-xapi-statement' emit to AppBaseModule.vue that will be executed by sendXapiStatement(s)
841
- */
842
- async resetUserData() {
843
- this.$bus.$emit('set-comp-status', 'appBase', 'loading')
844
- this.setUserMetaData({}) // resetting store record with existing for user data
845
- this.setApplicationSettings({}) // resetting store record for settings
846
- this.setRouteHistory([]) // resetting store record for all last visited pages
847
-
848
- // this.$bus.$emit('stop-timer')
849
- this.stopAppTimer()
850
- if (this.getModuleInfo.packageType !== 'xapi') return
851
-
852
- if (!this.getConnectionInfo || this.getConnectionInfo.remote == false)
853
- return this.$idb.deleteDataInDB(this.getModuleInfo.idbID) //Must call the idb delete methode to reset indexDB store
854
-
855
- //Send a completion statement for the current activity
856
- let aTitle = `${this.$t('text.activity')} ${this.getConnectionInfo.activity_id}`
857
-
858
- //Custom text for description and definition of the stament to be sent
859
- let text
860
- this.$i18n.locale == 'fr'
861
- ? (text = `L'${aTitle} de ${this.getModuleInfo.id}`)
862
- : (text = `The ${aTitle} of ${this.getModuleInfo.id}`)
863
-
864
- /*Dispatch a send statement event to method AppBaseModule sendXapiStatement(s)
865
- Content values are:
866
- User data URI: "host_address/course_ID/Lesson_ID/user-data" ex: "http://localhost:8080/330N01FD6001/m1l1/ // user-data" and
867
- User Bookmark URI: "host_address/course_ID/Lesson_ID/ending-point" ex: ""http://localhost:8080/330N01FD6001/m1l1/ending-point": "introduction"
868
- */
869
- const baseStmt = {
870
- definition: text,
871
- description: text
872
- }
873
- const exitStmt = {
874
- ...baseStmt,
875
- verb: 'exited'
876
- }
877
-
878
- const endStmt = {
879
- ...baseStmt,
880
- verb: 'terminated',
881
- extensions: [
882
- {
883
- id: 'ending-point',
884
- content: (() =>
885
- this.$route.name == 'menu'
886
- ? this.$helper.getRoutesFromVueRouter().meta.children[0]
887
- ._namedRoute
888
- : this.$route.name)()
889
- },
890
- {
891
- id: 'user-data',
892
- content: new Object()
893
- }
894
- ],
895
- duration: null,
896
- completion: false // Resetting completion state value to
897
- }
898
- //================================STATEMENT FOR THE SETTINGS ===============================================
899
-
900
- //Creating custom statement
901
- const stmtUserSettings = {
902
- ...baseStmt,
903
- id: (() => {
904
- if (this.getModuleInfo.courseID) return this.getModuleInfo.courseID
905
- else return null
906
- })(),
907
-
908
- verb: 'preferred',
909
- extensions: [
910
- {
911
- id: 'application-settings',
912
- content: new Object()
913
- }
914
- ]
915
- }
916
- //================================END STATEMENT ===============================================
917
-
918
- this.sendXapiStatements([stmtUserSettings, exitStmt, endStmt])
919
-
920
- this.$bus.$emit('set-comp-status', 'appBase', 'ready')
921
- const actorMbox = this.getConnectionInfo.actor.mbox.replace('mailto:', '')
922
- const activityId = this.getConnectionInfo.activity_id
923
- // Creating a asynchronous fecth method that would resolve after a 2 second
924
- const fetchData = async () => {
925
- //funtion that will make a multiple request on the server
926
- const requests = async () => {
927
- const _url = new URL(activityId)
928
- const parentID = `${_url.origin}/${this.getModuleInfo.courseID}` // redefining activity id for statement
929
-
930
- const lessonProgressParam = { email: actorMbox, activityId }
931
- const lessonStateParam = {
932
- email: actorMbox,
933
- activityId,
934
- verb: 'suspended'
935
- }
936
- const lessonPosionParam = {
937
- email: actorMbox,
938
- activityId
939
- }
940
- const playbarParam = {
941
- email: actorMbox,
942
- activityId,
943
- verb: 'played'
944
- }
945
- const preferencesParam = {
946
- email: actorMbox,
947
- activityId: parentID,
948
- verb: 'preferred'
949
- }
950
-
951
- const fetchParams = [
952
- lessonProgressParam,
953
- lessonStateParam,
954
- lessonPosionParam,
955
- playbarParam,
956
- preferencesParam
957
- ]
958
-
959
- // Using Promise.all to make parallel requests
960
- const {
961
- userData,
962
- savedPoint,
963
- preferredSettings,
964
- playbarValues,
965
- lessonStatus
966
- } = await this.$xapi._getBulkData(fetchParams)
967
-
968
- return {
969
- userData,
970
- savedPoint,
971
- preferredSettings,
972
- playbarValues,
973
- lessonStatus
974
- }
975
- }
976
- return new Promise((resolve) => {
977
- setTimeout(() => resolve(requests()), 2000)
978
- })
979
- }
980
-
981
- // Make request over user current data after sending statement to assure that the resetting worked
982
-
983
- const usrData = await fetchData()
984
- this.$bus.$emit('reset-complete')
985
- console.warn('😄 USER STATUS: ', {
986
- userID: actorMbox,
987
- activityId,
988
- Record: usrData
989
- })
990
- },
991
-
992
- /**
993
- * @description- Scroll directly to the target* containt
994
- * target content can be any Node defined by the ID
995
- * @param {String} target = id of HTMLElement. if target don't existe will scroll to wrapper-content
996
- * @param {Obj} opt = scroll behavior to apply default { top: 0, left: 0, behavior: 'auto' }
997
- */
998
-
999
- moveTo(target, opt = { top: 0, left: 0, behavior: 'auto' }) {
1000
- if (target.constructor !== String)
1001
- throw new Error(
1002
- '⚠️ Not supported value for @target. Must be of type String'
1003
- )
1004
-
1005
- let skipTo = document.querySelector(`#wrapper-content`) //default definition of main element
1006
-
1007
- if (target) {
1008
- let targetEl = document.querySelector(`#${target}`) // search for node element specified as main
1009
-
1010
- if (targetEl) skipTo = targetEl
1011
- }
1012
-
1013
- if (skipTo) opt.top = skipTo.offsetTop + opt.top // Set scroll top from target top
1014
-
1015
- if (skipTo) skipTo.setAttribute('tabIndex', -1) //Allowing accessibility control with keyboard
1016
- window.scrollTo(opt)
1017
- if (skipTo) this.resetFocus(skipTo) //focus on the target
1018
- },
1019
-
1020
- /**
1021
- * @description Reset the focus the element
1022
- * @param {HTMLElement} e - element that will get focus
1023
- */
1024
- resetFocus(e) {
1025
- if (e) e.focus()
1026
- },
1027
- /**
1028
- * @description Method to validate that application settings are correctly
1029
- * Opens error component when configurations are not correct
1030
- *
1031
- */
1032
- checkForErrors() {
1033
- let errMessage
1034
-
1035
- if (validateAppContent(this.appConfig, true).length) {
1036
- return (this.error = validateAppContent(this.appConfig, true))
1037
- }
1038
-
1039
- const { list: activitiesList } = this.getAllActivities()
1040
- let { no_menu, is_single_activity } = this.appConfig
1041
-
1042
- let noMenu = no_menu == undefined ? false : no_menu
1043
-
1044
- let isSingleActivity =
1045
- is_single_activity == undefined ? false : is_single_activity
1046
-
1047
- let err
1048
- if (this.getErrorMenu) err = true
1049
- else err = false
1050
-
1051
- let consoleMsg = ''
1052
- //error if There is more than one activity when is_single_activity
1053
- switch (true) {
1054
- case isSingleActivity && activitiesList.size > 1:
1055
- 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`
1056
-
1057
- consoleMsg = `Cannot have more than 1 activity with this settings configuration. Either disable 💲is_single_activity or DELETE ALL others activities`
1058
- this.error.push(errMessage)
1059
- break
1060
-
1061
- case isSingleActivity && !noMenu:
1062
- 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>`
1063
- consoleMsg = `Cannot have MENU with current settings configuration. Either set 💲no_menu:false or 💲is_single_activity:false`
1064
- this.error.push(errMessage)
1065
- break
1066
-
1067
- case err:
1068
- 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 `
1069
- consoleMsg = this.getErrorMenu
1070
- this.error.push(errMessage)
1071
- break
1072
- }
1073
-
1074
- if (errMessage)
1075
- return console.warn(
1076
- `%c WARNING!>>> ${consoleMsg}`,
1077
- 'background: orange; color: white; display: block; border-radius:5px; margin:5px;'
1078
- )
1079
- },
1080
- /**
1081
- * @description Handle the keydown event to detect if F5 key was pressed
1082
- * The function set the f5KeyPressed to true if F5 key was pressed
1083
- * @param {Event} e - keydown event
1084
- */
1085
- handleF5KeyPressed(e) {
1086
- this.f5KeyPressed = e.code == 'F5'
1087
- },
1088
-
1089
- /**
1090
- * @description Handle the beforeunload event to save data when the user close the app or reload the app
1091
- * The function check if the F5 key was pressed to avoid sending data when the user reload the app
1092
- */
1093
- handleBeforeUnload() {
1094
- //prevent app to send/Save data when F5 is pressed to reload app
1095
- if (this.f5KeyPressed) return
1096
-
1097
- this.executeCloseEventTriggered()
1098
- }
1099
- }
1100
- }
1101
- </script>
1102
- <style lang="scss">
1103
- #App-base {
1104
- width: 100%;
1105
- height: 100%;
1106
- min-height: 100vh;
1107
-
1108
- #overlay_loading {
1109
- position: fixed !important;
1110
- width: 100%;
1111
- height: 100%;
1112
- top: 0;
1113
- left: 0;
1114
- z-index: 9999;
1115
- }
1116
-
1117
- #build-info {
1118
- opacity: 0.9;
1119
- position: fixed;
1120
- right: 0;
1121
- bottom: 0;
1122
- color: #fff;
1123
- text-shadow: -1px 1px 1px rgba(0, 0, 0, 0.4);
1124
- background-color: hotpink;
1125
- font-family: serif, Impact, Arial;
1126
- font-size: 11px;
1127
- padding: 3px;
1128
- cursor: pointer;
1129
- z-index: 999;
1130
- span {
1131
- text-align: center;
1132
- display: block;
1133
- }
1134
- }
1135
-
1136
- .box {
1137
- width: 100%;
1138
- height: 100%;
1139
- position: relative;
1140
- }
1141
- }
1142
- .grp-spinners {
1143
- transform: scale(1.5);
1144
- #icon_loading {
1145
- animation: spin 1.3s cubic-bezier(0.57, 0.87, 0.77, 0.87) infinite;
1146
- }
1147
- }
1148
- @keyframes spin {
1149
- 100% {
1150
- transform: rotate(360deg);
1151
- }
1152
- }
1153
-
1154
- .timer {
1155
- position: sticky; /* Sticks inside the parent container */
1156
- bottom: 90%; /* Sticks to the top edge of the parent */
1157
- left: 100%; /* Sticks to the left edge of the parent */
1158
- display: flex;
1159
- align-items: center;
1160
- justify-content: flex-start;
1161
- font-size: 1em;
1162
- width: fit-content;
1163
- color: #333;
1164
- opacity: 0.9;
1165
- background-color: #eaabb6b3; /* Optional: ensures readability when content scrolls */
1166
- padding: 5px 12px; /* Some breathing space */
1167
- z-index: 1000; /* Keeps it above other elements */
1168
- }
1169
- </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
+ aria-hidden="true"
29
+ @click="buildInfoClicked = true"
30
+ >
31
+ <span>{{ getModuleInfo.courseID.toUpperCase() }}</span>
32
+ <span>FCAD {{ $helper.getFcadVersion() }}</span>
33
+ <span>template {{ $helper.getTemplateVersion() }}</span>
34
+ <span>{{ $helper.getBuildTime() }}</span>
35
+ </div>
36
+ </transition>
37
+
38
+ <router-view class="box" />
39
+ <div v-if="appDebugMode" class="timer">
40
+ <!-- <div class="timer"> -->
41
+ <div>Act: {{ appTimer.ISOTimeParser(activityDuration) }}</div>
42
+ <span>&nbsp;|&nbsp;</span>
43
+ <div>Les: {{ appTimer.ISOTimeParser(lessonDuration) }}</div>
44
+ </div>
45
+ <app-icons-next :extra-icons="userExtraIcons" />
46
+ </template>
47
+
48
+ <v-overlay
49
+ id="overlay_loading"
50
+ :model-value="!appReady && initialLoading"
51
+ class="align-center justify-center"
52
+ scrim="white"
53
+ opacity="0.9"
54
+ :persistent="true"
55
+ >
56
+ <div class="text-center grp-spinners">
57
+ <v-icon id="icon_loading" icon="mdi-dots-circle" size="x-large" />
58
+
59
+ <span class="sr-only">
60
+ {{ $t('message.loading_state_msg') }}
61
+ </span>
62
+ </div>
63
+ </v-overlay>
64
+ </div>
65
+ </template>
66
+ <script>
67
+ import { mapState, mapActions } from 'pinia'
68
+ import { useAppStore } from '../module/stores/appStore.js'
69
+ import { Timer } from '../composables/useTimer.js'
70
+ import { IdleDetector } from '../composables/useIdleDetector.js'
71
+ import { validateAppContent } from '../shared/validators'
72
+ import { version as fcadVersion } from '../../package.json'
73
+ import { computed, reactive } from 'vue'
74
+ import mobileDetect from 'mobile-detect'
75
+ import { useI18n } from 'vue-i18n'
76
+
77
+ export default {
78
+ provide() {
79
+ return {
80
+ lessonDuration: computed(() => this.lessonDuration), //exposing the lesson Duration variable to all components
81
+ elapsedIdleTime: computed(() => this.idleDetector.getElapsedTime()), //exposing idle detector elapse time to all components
82
+ appTimer: this.appTimer //exposing the app timer instance to all components
83
+ }
84
+ },
85
+ props: {
86
+ appConfig: {
87
+ type: Object,
88
+ required: true,
89
+ validator: (value) => {
90
+ if (import.meta.env.DEV) return validateAppContent(value).length === 0
91
+ }
92
+ }
93
+ },
94
+ setup() {
95
+ const store = useAppStore()
96
+ const appTimer = reactive(new Timer('ativityTimer')) // Making Timer instance reactive to be able to track changes
97
+ const idleDetector = reactive(new IdleDetector()) // Making detector instance reactive.
98
+ const { t } = useI18n()
99
+ return { store, appTimer, idleDetector, t }
100
+ },
101
+
102
+ data() {
103
+ return {
104
+ randKey: null,
105
+ isLandScape: null,
106
+ initialDeviceOrentation: null,
107
+ appIsFullScreen: false,
108
+ resizeiPad: false,
109
+ buildInfoClicked: false,
110
+ error: [],
111
+ initialLoading: true,
112
+ f5KeyPressed: false,
113
+ lessonDuration: 0,
114
+ idleTimeout: 5 * 60000, //5 mins: this is the time of inactivity before considering the user is idle
115
+ starterTimeout: null
116
+ }
117
+ },
118
+ computed: {
119
+ ...mapState(useAppStore, [
120
+ 'getCurrentBrowser',
121
+ 'getIsMobile',
122
+ 'getDeviceType',
123
+ 'getModuleInfo',
124
+ 'getAppStatus',
125
+ 'getUserInteraction',
126
+ 'getConnectionInfo',
127
+ 'getAllActivities',
128
+ 'getMediaPlaybarValues',
129
+ 'getAppConfigs',
130
+ 'getErrorMenu',
131
+ 'getRouteHistory',
132
+ 'getBookmarkEnabled',
133
+ 'getAppDebugMode',
134
+ 'getApplicationSettings'
135
+ ]),
136
+ appDebugMode() {
137
+ return this.getAppDebugMode
138
+ },
139
+ activityDuration() {
140
+ return this.appTimer.getTime() // return the time duration in seconds
141
+ },
142
+
143
+ getwidth() {
144
+ return window.innerWidth
145
+ },
146
+ getheight() {
147
+ return window.innerHeight
148
+ },
149
+
150
+ showBuildInfo() {
151
+ return (
152
+ import.meta.env.PROD &&
153
+ window.location.host === 'projets.cegepadistance.ca'
154
+ )
155
+ },
156
+ appReady() {
157
+ let readyState = this.getAppStatus === 'ready' ? true : false
158
+ return readyState
159
+ },
160
+
161
+ displayLang() {
162
+ let lang = false
163
+ const displayList = ['en-US', 'fr-FR', 'es-ES'] // list of xapi verbs language display
164
+
165
+ if (this.getModuleInfo.packageType === 'xapi') {
166
+ lang = displayList.find((l) => l.includes(this.$i18n.locale))
167
+ }
168
+ return lang
169
+ },
170
+ userExtraIcons() {
171
+ const icons = this.$helper.getSettingsFromStore('extra_icons')
172
+ ? this.$helper.getSettingsFromStore('extra_icons')
173
+ : null
174
+ return icons
175
+ } /**
176
+ * @description Set default value for bookmark
177
+ */,
178
+ bookmarkActive() {
179
+ return this.getBookmarkEnabled
180
+ }
181
+ },
182
+ watch: {
183
+ initialLoading: {
184
+ deep: true,
185
+ handler(newVal) {
186
+ if (newVal !== false) {
187
+ return
188
+ }
189
+ const eventParams = {
190
+ fcad_version: fcadVersion,
191
+ course_id: this.getAppConfigs.crs_id,
192
+ lesson_id: this.getAppConfigs.id
193
+ }
194
+ this.$analytics.sendEvent('user_meta', eventParams)
195
+ }
196
+ },
197
+ 'store.$state.userDataLoaded': {
198
+ handler(newValue) {
199
+ if (!newValue) return this.updateTracker('appBase', 'loading')
200
+ this.updateTracker('appBase', 'ready')
201
+ },
202
+ immediate: true
203
+ },
204
+ appReady: {
205
+ handler(newValue) {
206
+ if (newValue) {
207
+ this.setInitialLoadingDone()
208
+ }
209
+ }
210
+ }
211
+ },
212
+
213
+ created() {
214
+ if (import.meta.env.DEV) {
215
+ this.checkForErrors()
216
+ }
217
+ //Declare loading state if no error was detected
218
+
219
+ if (!this.error.length) {
220
+ // this.updateTracker('appBase', 'loading')
221
+ this.initializeApp(this.appConfig)
222
+ }
223
+
224
+ window.versionFCAD = this.$helper.getFcadVersionString()
225
+ //check if this is running in a mobile environment and register state in the store
226
+ const md = new mobileDetect(window.navigator.userAgent)
227
+ md.mobile() !== null
228
+ ? this.setMobileState(true)
229
+ : this.setMobileState(false)
230
+ const currentBrowser = this.getBrowser() //get current browser vendor
231
+
232
+ // register the current browser in the store
233
+ this.setCurrentBrowser(currentBrowser)
234
+
235
+ // register device type running the App in the store (ios, Android or Deskop)
236
+ this.setDeviceType(this.detectDevice())
237
+
238
+ this.$bus.$on('set-comp-status', this.updateTracker)
239
+ this.$bus.$on('send-xapi-statement', this.sendXapiStatements)
240
+ this.$bus.$on('reset-userdata', this.resetUserData)
241
+ this.$bus.$on('fire-exit-event', this.endLesson)
242
+ this.$bus.$on('reset-focus-on', this.resetFocus)
243
+ this.$bus.$on('move-to-target', this.moveTo)
244
+ this.$analytics.init(this.getAppConfigs.analytics_id, this.$router)
245
+ this.$bus.$on('start-timer', this.startAppTimer)
246
+ this.$bus.$on('stop-app-timer', this.stopAppTimer)
247
+ this.$bus.$on('start-idle-detector', this.setIdleDetector)
248
+ this.$bus.$on('stop-idle-detector', this.unsetIdleDetector)
249
+ },
250
+ beforeMount() {
251
+ window.addEventListener(
252
+ 'beforeunload',
253
+ (e) => {
254
+ this.executeCloseEventTriggered()
255
+ // e.preventDefault()
256
+ // e.returnValue = true
257
+ },
258
+ true
259
+ )
260
+ },
261
+
262
+ mounted() {
263
+ // set the language of the app
264
+ this.setLocale(this.getAppConfigs.lang)
265
+ window.addEventListener('keydown', this.handleF5KeyPressed)
266
+ window.addEventListener('beforeunload', this.handleBeforeUnload)
267
+ },
268
+ beforeUnmount() {
269
+ this.$bus.$off('set-comp-status', this.updateTracker)
270
+ this.$bus.$off('reset-userdata', this.resetUserData)
271
+ this.$bus.$off('fire-exit-event', this.endLesson)
272
+ this.$bus.$off('reset-focus-on', this.resetFocus)
273
+ this.$bus.$off('send-xapi-statement', this.sendXapiStatements)
274
+ this.$bus.$off('move-to-target', this.moveTo)
275
+ this.$bus.$off('start-idle-detector', this.setIdleDetector)
276
+ this.$bus.$off('stop-idle-detector', this.unsetIdleDetector)
277
+ this.$bus.$off('start-timer', this.startAppTimer)
278
+ this.$bus.$off('stop-app-timer', this.stopAppTimer)
279
+ window.removeEventListener('keydown', this.handleF5KeyPressed)
280
+ window.removeEventListener('beforeunload', this.handleBeforeUnload)
281
+ if (this.getAppConfigs.remote && this.unsubscribeToSetConfig)
282
+ this.unsubscribeToSetConfig() //stop watching changing in store to save to local storage
283
+ },
284
+ methods: {
285
+ ...mapActions(useAppStore, [
286
+ 'setDeviceType',
287
+ 'updateDataFetchFromServer',
288
+ 'setUserMetaData',
289
+ 'setRouteHistory',
290
+ 'setApplicationSettings',
291
+ 'setMediaPlaybarValues',
292
+ 'updateCompStatusTracker',
293
+ 'initializeApp',
294
+ 'setMobileState',
295
+ 'setCurrentBrowser'
296
+ ]),
297
+ /**
298
+ * @description start the app timer instance
299
+ * Check if the timer is already started, if yes it add the activity duration
300
+ * to the lesson duration and restart the timer
301
+ */
302
+
303
+ startAppTimer() {
304
+ if (this.$route.name === 'module') return // don't start timer when in menu
305
+ if (this.appTimer.getTimerState() == 'started') {
306
+ this.lessonDuration += this.activityDuration //
307
+ this.appTimer.stop() // Start the activity timer
308
+ }
309
+
310
+ //Delay actually one second before starting timers
311
+ this.starterTimeout = setTimeout(() => {
312
+ this.appTimer.start() // Start the activity timer
313
+ this.setIdleDetector() //start the idle time detector
314
+ }, 500)
315
+ },
316
+
317
+ /**
318
+ * @description stop the app timer instance
319
+ * The function add the activity duration to the lesson duration and stop the timer
320
+ */
321
+ stopAppTimer() {
322
+ this.lessonDuration += this.activityDuration
323
+ this.appTimer.stop() // stop timer
324
+ clearTimeout(this.starterTimeout)
325
+ },
326
+
327
+ /**
328
+ * @description set the idle detector to start detecting idle time
329
+ * Attach DOM event listeners to reset the idle timer
330
+ * The idle detector will start after 1 second after the app is loaded
331
+ * The idle detector will pause the app timer when idle time is reached
332
+ */
333
+
334
+ setIdleDetector() {
335
+ setTimeout(() => {
336
+ this.idleDetector.startIdleTimer(
337
+ () => this.appTimer.pause(),
338
+ this.idleTimeout
339
+ )
340
+ document.addEventListener('mousedown', this.resetIdleTimer, false) //set eventlisteners
341
+ document.addEventListener('mousemove', this.resetIdleTimer, false) //set eventlisteners
342
+ document.addEventListener('keypress', this.resetIdleTimer, false) //set eventlisteners
343
+ document.addEventListener('touchmove', this.resetIdleTimer, false) //set eventlisteners
344
+ }, 800)
345
+ },
346
+ /**
347
+ * @description unset the idle detector
348
+ * stop the idle timer and remove DOM event to stop listening the idle timer
349
+ */
350
+ unsetIdleDetector() {
351
+ this.idleDetector.stopIdleTimer()
352
+ document.removeEventListener('mousedown', this.resetIdleTimer, false)
353
+ document.removeEventListener('mousemove', this.resetIdleTimer, false)
354
+ document.removeEventListener('keypress', this.resetIdleTimer, false)
355
+ document.removeEventListener('touchmove', this.resetIdleTimer, false)
356
+ },
357
+ /**
358
+ * @description reset the idle timer
359
+ * The function stop the idle timer and restart it
360
+ * start also the app timer if it was paused
361
+ *
362
+ */
363
+ resetIdleTimer() {
364
+ if (this.$route.name == 'menu' || this.$route.name == 'module') return // don't reset idle timer when in menu
365
+ this.idleDetector.stopIdleTimer()
366
+
367
+ if (this.appTimer.getTimerState() == 'stopped') this.appTimer.start()
368
+ this.idleDetector.startIdleTimer(
369
+ () => this.appTimer.pause(),
370
+ this.idleTimeout
371
+ )
372
+ },
373
+
374
+ setInitialLoadingDone() {
375
+ this.initialLoading = false
376
+ },
377
+ /**
378
+ * @description set the desired language for the app default is french
379
+ * @param {String} [lang=fr]
380
+ */
381
+
382
+ setLocale(lang) {
383
+ if (!lang) lang = 'fr'
384
+ else {
385
+ lang = lang.toLowerCase()
386
+ if (lang === 'français' || lang === 'francais' || lang === 'french')
387
+ lang = 'fr'
388
+ else if (lang === 'english' || lang === 'anglais') lang = 'en'
389
+ else lang = lang.substring(0, 2).toLowerCase()
390
+ this.$i18n.locale = lang
391
+ //Set vuetify locale
392
+ this.$vuetify.locale.current = lang
393
+ }
394
+ },
395
+
396
+ getScormState() {
397
+ return this.$scorm.GetValue('cmi.suspend_data')
398
+ },
399
+
400
+ getBrowser() {
401
+ let browser
402
+ const test = (regexp) => regexp.test(window.navigator.userAgent) //defining the testing fonction
403
+
404
+ switch (true) {
405
+ case test(/edg/i):
406
+ browser = 'Edge'
407
+ break
408
+ case test(/trident/i):
409
+ browser = 'IE'
410
+ break
411
+ case test(/firefox|fxios/i):
412
+ browser = 'Firefox'
413
+ break
414
+ case test(/opr\//i):
415
+ browser = 'Opera'
416
+ break
417
+ case test(/ucbrowser/i):
418
+ browser = 'UC Browser'
419
+ break
420
+ case test(/samsungbrowser/i):
421
+ browser = 'Samsung Browser'
422
+ break
423
+ case test(/chrome|chromium|crios/i):
424
+ if (navigator.brave && navigator.brave.isBrave()) browser = 'Brave'
425
+ // as of 2020-11 Brave adds a Class brave in the navigator object
426
+ else browser = 'Chrome'
427
+ break
428
+ case test(/safari/i):
429
+ browser = 'Safari'
430
+ break
431
+ default:
432
+ browser = 'Other'
433
+ break
434
+ }
435
+ // Return browser initiale
436
+ return browser
437
+ },
438
+
439
+ detectDevice() {
440
+ let device = null
441
+ if (
442
+ navigator.appVersion.includes('iPad') ||
443
+ navigator.appVersion.includes('iPhone')
444
+ )
445
+ device = 'iOSDevice'
446
+ else if (navigator.appVersion.includes('Android'))
447
+ device = 'AndroidDevice'
448
+ else device = 'Desktop'
449
+
450
+ return device
451
+ },
452
+
453
+ executeCloseEventTriggered() {
454
+ if (
455
+ this.getModuleInfo.packageType === 'scorm' &&
456
+ this.$scorm.initialized
457
+ ) {
458
+ this.$bus.$emit('save-to-scorm', 'disconnect') // emit event to save to scorm before closing the app
459
+ }
460
+ //Xapi context
461
+ else if (
462
+ this.getModuleInfo.packageType === 'xapi' &&
463
+ this.getConnectionInfo &&
464
+ this.getConnectionInfo.actor &&
465
+ this.getConnectionInfo.remote
466
+ ) {
467
+ this.endLesson(null, true)
468
+ }
469
+ },
470
+
471
+ updateTracker(name, status) {
472
+ this.updateCompStatusTracker({ name, status })
473
+ },
474
+ //============================Multiple Satements Sending at once======================================
475
+ /**
476
+ * @description Send a custom statements to the lrs. Receive some custom params and build a statement to send
477
+ * @param {Array} stmts- array of stamtents objects that will be send to the server
478
+ * @param {Function} cb
479
+ */
480
+ async sendXapiStatements(stmts, cb = null, withFetch = true) {
481
+ cb = cb || null
482
+ if (!stmts) return
483
+ let stmtsArray = []
484
+
485
+ stmts.constructor === Object
486
+ ? stmtsArray.push(stmts)
487
+ : (stmtsArray = [...stmts])
488
+
489
+ const crsParams = this.getModuleInfo
490
+
491
+ if (
492
+ this.getConnectionInfo &&
493
+ this.getConnectionInfo.actor &&
494
+ this.getConnectionInfo.remote
495
+ ) {
496
+ const stmtsQueue = []
497
+
498
+ stmtsArray.forEach((stmtObj) => {
499
+ const {
500
+ id,
501
+ result = null,
502
+ definition,
503
+ objectType,
504
+ type,
505
+ description,
506
+ verb,
507
+ extensions,
508
+ duration,
509
+ completion
510
+ } = stmtObj
511
+
512
+ //define the activity id
513
+ let activityId
514
+ //=========================== activity ID of Object ==========================================
515
+ /*
516
+ * Define the acitivity id of the stmt according to following:
517
+ * The statement is sent at component/ element level : there is Id and Id is not null (id of the element)
518
+ * The statement is sent at module level. there is no id
519
+ * the Statement must be sent at the course level: There course_id exist and id is the course_id
520
+ */
521
+ if (id && id !== crsParams.courseID)
522
+ activityId = `${this.getConnectionInfo.activity_id}/${id}`
523
+ else if (crsParams.courseID && id === crsParams.courseID)
524
+ activityId = this.getConnectionInfo.activity_id.replace(
525
+ `/${crsParams.id}`,
526
+ ''
527
+ )
528
+ else activityId = `${this.getConnectionInfo.activity_id}`
529
+
530
+ //define the statement object
531
+ let stmt = {
532
+ actor: this.getConnectionInfo.actor,
533
+ verb: (() => {
534
+ if (verb && this.$xapi.verbs[verb.trim()])
535
+ return this.$xapi.verbs[verb.trim()]
536
+ else {
537
+ /**
538
+ * Determine the verb to use by:
539
+ * Checking if the activity is already included in the data fetch from server.
540
+ * If not, fetch directly from the server
541
+ * There is a found, verb is 'RESUMED'
542
+ * There is no found, verb is 'INITIALIZED'
543
+ */
544
+
545
+ //Regex to test that id contains list of word
546
+ const regex =
547
+ /(menu|activite_(\d)*|A(\d){2}|conclusion|introduction)$/gm
548
+
549
+ switch (true) {
550
+ case regex.test(activityId): {
551
+ //ID is of activity (menu, conclusion, activite, intro )
552
+ const activityStr = activityId.split('/').toReversed()[0]
553
+
554
+ if (!this.getUserInteraction[activityId])
555
+ return this.$xapi.verbs.initialized
556
+
557
+ let activity_ref
558
+ //Define the activity reference
559
+ if (['menu', 'introduction', 'A00'].includes(activityStr))
560
+ activity_ref = 'A00'
561
+ else if (['conclusion', 'A99'].includes(activityStr))
562
+ activity_ref = 'A99'
563
+ else if (activityStr.includes('activite_')) {
564
+ const aNum = activityStr.split('_')[1]
565
+ activity_ref = aNum.length > 1 ? `A${aNum}` : `A0${aNum}`
566
+ } else activity_ref = activityStr // should be AXX
567
+
568
+ if (
569
+ this.getUserInteraction &&
570
+ this.getUserInteraction[activity_ref]
571
+ )
572
+ return this.$xapi.verbs.resumed
573
+ else return this.$xapi.verbs.initialized
574
+ }
575
+ default: {
576
+ //ID of Lesson or doesn't exist relate (menu, conclusion, activite, intro )
577
+ if (
578
+ this.$xapi._getAgent(
579
+ this.getConnectionInfo.actor.mbox.replace(
580
+ 'mailto:',
581
+ ''
582
+ ),
583
+ activityId
584
+ )
585
+ )
586
+ return this.$xapi.verbs.resumed
587
+ else return this.$xapi.verbs.initialized
588
+ }
589
+ }
590
+ }
591
+ })(),
592
+ object: {
593
+ id: activityId,
594
+ definition: {
595
+ name: {
596
+ [this.displayLang]: `${definition}`
597
+ },
598
+ description: {
599
+ [this.displayLang]: `${description}`,
600
+ type: type || 'http://activitystrea.ms/schema/1.0/page'
601
+ }
602
+ },
603
+ objectType: objectType || 'Activity'
604
+ },
605
+ context: {
606
+ contextActivities: {}
607
+ }
608
+ }
609
+ //===================== contextActivity parent =====================//
610
+ /*
611
+ Define parent in the contextActivity
612
+ When:
613
+ 1- when we have the id (parent === module)
614
+ 2- when with have no id but we have a course_id (parent is cours_id)
615
+ */
616
+ const activityParent = (() => {
617
+ if (crsParams.courseID && !id)
618
+ return {
619
+ id: this.getConnectionInfo.activity_id.replace(
620
+ `/${crsParams.id}`,
621
+ ''
622
+ ),
623
+ objectType: objectType || 'Activity'
624
+ }
625
+ else if (id && id !== crsParams.courseID)
626
+ return {
627
+ id: `${this.getConnectionInfo.activity_id}`,
628
+ objectType: objectType || 'Activity'
629
+ }
630
+ else return null
631
+ })()
632
+
633
+ // Add the parent key of the context activity
634
+ if (activityParent)
635
+ stmt.context.contextActivities['parent'] = [activityParent]
636
+
637
+ //===================== contextActivity grouping =====================//
638
+
639
+ //Defining the Grouping of the context activity
640
+ const activityGrouping = (() => {
641
+ if (
642
+ activityParent &&
643
+ crsParams.courseID &&
644
+ activityParent.id.includes(crsParams.id)
645
+ )
646
+ return {
647
+ id: this.getConnectionInfo.activity_id.replace(
648
+ `/${crsParams.id}`,
649
+ ''
650
+ )
651
+ }
652
+ else return null
653
+ })()
654
+ //Adding the grouping of the context activity
655
+ if (activityGrouping)
656
+ stmt.context.contextActivities['grouping'] = [activityGrouping]
657
+
658
+ //add result data to statement
659
+ if (result) stmt['result'] = result
660
+
661
+ // Add duration info to statement when activity is complete
662
+ if (['completed', 'suspended', 'terminated'].includes(verb)) {
663
+ if (!stmt.result) stmt.result = {} // check if exist
664
+
665
+ let d
666
+
667
+ duration
668
+ ? (d = `PT${duration.split(':')[0]}H${duration.split(':')[1]}M${
669
+ duration.split(':')[2]
670
+ }S`)
671
+ : (d = `PT${
672
+ this.appTimer
673
+ .ISOTimeParser(this.activityDuration)
674
+ .split(':')[0]
675
+ }H${
676
+ this.appTimer
677
+ .ISOTimeParser(this.activityDuration)
678
+ .split(':')[1]
679
+ }M${
680
+ this.appTimer
681
+ .ISOTimeParser(this.activityDuration)
682
+ .split(':')[2]
683
+ }S`)
684
+
685
+ stmt.result['duration'] = d
686
+ //Set the completion status of the result
687
+ if (completion) stmt.result['completion'] = completion
688
+ }
689
+
690
+ if (
691
+ extensions &&
692
+ extensions.constructor === Array &&
693
+ extensions.length > 0
694
+ ) {
695
+ // ===================== Extension of the Object definition =====================//
696
+ //Validate each entry given in the extension Array
697
+ extensions.forEach((e) => {
698
+ //entry must be of type Object
699
+ if (e.constructor !== Object)
700
+ throw new Error(`'${e}' is not a valid value. Must be Object`)
701
+
702
+ //Entry Must have id and content keys
703
+ const validKey = ['id', 'content']
704
+ Object.keys(e).forEach((key) => {
705
+ if (!validKey.includes(key))
706
+ throw new Error(`Not valid key '${key}' for entry ${e}`)
707
+
708
+ //id must be a String
709
+ if (key === 'id' && e[key] && e[key].constructor !== String)
710
+ throw new Error(`'${key}' must be of type String`)
711
+ })
712
+
713
+ stmt.object.definition['extensions'] = {
714
+ ...stmt.object.definition['extensions'],
715
+ [`${activityId}/${e.id}`]: e.content
716
+ }
717
+ })
718
+ }
719
+
720
+ //============================================================
721
+ stmtsQueue.push(stmt)
722
+ })
723
+ this.$xapi._sendStatements(stmtsQueue, cb, withFetch)
724
+ }
725
+ },
726
+
727
+ /**
728
+ * @param {Function} cb
729
+ * @param {Bool} option - set if must use Fetch API or not. default = false
730
+ */
731
+ endLesson(cb, option = true) {
732
+ cb = cb || null
733
+ let text
734
+
735
+ //Defining the text to display for stmt description and definition
736
+ switch (this.$i18n.locale) {
737
+ case 'fr':
738
+ if (this.getModuleInfo.courseID)
739
+ text = `Le ${this.getModuleInfo.id} de ${this.getModuleInfo.courseID}`
740
+ else text = `Le ${this.getModuleInfo.id}`
741
+ break
742
+ case 'en':
743
+ if (this.getModuleInfo.courseID)
744
+ text = `The ${this.getModuleInfo.id} of ${this.getModuleInfo.courseID}`
745
+ else text = `The ${this.getModuleInfo.id}`
746
+ break
747
+ }
748
+
749
+ // Retrieve only user data in lessons front its interaction that we want to send to the LRS.
750
+ // Note: User Settings are sent on a different URI and statement
751
+ const { isFistTime, userSettings, ...lessonsData } =
752
+ this.getUserInteraction
753
+
754
+ const stmt = {
755
+ verb: 'suspended',
756
+ definition: text,
757
+ description: text,
758
+ extensions: [
759
+ {
760
+ id: 'ending-point',
761
+ content: (() =>
762
+ this.$route.name == 'menu'
763
+ ? this.$helper.getRoutesFromVueRouter().meta.children[0]
764
+ ._namedRoute
765
+ : this.$route.name)()
766
+ },
767
+ {
768
+ id: 'user-data',
769
+ content: {
770
+ routeHistory: (() => {
771
+ const history = this.getRouteHistory.toReversed() //get the route history from the last
772
+
773
+ history[0] = this.$route.meta // change the last recored route to the current route
774
+
775
+ return history.toReversed()
776
+ })(),
777
+ ...lessonsData
778
+ }
779
+ }
780
+ ],
781
+ duration: this.appTimer.ISOTimeParser(this.lessonDuration)
782
+ }
783
+
784
+ const endStmt = { ...stmt }
785
+ endStmt.verb = 'terminated'
786
+ //================================STATEMENT FOR THE PLAYBAR ===============================================
787
+ //Creating custom statement
788
+ const stmtPlaybar = {
789
+ verb: 'played',
790
+ definition: text,
791
+ description: text,
792
+ extensions: [
793
+ {
794
+ id: 'playbar-values',
795
+ content: {
796
+ ...this.getMediaPlaybarValues()
797
+ }
798
+ }
799
+ ]
800
+ }
801
+ //================================STATEMENT FOR THE SETTINGS ===============================================
802
+
803
+ //Creating custom statement
804
+ const stmtUserSettings = {
805
+ id: (() => {
806
+ if (!this.getModuleInfo.courseID) return null
807
+ return this.getModuleInfo.courseID
808
+ })(),
809
+ verb: 'preferred',
810
+ definition: text,
811
+ description: text,
812
+ extensions: [
813
+ {
814
+ id: 'application-settings',
815
+ content: {
816
+ ...this.getApplicationSettings
817
+ }
818
+ // content: {
819
+ // bookmark: false
820
+ // } //for testing
821
+ }
822
+ ]
823
+ }
824
+ //================================END STATEMENT ===============================================
825
+
826
+ this.sendXapiStatements(
827
+ [stmtPlaybar, stmtUserSettings, stmt, endStmt],
828
+ null,
829
+ option
830
+ ) //send xapi statement
831
+
832
+ if (this.appTimer.getTimerState() === 'started')
833
+ setTimeout(() => this.stopAppTimer(), 0) //clear the timer
834
+
835
+ if (cb) cb()
836
+ },
837
+
838
+ /** @description Method to reset the progression status of user
839
+ * the function reset first the user data in the app store then
840
+ * reset the idbStore if APP is running locally or prepare a statement with new userData Object to be sent over the LRS
841
+ * for the connected userAgent, activity if APP is Running online
842
+ * @event 'send-xapi-statement' emit to AppBaseModule.vue that will be executed by sendXapiStatement(s)
843
+ */
844
+ async resetUserData() {
845
+ this.$bus.$emit('set-comp-status', 'appBase', 'loading')
846
+ this.setUserMetaData({}) // resetting store record with existing for user data
847
+ this.setApplicationSettings({}) // resetting store record for settings
848
+ this.setRouteHistory([]) // resetting store record for all last visited pages
849
+
850
+ // this.$bus.$emit('stop-timer')
851
+ this.stopAppTimer()
852
+ if (this.getModuleInfo.packageType !== 'xapi') return
853
+
854
+ if (!this.getConnectionInfo || this.getConnectionInfo.remote == false)
855
+ return this.$idb.deleteDataInDB(this.getModuleInfo.idbID) //Must call the idb delete methode to reset indexDB store
856
+
857
+ //Send a completion statement for the current activity
858
+ let aTitle = `${this.$t('text.activity')} ${this.getConnectionInfo.activity_id}`
859
+
860
+ //Custom text for description and definition of the stament to be sent
861
+ let text
862
+ this.$i18n.locale == 'fr'
863
+ ? (text = `L'${aTitle} de ${this.getModuleInfo.id}`)
864
+ : (text = `The ${aTitle} of ${this.getModuleInfo.id}`)
865
+
866
+ /*Dispatch a send statement event to method AppBaseModule sendXapiStatement(s)
867
+ Content values are:
868
+ User data URI: "host_address/course_ID/Lesson_ID/user-data" ex: "http://localhost:8080/330N01FD6001/m1l1/ // user-data" and
869
+ User Bookmark URI: "host_address/course_ID/Lesson_ID/ending-point" ex: ""http://localhost:8080/330N01FD6001/m1l1/ending-point": "introduction"
870
+ */
871
+ const baseStmt = {
872
+ definition: text,
873
+ description: text
874
+ }
875
+ const exitStmt = {
876
+ ...baseStmt,
877
+ verb: 'exited'
878
+ }
879
+
880
+ const endStmt = {
881
+ ...baseStmt,
882
+ verb: 'terminated',
883
+ extensions: [
884
+ {
885
+ id: 'ending-point',
886
+ content: (() =>
887
+ this.$route.name == 'menu'
888
+ ? this.$helper.getRoutesFromVueRouter().meta.children[0]
889
+ ._namedRoute
890
+ : this.$route.name)()
891
+ },
892
+ {
893
+ id: 'user-data',
894
+ content: new Object()
895
+ }
896
+ ],
897
+ duration: null,
898
+ completion: false // Resetting completion state value to
899
+ }
900
+ //================================STATEMENT FOR THE SETTINGS ===============================================
901
+
902
+ //Creating custom statement
903
+ const stmtUserSettings = {
904
+ ...baseStmt,
905
+ id: (() => {
906
+ if (this.getModuleInfo.courseID) return this.getModuleInfo.courseID
907
+ else return null
908
+ })(),
909
+
910
+ verb: 'preferred',
911
+ extensions: [
912
+ {
913
+ id: 'application-settings',
914
+ content: new Object()
915
+ }
916
+ ]
917
+ }
918
+ //================================END STATEMENT ===============================================
919
+
920
+ this.sendXapiStatements([stmtUserSettings, exitStmt, endStmt])
921
+
922
+ this.$bus.$emit('set-comp-status', 'appBase', 'ready')
923
+ const actorMbox = this.getConnectionInfo.actor.mbox.replace('mailto:', '')
924
+ const activityId = this.getConnectionInfo.activity_id
925
+ // Creating a asynchronous fecth method that would resolve after a 2 second
926
+ const fetchData = async () => {
927
+ //funtion that will make a multiple request on the server
928
+ const requests = async () => {
929
+ const _url = new URL(activityId)
930
+ const parentID = `${_url.origin}/${this.getModuleInfo.courseID}` // redefining activity id for statement
931
+
932
+ const lessonProgressParam = { email: actorMbox, activityId }
933
+ const lessonStateParam = {
934
+ email: actorMbox,
935
+ activityId,
936
+ verb: 'suspended'
937
+ }
938
+ const lessonPosionParam = {
939
+ email: actorMbox,
940
+ activityId
941
+ }
942
+ const playbarParam = {
943
+ email: actorMbox,
944
+ activityId,
945
+ verb: 'played'
946
+ }
947
+ const preferencesParam = {
948
+ email: actorMbox,
949
+ activityId: parentID,
950
+ verb: 'preferred'
951
+ }
952
+
953
+ const fetchParams = [
954
+ lessonProgressParam,
955
+ lessonStateParam,
956
+ lessonPosionParam,
957
+ playbarParam,
958
+ preferencesParam
959
+ ]
960
+
961
+ // Using Promise.all to make parallel requests
962
+ const {
963
+ userData,
964
+ savedPoint,
965
+ preferredSettings,
966
+ playbarValues,
967
+ lessonStatus
968
+ } = await this.$xapi._getBulkData(fetchParams)
969
+
970
+ return {
971
+ userData,
972
+ savedPoint,
973
+ preferredSettings,
974
+ playbarValues,
975
+ lessonStatus
976
+ }
977
+ }
978
+ return new Promise((resolve) => {
979
+ setTimeout(() => resolve(requests()), 2000)
980
+ })
981
+ }
982
+
983
+ // Make request over user current data after sending statement to assure that the resetting worked
984
+
985
+ const usrData = await fetchData()
986
+ this.$bus.$emit('reset-complete')
987
+ console.warn('😄 USER STATUS: ', {
988
+ userID: actorMbox,
989
+ activityId,
990
+ Record: usrData
991
+ })
992
+ },
993
+
994
+ /**
995
+ * @description- Scroll directly to the target* containt
996
+ * target content can be any Node defined by the ID
997
+ * @param {String} target = id of HTMLElement. if target don't existe will scroll to wrapper-content
998
+ * @param {Obj} opt = scroll behavior to apply default { top: 0, left: 0, behavior: 'auto' }
999
+ */
1000
+
1001
+ moveTo(target, opt = { top: 0, left: 0, behavior: 'auto' }) {
1002
+ if (target.constructor !== String)
1003
+ throw new Error(
1004
+ '⚠️ Not supported value for @target. Must be of type String'
1005
+ )
1006
+
1007
+ let skipTo = document.querySelector(`#wrapper-content`) //default definition of main element
1008
+
1009
+ if (target) {
1010
+ let targetEl = document.querySelector(`#${target}`) // search for node element specified as main
1011
+
1012
+ if (targetEl) skipTo = targetEl
1013
+ }
1014
+
1015
+ if (skipTo) opt.top = skipTo.offsetTop + opt.top // Set scroll top from target top
1016
+
1017
+ if (skipTo) skipTo.setAttribute('tabIndex', -1) //Allowing accessibility control with keyboard
1018
+ window.scrollTo(opt)
1019
+ if (skipTo) this.resetFocus(skipTo) //focus on the target
1020
+ },
1021
+
1022
+ /**
1023
+ * @description Reset the focus the element
1024
+ * @param {HTMLElement} e - element that will get focus
1025
+ */
1026
+ resetFocus(e) {
1027
+ if (e) e.focus()
1028
+ },
1029
+ /**
1030
+ * @description Method to validate that application settings are correctly
1031
+ * Opens error component when configurations are not correct
1032
+ *
1033
+ */
1034
+ checkForErrors() {
1035
+ let errMessage
1036
+
1037
+ if (validateAppContent(this.appConfig, true).length) {
1038
+ return (this.error = validateAppContent(this.appConfig, true))
1039
+ }
1040
+
1041
+ const { list: activitiesList } = this.getAllActivities()
1042
+ let { no_menu, is_single_activity } = this.appConfig
1043
+
1044
+ let noMenu = no_menu == undefined ? false : no_menu
1045
+
1046
+ let isSingleActivity =
1047
+ is_single_activity == undefined ? false : is_single_activity
1048
+
1049
+ let err
1050
+ if (this.getErrorMenu) err = true
1051
+ else err = false
1052
+
1053
+ let consoleMsg = ''
1054
+ //error if There is more than one activity when is_single_activity
1055
+ switch (true) {
1056
+ case isSingleActivity && activitiesList.size > 1:
1057
+ 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`
1058
+
1059
+ consoleMsg = `Cannot have more than 1 activity with this settings configuration. Either disable 💲is_single_activity or DELETE ALL others activities`
1060
+ this.error.push(errMessage)
1061
+ break
1062
+
1063
+ case isSingleActivity && !noMenu:
1064
+ 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>`
1065
+ consoleMsg = `Cannot have MENU with current settings configuration. Either set 💲no_menu:false or 💲is_single_activity:false`
1066
+ this.error.push(errMessage)
1067
+ break
1068
+
1069
+ case err:
1070
+ 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 `
1071
+ consoleMsg = this.getErrorMenu
1072
+ this.error.push(errMessage)
1073
+ break
1074
+ }
1075
+
1076
+ if (errMessage)
1077
+ return console.warn(
1078
+ `%c WARNING!>>> ${consoleMsg}`,
1079
+ 'background: orange; color: white; display: block; border-radius:5px; margin:5px;'
1080
+ )
1081
+ },
1082
+ /**
1083
+ * @description Handle the keydown event to detect if F5 key was pressed
1084
+ * The function set the f5KeyPressed to true if F5 key was pressed
1085
+ * @param {Event} e - keydown event
1086
+ */
1087
+ handleF5KeyPressed(e) {
1088
+ this.f5KeyPressed = e.code == 'F5'
1089
+ },
1090
+
1091
+ /**
1092
+ * @description Handle the beforeunload event to save data when the user close the app or reload the app
1093
+ * The function check if the F5 key was pressed to avoid sending data when the user reload the app
1094
+ */
1095
+ handleBeforeUnload() {
1096
+ //prevent app to send/Save data when F5 is pressed to reload app
1097
+ if (this.f5KeyPressed) return
1098
+
1099
+ this.executeCloseEventTriggered()
1100
+ }
1101
+ }
1102
+ }
1103
+ </script>
1104
+ <style lang="scss">
1105
+ #App-base {
1106
+ width: 100%;
1107
+ height: 100%;
1108
+ min-height: 100vh;
1109
+
1110
+ #overlay_loading {
1111
+ position: fixed !important;
1112
+ width: 100%;
1113
+ height: 100%;
1114
+ top: 0;
1115
+ left: 0;
1116
+ z-index: 9999;
1117
+ }
1118
+
1119
+ #build-info {
1120
+ opacity: 0.9;
1121
+ position: fixed;
1122
+ right: 0;
1123
+ bottom: 0;
1124
+ color: #fff;
1125
+ text-shadow: -1px 1px 1px rgba(0, 0, 0, 0.4);
1126
+ background-color: hotpink;
1127
+ font-family: serif, Impact, Arial;
1128
+ font-size: 11px;
1129
+ padding: 3px;
1130
+ cursor: pointer;
1131
+ z-index: 999;
1132
+ span {
1133
+ text-align: center;
1134
+ display: block;
1135
+ }
1136
+ }
1137
+
1138
+ .box {
1139
+ width: 100%;
1140
+ height: 100%;
1141
+ position: relative;
1142
+ }
1143
+ }
1144
+ .grp-spinners {
1145
+ transform: scale(1.5);
1146
+ #icon_loading {
1147
+ animation: spin 1.3s cubic-bezier(0.57, 0.87, 0.77, 0.87) infinite;
1148
+ }
1149
+ }
1150
+ @keyframes spin {
1151
+ 100% {
1152
+ transform: rotate(360deg);
1153
+ }
1154
+ }
1155
+
1156
+ .timer {
1157
+ position: sticky; /* Sticks inside the parent container */
1158
+ bottom: 90%; /* Sticks to the top edge of the parent */
1159
+ left: 100%; /* Sticks to the left edge of the parent */
1160
+ display: flex;
1161
+ align-items: center;
1162
+ justify-content: flex-start;
1163
+ font-size: 1em;
1164
+ width: fit-content;
1165
+ color: #333;
1166
+ opacity: 0.9;
1167
+ background-color: #eaabb6b3; /* Optional: ensures readability when content scrolls */
1168
+ padding: 5px 12px; /* Some breathing space */
1169
+ z-index: 1000; /* Keeps it above other elements */
1170
+ }
1171
+ </style>