diff --git a/README.md b/README.md index f0442ae..153e968 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,12 @@ Please refer to [example.json](example.json) for the JSON snippet which should b | Property | Type | Default | Description | |--|--|--|--| -| \_isEnabled | Boolean | `false` | Set to `true` to enable the extension. +| \_isEnabled | Boolean | `true` | Set to `true` to enable the extension. +| \_isRestoreEnabled | Boolean | `false` | If enabled, the course will use xAPI data to restore course progress. +| \_isDebugModeEnabled | Boolean | `false` | If enabled, the course will continue to generate statements for testing but it will instead post the statement to the console instead of the LRS. | \_activityId | String | `""` | Used to populate the Activity id included in _tincan.xml_ and for use in the Statement and State API. The value will be overriden if changed on the hosting environment. | \_revision | String | `""` | This helps identify users running particular versions of the content, should functionality have been changed or issues found which require updates/voided statements for users running a specific revision. Where data has changed significantly, a new Activity id is recommended. -| \_tracking | Object | `{ _storeQuestionResponses: true, _questionInteractions: true, _assessmentsCompletion: false, _assessmentCompletion: true }` | +| \_tracking | Object | `{ _storeQuestionResponses: true, _questionInteractions: true, _assessmentsCompletion: false, _assessmentCompletion: true, _navbar: true, _trackingErrors: true, _visua11y: false }` | | \_errors | Object | See [example.json](example.json) | The `title` and `body` content to be displayed to the user in the event that data cannot be sent to the LRS. `_isCancellable: false` can be used to prevent the user from closing the error and proceeding with the course (requires fix for https://github.com/adaptlearning/adapt_framework/issues/2743).

The types of errors are as follows: ## Launch types @@ -31,21 +33,26 @@ The granular detail of what is included within each statement and how to analyse |--|--| | http://adlnet.gov/expapi/activities/course | Defines the course model. | http://adlnet.gov/expapi/verbs/module | Defines a content page model. -| http://adlnet.gov/expapi/activities/interaction | Defines a component model. +| http://adlnet.gov/expapi/activities/interaction | Defines a component model or UI element. | http://adlnet.gov/expapi/verbs/cmi.interaction | Defines a question component model. | http://adlnet.gov/expapi/activities/assessment | Defines an assessment article model and/or the overall assessment. +| http://id.tincanapi.com/activitytype/resource | Defines a resource link. +| http://id.tincanapi.com/activitytype/vocabulary-word | Defines a glossary term. ### Verbs | Verb | id | Description | |--|--|--| | initialized | http://adlnet.gov/expapi/verbs/initialized | Sent once the course has been launched, to indicate the start of the session. | terminated | http://adlnet.gov/expapi/verbs/terminated | Sent once the course has been closed, to indicate the end of a session. -| preferred | http://adlnet.gov/expapi/verbs/preferred | Sent to indicate a user’s language preference. +| preferred | http://adlnet.gov/expapi/verbs/preferred | Sent to indicate a user’s language preference or chosen course preference in visua11y. | completed | http://adlnet.gov/expapi/verbs/completed | Sent once an activity within the course has been completed. As default, restricted to course and page models. Component completion can be enabled as required for each model in _components.json_ via `_recordCompletion: true`. | experienced | http://adlnet.gov/expapi/verbs/experienced | Sent each time a user leaves a page. | answered | http://adlnet.gov/expapi/verbs/answered | Sent each time a question component has been answered. | passed | http://adlnet.gov/expapi/verbs/passed | Sent should a user achieve the required passmark for an assessment. | failed | http://adlnet.gov/expapi/verbs/failed | Sent should a user score below the required passmark for an assessment. +| received | http://activitystrea.ms/schema/1.0/receive | Sent one a learner is served a notification, such as a loss of connectivity with an LMS. +| accessed | http://activitystrea.ms/schema/1.0/access | Sent one a learner selected a link to a resource. +| viewed | http://id.tincanapi.com/verb/viewed | Sent one a learner opened a glossary term within the drawer. ## Limitations - Does not support multiple endpoints for storing data to more than one LRS. diff --git a/example.json b/example.json index edfe618..09cf864 100644 --- a/example.json +++ b/example.json @@ -2,6 +2,8 @@ { "_xapi": { "_isEnabled": true, + "_isRestoreEnabled": true, + "_isDebugModeEnabled": false, "_comment": "included in tincan.xml. _activityId will be overriden if changed on the hosting environment.", "_activityId": "https://example.com/unique_identifier", "_revision": "", @@ -9,7 +11,10 @@ "_storeQuestionResponses": true, "_questionInteractions": true, "_assessmentsCompletion": false, - "_assessmentCompletion": true + "_assessmentCompletion": true, + "_navbar": true, + "_trackingErrors": true, + "_visua11y": false }, "_errors": { "_launch": { @@ -32,4 +37,4 @@ } } } -} \ No newline at end of file +} diff --git a/js/adapt-kineo-xapi.js b/js/adapt-kineo-xapi.js index 6eed94a..8f03154 100644 --- a/js/adapt-kineo-xapi.js +++ b/js/adapt-kineo-xapi.js @@ -1,173 +1,180 @@ -define([ - 'core/js/adapt', - './offlineStorage', - './errorNotificationModel', - './launchModel', - './statementModel', - './stateModel', - 'libraries/xapiwrapper.min', - 'libraries/url-polyfill', - 'libraries/fetch-polyfill', - 'libraries/promise-polyfill.min' -], function(Adapt, OfflineStorage, ErrorNotificationModel, LaunchModel, StatementModel, StateModel) { - - const xAPI = Backbone.Controller.extend({ - - _isInitialized: false, - _config: null, - _activityId: null, - _restoredLanguage: null, - _currentLanguage: null, - errorNotificationModel: null, - launchModel: null, - statementModel: null, - stateModel: null, - - initialize: function() { - this.listenToOnce(Adapt, 'offlineStorage:prepare', this.onPrepareOfflineStorage); - }, - - initializeErrorNotification: function() { - const config = this._config._errors; - - this.errorNotificationModel = new ErrorNotificationModel(config); - }, - - initializeLaunch: function() { - this.listenToOnce(Adapt, { - 'xapi:launchInitialized': this.onLaunchInitialized, - 'xapi:launchFailed': this.onLaunchFailed - }); - - this.launchModel = new LaunchModel(); - }, - - initializeState: function() { - this.listenTo(Adapt, 'xapi:stateLoaded', this.onStateLoaded); - - const config = { - activityId: this.getActivityId(), - registration: this.launchModel.get('registration'), - actor: this.launchModel.get('actor') - }; - - this.stateModel = new StateModel(config, { - wrapper: this.launchModel.getWrapper(), - _tracking: this._config._tracking - }); - }, - - initializeStatement: function() { - const config = { - activityId: this.getActivityId(), - registration: this.launchModel.get('registration'), - revision: this._config._revision || null, - actor: this.launchModel.get('actor'), - contextActivities: this.launchModel.get('contextActivities') - }; - - this.statementModel = new StatementModel(config, { - wrapper: this.launchModel.getWrapper(), - _tracking: this._config._tracking - }); - }, - - getActivityId: function() { - if (this._activityId) return this._activityId; - - const lrs = this.launchModel.getWrapper().lrs; - // if using cmi5 the activityId MUST come from the query string for "cmi.defined" statements - let activityId = lrs.activityId || lrs.activity_id || this._config._activityId; - - // @todo: should activityId be derived from URL? Would suggest not as the domain may not be controlled by the author/vendor - if (!activityId) Adapt.trigger('xapi:activityIdError'); - - // remove trailing slash if included - activityId = activityId.replace(/\/?$/, ''); - - return activityId; - }, - - // @todo: offlineStorage conflict with adapt-contrib-spoor - onPrepareOfflineStorage: function() { - this._config = Adapt.config.get('_xapi'); - - if (this._config && this._config._isEnabled) { - Adapt.wait.begin(); - - Adapt.offlineStorage.initialize(OfflineStorage); - - this.initializeErrorNotification(); - this.initializeLaunch(); - } - }, - - onLaunchInitialized: function() { - this._activityId = this.getActivityId(); - - if (!this._activityId) { - this.onLaunchFailed(); - - return; - } - - this.listenToOnce(Adapt, { - 'offlineStorage:ready': this.onOfflineStorageReady, - 'app:dataLoaded': this.onDataLoaded, - 'adapt:initialize': this.onAdaptInitialize - }); - - this.listenTo(Adapt, { - 'app:languageChanged': this.onLanguageChanged - }); +import Adapt from 'core/js/adapt'; +import wait from 'core/js/wait'; +import offlineStorage from 'core/js/offlineStorage'; +import OfflineStorageExtension from './offlineStorageExtension'; +import ErrorNotificationModel from './errorNotificationModel'; +import LaunchModel from './launchModel'; +import StatementModel from './statementModel'; +import StateModel from './stateModel'; +import 'libraries/xapiwrapper.min'; +import 'libraries/url-polyfill'; +import 'libraries/fetch-polyfill'; +import 'libraries/promise-polyfill.min'; + +class xAPI extends Backbone.Controller { + + defaults() { + return { + _isInitialized: false, + _config: null, + _activityId: null, + _restoredLanguage: null, + _currentLanguage: null, + errorNotificationModel: null, + launchModel: null, + statementModel: null, + stateModel: null, + _navbar: true, + _visua11y: true, + _trackingErrors: true + }; + } + + initialize() { + this.listenToOnce(Adapt, 'offlineStorage:prepare', this.onPrepareOfflineStorage); + } + + initializeErrorNotification() { + const config = this._config._errors; + + this.errorNotificationModel = new ErrorNotificationModel(config); + } + + initializeLaunch() { + this.listenToOnce(Adapt, { + 'xapi:launchInitialized': this.onLaunchInitialized, + 'xapi:launchFailed': this.onLaunchFailed + }); + + this.launchModel = new LaunchModel(); + } + + initializeState() { + this.listenTo(Adapt, 'xapi:stateLoaded', this.onStateLoaded); + + const config = { + activityId: this.getActivityId(), + registration: this.launchModel.get('registration'), + actor: this.launchModel.get('actor') + }; + + this.stateModel = new StateModel(config, { + wrapper: this.launchModel.getWrapper(), + _tracking: this._config._tracking + }); + } + + initializeStatement() { + const config = { + activityId: this.getActivityId(), + registration: this.launchModel.get('registration'), + revision: this._config._revision || null, + actor: this.launchModel.get('actor'), + contextActivities: this.launchModel.get('contextActivities') + }; + + this.statementModel = new StatementModel(config, { + wrapper: this.launchModel.getWrapper(), + _tracking: this._config._tracking + }); + } + + getActivityId() { + if (this._activityId) return this._activityId; + + const lrs = this.launchModel.getWrapper().lrs; + // if using cmi5 the activityId MUST come from the query string for "cmi.defined" statements + let activityId = lrs.activityId || lrs.activity_id || this._config._activityId; + + // @todo: should activityId be derived from URL? Would suggest not as the domain may not be controlled by the author/vendor + if (!activityId) Adapt.trigger('xapi:activityIdError'); + + // remove trailing slash if included + activityId = activityId.replace(/\/?$/, ''); + + return activityId; + } + + // @todo: offlineStorage conflict with adapt-contrib-spoor + onPrepareOfflineStorage() { + this._config = Adapt.config.get('_xapi'); + + if (this._config && this._config._isEnabled) { + wait.begin(); + + offlineStorage.initialize(OfflineStorageExtension); + + this.initializeErrorNotification(); + this.initializeLaunch(); + } + } - this.initializeState(); - this.initializeStatement(); - }, + onLaunchInitialized() { + this._activityId = this.getActivityId(); - onLaunchFailed: function() { - Adapt.wait.end(); + if (!this._activityId) { + this.onLaunchFailed(); - Adapt.offlineStorage.setReadyStatus(); - }, + return; + } - onOfflineStorageReady: function() { - this._restoredLanguage = Adapt.offlineStorage.get('lang'); - }, + this.listenToOnce(Adapt, { + 'offlineStorage:ready': this.onOfflineStorageReady, + 'app:dataLoaded': this.onDataLoaded, + 'adapt:initialize': this.onAdaptInitialize + }); - onLanguageChanged: function(lang) { - const languageConfig = Adapt.config.get('_languagePicker'); + this.listenTo(Adapt, { + 'app:languageChanged': this.onLanguageChanged + }); - if (languageConfig && languageConfig._isEnabled && this._restoredLanguage !== lang && this._currentLanguage !== lang) { - // only reset if language has changed since the course was started - not neccessary before - const resetState = this._isInitialized && !languageConfig._restoreStateOnLanguageChange; + this.initializeState(); + this.initializeStatement(); + } - // @todo: only send when via a user selection? If `"_showOnCourseLoad": false`, this will still be triggered - Adapt.trigger('xapi:languageChanged', lang, resetState); - } + onLaunchFailed() { + wait.end(); - this._restoredLanguage = null; - this._currentLanguage = lang; - }, + offlineStorage.setReadyStatus(); + } - onStateLoaded: function() { - Adapt.wait.end(); + onOfflineStorageReady() { + this._restoredLanguage = offlineStorage.get('lang'); + } - Adapt.offlineStorage.setReadyStatus(); - }, + onLanguageChanged(lang) { + const languageConfig = Adapt.config.get('_languagePicker'); - onDataLoaded: function() { - const globals = Adapt.course.get('_globals'); - if (!globals._learnerInfo) globals._learnerInfo = {}; - globals._learnerInfo = Adapt.offlineStorage.get('learnerinfo'); - }, + if (languageConfig && languageConfig._isEnabled && this._restoredLanguage !== lang && this._currentLanguage !== lang) { + // only reset if language has changed since the course was started - not neccessary before + const resetState = this._isInitialized && !languageConfig._restoreStateOnLanguageChange; - onAdaptInitialize: function() { - this._isInitialized = true; + // @todo: only send when via a user selection? If `"_showOnCourseLoad": false`, this will still be triggered + Adapt.trigger('xapi:languageChanged', lang, resetState); } - }); + this._restoredLanguage = null; + this._currentLanguage = lang; + } + + onStateLoaded() { + wait.end(); + + offlineStorage.setReadyStatus(); + } + + onDataLoaded() { + const globals = Adapt.course.get('_globals'); + if (!globals._learnerInfo) globals._learnerInfo = {}; + globals._learnerInfo = offlineStorage.get('learnerinfo'); + } + + onAdaptInitialize() { + this._isInitialized = true; + } + +} - return new xAPI(); +Adapt.xapi = new xAPI(); -}); +export default Adapt.xapi; diff --git a/js/errorNotificationModel.js b/js/errorNotificationModel.js index bd856c1..ae7981f 100644 --- a/js/errorNotificationModel.js +++ b/js/errorNotificationModel.js @@ -1,115 +1,116 @@ -define([ - 'core/js/adapt' -], function(Adapt) { - - const LAUNCH_ERROR_ID = 'launch-error'; - const ACTIVITYID_ERROR_ID = 'activityId-error'; - const LRS_ERROR_ID = 'lrs-error'; - - const ErrorNotificationModel = Backbone.Model.extend({ - - _isReady: false, - _isNotifyOpen: false, - _isDeferredLoadingError: false, - _currentNotifyId: null, - - initialize: function() { - this.listenToOnce(Adapt, { - 'app:dataLoaded': this.onDataLoaded - }); - - this.listenTo(Adapt, { - 'xapi:launchError': this.onShowLaunchError, - 'xapi:activityIdError': this.onShowActivityIdError, - 'xapi:lrsError': this.onShowLRSError, - 'notify:closed': this.onNotifyClosed - }); - }, - - _showNotification: function(config, id) { - if (this._isReady) { - if (!this._isNotifyOpen) { - Adapt.log.error(config.title); - - const notifyConfig = this._getNotifyConfig(config, id); - - Adapt.trigger('notify:popup', notifyConfig); - - this._isNotifyOpen = true; - this._currentNotifyId = id; - - } else if (this._currentNotifyId !== id) { - this.listenToOnce(Adapt, 'notify:closed', _.partial(this._showNotification, config, id)); - } - } else { - this._isDeferredLoadingError = true; - - this.listenToOnce(Adapt, 'app:dataLoaded', _.partial(this._showNotification, config, id)); - } - }, - - _getNotifyConfig: function(config, id) { - const notifyConfig = { - title: config.title, - body: config.body, - _classes: 'xAPIError ' + id + ' ' + config._classes, - _isxAPIError: true - }; - - let isCancellable = true; - - if (config.hasOwnProperty('_isCancellable')) { - isCancellable = config._isCancellable; - notifyConfig._isCancellable = isCancellable; - notifyConfig._closeOnShadowClick = !isCancellable; +import Adapt from "core/js/adapt"; +import wait from "core/js/wait"; + +const LAUNCH_ERROR_ID = 'launch-error'; +const ACTIVITYID_ERROR_ID = 'activityId-error'; +const LRS_ERROR_ID = 'lrs-error'; + +class ErrorNotificationModel extends Backbone.Model { + + defaults() { + return { + _isReady: false, + _isNotifyOpen: false, + _isDeferredLoadingError: false, + _currentNotifyId: null + }; + } + + initialize() { + this.listenToOnce(Adapt, { + 'app:dataLoaded': this.onDataLoaded + }); + + this.listenTo(Adapt, { + 'xapi:launchError': this.onShowLaunchError, + 'xapi:activityIdError': this.onShowActivityIdError, + 'xapi:lrsError': this.onShowLRSError, + 'notify:closed': this.onNotifyClosed + }); + } + + _showNotification(config, id) { + if (this._isReady) { + if (!this._isNotifyOpen) { + Adapt.log.error(config.title); + + const notifyConfig = this._getNotifyConfig(config, id); + + Adapt.trigger('notify:popup', notifyConfig); + + this._isNotifyOpen = true; + this._currentNotifyId = id; + + } else if (this._currentNotifyId !== id) { + this.listenToOnce(Adapt, 'notify:closed', _.partial(this._showNotification, config, id)); } + } else { + this._isDeferredLoadingError = true; - return notifyConfig; - }, + this.listenToOnce(Adapt, 'app:dataLoaded', _.partial(this._showNotification, config, id)); + } + } + + _getNotifyConfig(config, id) { + const notifyConfig = { + title: config.title, + body: config.body, + _classes: 'xAPIError ' + id + ' ' + config._classes, + _isxAPIError: true + }; + + let isCancellable = true; + + if (config.hasOwnProperty('_isCancellable')) { + isCancellable = config._isCancellable; + notifyConfig._isCancellable = isCancellable; + notifyConfig._closeOnShadowClick = !isCancellable; + } - /** - * Can't show notify until data has loaded due to `import_globals` in template - */ - onDataLoaded: function() { - this._isReady = true; + return notifyConfig; + } - if (this._isDeferredLoadingError) { - Adapt.wait.begin(); + /** + * Can't show notify until data has loaded due to `import_globals` in template + */ + onDataLoaded() { + this._isReady = true; - $('.loading').hide(); - } - }, + if (this._isDeferredLoadingError) { + wait.begin(); - onShowLaunchError: function() { - this._showNotification(this.get('_launch'), LAUNCH_ERROR_ID); - }, + $('.loading').hide(); + } + } - onShowActivityIdError: function() { - this._showNotification(this.get('_activityId'), ACTIVITYID_ERROR_ID); - }, + onShowLaunchError() { + this._showNotification(this.get('_launch'), LAUNCH_ERROR_ID); + } - onShowLRSError: function() { - this._showNotification(this.get('_lrs'), LRS_ERROR_ID); - }, + onShowActivityIdError() { + this._showNotification(this.get('_activityId'), ACTIVITYID_ERROR_ID); + } - onNotifyClosed: function(notify) { - if (!notify.model.get('_isxAPIError')) return; + onShowLRSError() { + this._showNotification(this.get('_lrs'), LRS_ERROR_ID); + } - if (this._isDeferredLoadingError) { - Adapt.wait.end(); + onNotifyClosed(notify) { + if (!notify.model.get('_isxAPIError')) return; - this._isDeferredLoadingError = false; + if (this._isDeferredLoadingError) { + wait.end(); - // cancel other errors if launch failed and user dismissed, as it won't track regardless - this.stopListening(); - } + this._isDeferredLoadingError = false; - this._isNotifyOpen = false; - this._currentNotifyId = null; + // cancel other errors if launch failed and user dismissed, as it won't track regardless + this.stopListening(); } - }); + this._isNotifyOpen = false; + this._currentNotifyId = null; + } - return ErrorNotificationModel; +} -}); +export default ErrorNotificationModel; diff --git a/js/launchModel.js b/js/launchModel.js index 1e64a89..bef0653 100644 --- a/js/launchModel.js +++ b/js/launchModel.js @@ -1,129 +1,125 @@ -define([ - 'core/js/adapt' -], function(Adapt) { +import Adapt from "core/js/adapt"; - const LaunchModel = Backbone.Model.extend({ +class LaunchModel extends Backbone.Model { - defaults: { + defaults() { + return { + _xAPIWrapper: null, + _retryCount: 0, + _retryLimit: 1, registration: null, actor: null, contextActivities: { grouping: [] } - }, + }; + } - _xAPIWrapper: null, - _retryCount: 0, - _retryLimit: 1, + initialize() { + this.initializeLaunch(); + } - initialize: function() { - this.initializeLaunch(); - }, + initializeLaunch() { + const lrs = ADL.XAPIWrapper.lrs; - initializeLaunch: function() { - const lrs = ADL.XAPIWrapper.lrs; + /** + * can auth be sent through in a different process, e.g. OAuth? + * lrs.endpoint && lrs.auth have defaults in the ADL xAPIWrapper, so can't assume their existence means they are the correct credentials - errors will be handled when communicating with the LRS + */ + if (lrs.endpoint && lrs.auth && lrs.actor) { + this._xAPIWrapper = ADL.XAPIWrapper; - /** - * can auth be sent through in a different process, e.g. OAuth? - * lrs.endpoint && lrs.auth have defaults in the ADL xAPIWrapper, so can't assume their existence means they are the correct credentials - errors will be handled when communicating with the LRS - */ - if (lrs.endpoint && lrs.auth && lrs.actor) { - this._xAPIWrapper = ADL.XAPIWrapper; + // add trailing slash if missing in endpoint + lrs.endpoint = lrs.endpoint.replace(/\/?$/, '/'); - // add trailing slash if missing in endpoint - lrs.endpoint = lrs.endpoint.replace(/\/?$/, '/'); + // @todo: capture grouping URL params - unsure what data this actually contains based on specs - unlike contextActivities for ADL Launch + const launchData = { + registration: lrs.registration || null, + actor: JSON.parse(lrs.actor)/*, + 'contextActivities': launchdata.contextActivities */ + }; - // @todo: capture grouping URL params - unsure what data this actually contains based on specs - unlike contextActivities for ADL Launch - const launchData = { - registration: lrs.registration || null, - actor: JSON.parse(lrs.actor)/*, - 'contextActivities': launchdata.contextActivities */ - }; + this.set(launchData); - this.set(launchData); + this.triggerLaunchInitialized(); + } else { + ADL.launch(_.bind(this.onADLLaunchAttempt, this), false); + } + } + + getWrapper() { + return this._xAPIWrapper; + } + + showErrorNotification() { + Adapt.trigger('xapi:launchError'); + } + + triggerLaunchInitialized() { + _.defer(function() { + Adapt.trigger('xapi:launchInitialized'); + }); + } + + onADLLaunchAttempt(err, launchdata, wrapper) { + /* + 200 = OK + 400 = launch already initialized + 404 = launch removed + */ + if (!err) { + this._xAPIWrapper = wrapper; + + // can ADL launch include registration? + const launchData = { + registration: launchdata.registration || null, + actor: launchdata.actor + }; + + const contextActivities = launchdata.contextActivities; + if (!(_.isEmpty(contextActivities))) launchData.contextActivities = contextActivities; - this.triggerLaunchInitialized(); - } else { - ADL.launch(_.bind(this.onADLLaunchAttempt, this), false); - } - }, - - getWrapper: function() { - return this._xAPIWrapper; - }, - - showErrorNotification: function() { - Adapt.trigger('xapi:launchError'); - }, - - triggerLaunchInitialized: function() { - _.defer(function() { - Adapt.trigger('xapi:launchInitialized'); - }); - }, - - onADLLaunchAttempt: function(err, launchdata, wrapper) { - /* - 200 = OK - 400 = launch already initialized - 404 = launch removed - */ - if (!err) { - this._xAPIWrapper = wrapper; - - // can ADL launch include registration? - const launchData = { - registration: launchdata.registration || null, - actor: launchdata.actor - }; - - const contextActivities = launchdata.contextActivities; - if (!(_.isEmpty(contextActivities))) launchData.contextActivities = contextActivities; - - this.set(launchData); - - // store launch server details should browser be reloaded and launch server session still initialized - sessionStorage.setItem('lrs', JSON.stringify(wrapper.lrs)); - sessionStorage.setItem('launchData', JSON.stringify(launchData)); - - this.triggerLaunchInitialized(); - } else if (performance.navigation.type === 1) { - this.onReload(); - } else if (this._retryCount < this._retryLimit) { - this._retryCount++; - - this.initializeLaunch(); - } else { - this.onLaunchFail(); - } - }, + this.set(launchData); - // if launch session expired, will the next request to the launch server produce an error notification for the user? - onReload: function() { - const lrs = JSON.parse(sessionStorage.getItem('lrs')); - const launchData = JSON.parse(sessionStorage.getItem('launchData')); + // store launch server details should browser be reloaded and launch server session still initialized + sessionStorage.setItem('lrs', JSON.stringify(wrapper.lrs)); + sessionStorage.setItem('launchData', JSON.stringify(launchData)); - if (!lrs || !launchData) { - this.onLaunchFail(); - return; - } + this.triggerLaunchInitialized(); + } else if (performance.navigation.type === 1) { + this.onReload(); + } else if (this._retryCount < this._retryLimit) { + this._retryCount++; - this._xAPIWrapper = ADL.XAPIWrapper; - this._xAPIWrapper.changeConfig(lrs); + this.initializeLaunch(); + } else { + this.onLaunchFail(); + } + } - this.set(launchData); + // if launch session expired, will the next request to the launch server produce an error notification for the user? + onReload() { + const lrs = JSON.parse(sessionStorage.getItem('lrs')); + const launchData = JSON.parse(sessionStorage.getItem('launchData')); - this.triggerLaunchInitialized(); - }, + if (!lrs || !launchData) { + this.onLaunchFail(); + return; + } - onLaunchFail: function() { - Adapt.trigger('xapi:launchFailed'); + this._xAPIWrapper = ADL.XAPIWrapper; + this._xAPIWrapper.changeConfig(lrs); - this.showErrorNotification(); - } + this.set(launchData); + + this.triggerLaunchInitialized(); + } - }); + onLaunchFail() { + Adapt.trigger('xapi:launchFailed'); - return LaunchModel; + this.showErrorNotification(); + } +} -}); +export default LaunchModel; diff --git a/js/offlineStorageExtension.js b/js/offlineStorageExtension.js new file mode 100644 index 0000000..1880250 --- /dev/null +++ b/js/offlineStorageExtension.js @@ -0,0 +1,49 @@ +const OfflineStorageExtension = { + + // will be set to StateModel once ready - store values until then + model: new Backbone.Model(), + + get(name) { + switch (name.toLowerCase()) { + case 'student':// for backwards-compatibility. learnerInfo is preferred and will give more information + return this.model.get('actor').name; + case 'learnerinfo': + return this._getActorData(); + default: + return this.model.get(name); + } + }, + + set(name, value) { + this.model.set(name, value); + }, + + _getActorData: function() { + const actor = this.model.get('actor'); + const id = this._getIdFromActor(actor); + + /* + * don't think we should make any judgement on name format for firstname or lastname, as there is no standard for this in xAPI + * if actor.name not provided, use id IFI + */ + return { + id, + name: actor.name || id + }; + }, + + _getIdFromActor(actor) { + let id = actor.openid; + if (id) return id; + + id = actor.account && actor.account.name; + if (id) return id; + + id = actor.mbox || actor.mbox_sha1sum; + + return id; + } + +} + +export default OfflineStorageExtension; diff --git a/js/stateModel.js b/js/stateModel.js index 3a3cff4..e9ff6ad 100644 --- a/js/stateModel.js +++ b/js/stateModel.js @@ -1,455 +1,461 @@ -define([ - 'core/js/adapt', - './offlineStorage', - 'libraries/async.min' -], function(Adapt, OfflineStorage, Async) { +import Adapt from "core/js/adapt"; +import wait from "core/js/wait"; +import OfflineStorageExtension from "./offlineStorageExtension"; +import * as Async from 'libraries/async.min'; - const COMPONENTS_KEY = 'components'; - const DURATIONS_KEY = 'durations'; +const COMPONENTS_KEY = 'components'; +const DURATIONS_KEY = 'durations'; - const StateModel = Backbone.Model.extend({ +class StateModel extends Backbone.Model { - defaults: { + defaults() { + return { activityId: null, actor: null, registration: null, components: [], - durations: [] - }, - - _tracking: { - _storeQuestionResponses: true - }, - - xAPIWrapper: null, - _isInitialized: false, - _isLoaded: false, - _isRestored: false, - _queues: {}, - - initialize: function(attributes, options) { - this.listenTo(Adapt, { - 'adapt:initialize': this.onAdaptInitialize, - 'xapi:languageChanged': this.onLanguageChanged, - 'xapi:stateReset': this.onStateReset - }); + durations: [], + xAPIWrapper: null, + _isInitialized: false, + _isLoaded: false, + _isRestored: false, + _queues: {}, + _tracking: { + _storeQuestionResponses: true + } + }; + } - this.xAPIWrapper = options.wrapper; + initialize(attributes, options) { - _.extend(this._tracking, options._tracking); + this.listenTo(Adapt, { + 'adapt:initialize': this.onAdaptInitialize, + 'xapi:languageChanged': this.onLanguageChanged, + 'xapi:stateReset': this.onStateReset + }); - this.setOfflineStorageModel(); + // Instance Variables + this._queues = this.get('_queues'); + this.xAPIWrapper = options.wrapper; + this._tracking = { + ...this.defaults()._tracking, + ...options._tracking + }; - this.load(); - }, + this.setOfflineStorageModel(); - setOfflineStorageModel: function() { - const attributes = OfflineStorage.model.attributes; + this.load(); + } - for (const key in attributes) { - this.set(key, attributes[key]); - this.save(key); - } + setOfflineStorageModel() { + const attributes = OfflineStorageExtension.model.attributes; - OfflineStorage.model = this; - }, + for (const key in attributes) { + this.set(key, attributes[key]); + this.save(key); + } - setupListeners: function() { - this.setupModelListeners(); + OfflineStorageExtension.model = this; + } - // don't create new listeners for those which are still valid from initial course load - if (this._isInitialized) return; + setupListeners() { + this.setupModelListeners(); - this.listenTo(Adapt, { - 'xapi:durationsChange': this.onDurationChange, - // ideally core would trigger `state.change` for each model so we don't have to return early for non-component types - 'state:change': this.onTrackableStateChange - }); - }, + // don't create new listeners for those which are still valid from initial course load + if (this._isInitialized) return; - setupModelListeners: function() { - this.listenTo(Adapt.course, { - 'change:_totalDuration': this.onDurationChange - }); + this.listenTo(Adapt, { + 'xapi:durationsChange': this.onDurationChange, + // ideally core would trigger `state.change` for each model so we don't have to return early for non-component types + 'state:change': this.onTrackableStateChange + }); + } - this.listenTo(Adapt.contentObjects, { - 'change:_totalDuration': this.onDurationChange - }); - }, + setupModelListeners() { + this.listenTo(Adapt.course, { + 'change:_totalDuration': this.onDurationChange + }); - removeModelListeners: function() { - this.stopListening(Adapt.course, { - 'change:_totalDuration': this.onDurationChange - }); + this.listenTo(Adapt.contentObjects, { + 'change:_totalDuration': this.onDurationChange + }); + } - this.stopListening(Adapt.contentObjects, { - 'change:_totalDuration': this.onDurationChange - }); - }, - - showErrorNotification: function() { - Adapt.trigger('xapi:lrsError'); - }, - - load: function() { - const scope = this; - - this._getStates(function(err, data) { - if (err) { - scope.showErrorNotification(); - } else { - const states = data; - - Async.each(states, function(id, callback) { - scope._fetchState(id, function(err, data) { - if (err) { - scope.showErrorNotification(); - } else { - // all data is now saved and retrieved as JSON, so no need for try/catch anymore - scope.set(id, data); - } - - callback(); - }); - }, function(err) { + removeModelListeners() { + this.stopListening(Adapt.course, { + 'change:_totalDuration': this.onDurationChange + }); + + this.stopListening(Adapt.contentObjects, { + 'change:_totalDuration': this.onDurationChange + }); + } + + showErrorNotification() { + Adapt.trigger('xapi:lrsError'); + } + + load() { + const scope = this; + + this._getStates(function(err, data) { + if (err) { + scope.showErrorNotification(); + } else { + const states = data; + + Async.each(states, function(id, callback) { + scope._fetchState(id, function(err, data) { if (err) { scope.showErrorNotification(); } else { - scope._isLoaded = true; - - Adapt.trigger('xapi:stateLoaded'); - - scope.listenTo(Adapt, 'app:dataReady', scope.onDataReady); + // all data is now saved and retrieved as JSON, so no need for try/catch anymore + scope.set(id, data); } + + callback(); }); - } - }); - }, + }, function(err) { + if (err) { + scope.showErrorNotification(); + } else { + scope._isLoaded = true; - reset: function() { - const scope = this; + Adapt.trigger('xapi:stateLoaded'); - this._getStates(function(err, data) { - if (err) { - scope.showErrorNotification(); - } else { - Adapt.wait.begin(); + scope.listenTo(Adapt, 'app:dataReady', scope.onDataReady); + } + }); + } + }); + } - const states = data; + reset() { + const scope = this; - Async.each(states, function(id, callback) { - scope.delete(id, callback); - }, function(err) { - if (err) scope.showErrorNotification(); + this._getStates(function(err, data) { + if (err) { + scope.showErrorNotification(); + } else { + wait.begin(); - const data = {}; - data[COMPONENTS_KEY] = []; - data[DURATIONS_KEY] = []; - scope.set(data, { silent: true }); + const states = data; - Adapt.wait.end(); - }); - } - }); - }, + Async.each(states, function(id, callback) { + scope.delete(id, callback); + }, function(err) { + if (err) scope.showErrorNotification(); - restore: function() { - this._restoreComponentsData(); - this._restoreDurationsData(); + const data = {}; + data[COMPONENTS_KEY] = []; + data[DURATIONS_KEY] = []; + scope.set(data, { silent: true }); - this._isRestored = true; + wait.end(); + }); + } + }); + } - Adapt.trigger('xapi:stateReady'); - }, + restore() { + this._restoreComponentsData(); + this._restoreDurationsData(); - set: function(id, value) { - Backbone.Model.prototype.set.apply(this, arguments); + this._isRestored = true; - // @todo: save every time the value changes, or only on specific events? - if (this._isLoaded) { - if (Adapt.terminate) { - this.save(id); - } else { - const queue = this._getQueueById(id); - queue.push(id); - } + Adapt.trigger('xapi:stateReady'); + } + + set(id, value) { + Backbone.Model.prototype.set.apply(this, arguments); + + // @todo: save every time the value changes, or only on specific events? + if (this._isLoaded) { + if (Adapt.terminate) { + this.save(id); + } else { + const queue = this._getQueueById(id); + queue.push(id); } - }, + } + } - save: function(id, callback) { - const scope = this; - const state = this.get(id); - const data = JSON.stringify(state); + save(id, callback) { + const scope = this; + const state = this.get(id); + const data = JSON.stringify(state); - // ensure any data being set is completed before restoring following languageChange - if (!this._isRestored) Adapt.wait.begin(); + // ensure any data being set is completed before restoring following languageChange + if (!this._isRestored) wait.begin(); - fetch(this._getStateURL(id), { - keepalive: Adapt.terminate || false, - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: this.xAPIWrapper.lrs.auth, - 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion - }, - body: data - }).then(function(response) { - // if (response) Adapt.log.debug(response); + fetch(this._getStateURL(id), { + keepalive: Adapt.terminate || false, + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: this.xAPIWrapper.lrs.auth, + 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion + }, + body: data + }).then(function(response) { + // if (response) Adapt.log.debug(response); - if (!response.ok) throw Error(response.statusText); + if (!response.ok) throw Error(response.statusText); - if (callback) callback(); + if (callback) callback(); - if (!scope._isRestored) Adapt.wait.end(); + if (!scope._isRestored) wait.end(); - return response; - }).catch(function(error) { - scope.showErrorNotification(); + return response; + }).catch(function(error) { + scope.showErrorNotification(); - if (callback) callback(); + if (callback) callback(); - if (!scope._isRestored) Adapt.wait.end(); - }); - }, + if (!scope._isRestored) wait.end(); + }); + } - delete: function(id, callback) { - this.unset(id, { silent: true }); + delete(id, callback) { + this.unset(id, { silent: true }); - const scope = this; + const scope = this; - fetch(this._getStateURL(id), { - method: 'DELETE', - headers: { - Authorization: this.xAPIWrapper.lrs.auth, - 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion - } - }).then(function(response) { - if (!response.ok) throw Error(response.statusText); + fetch(this._getStateURL(id), { + method: 'DELETE', + headers: { + Authorization: this.xAPIWrapper.lrs.auth, + 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion + } + }).then(function(response) { + if (!response.ok) throw Error(response.statusText); - if (callback) callback(); + if (callback) callback(); - return response; - }).catch(function(error) { - scope.showErrorNotification(); + return response; + }).catch(function(error) { + scope.showErrorNotification(); - if (callback) callback(); - }); - }, - - _getStateURL: function(stateId) { - const activityId = this.get('activityId'); - const agent = this.get('actor'); - const registration = this.get('registration'); - let url = this.xAPIWrapper.lrs.endpoint + 'activities/state?activityId=' + encodeURIComponent(activityId) + '&agent=' + encodeURIComponent(JSON.stringify(agent)); - - if (registration) url += '®istration=' + encodeURIComponent(registration); - if (stateId) url += '&stateId=' + encodeURIComponent(stateId); - - return url; - }, - - _fetchState: function(stateId, callback) { - const scope = this; - - fetch(this._getStateURL(stateId), { - // cache: "no-cache", - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: this.xAPIWrapper.lrs.auth, - 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion, - 'Cache-Control': 'no-cache', - Pragma: 'no-cache' - } - }).then(function(response) { - if (!response.ok) throw Error(response.statusText); + if (callback) callback(); + }); + } - return response.json(); - }).then(function(data) { - // if (data) Adapt.log.debug(data); + _getStateURL(stateId) { + const activityId = this.get('activityId'); + const agent = this.get('actor'); + const registration = this.get('registration'); + const { endpoint } = this.xAPIWrapper.lrs; - if (callback) callback(null, data); - }).catch(function(error) { - scope.showErrorNotification(); + let url = this.xAPIWrapper.lrs.endpoint + 'activities/state?activityId=' + encodeURIComponent(activityId) + '&agent=' + encodeURIComponent(JSON.stringify(agent)); - if (callback) callback(); - }); - }, + if (registration) url += '®istration=' + encodeURIComponent(registration); + if (stateId) url += '&stateId=' + encodeURIComponent(stateId); - _getStates: function(callback) { - const scope = this; + return url; + } - Adapt.wait.begin(); + _fetchState(stateId, callback) { + const scope = this; - this._fetchState(null, function(err, data) { - if (err) { - scope.showErrorNotification(); + fetch(this._getStateURL(stateId), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: this.xAPIWrapper.lrs.auth, + 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion, + 'Cache-Control': 'no-cache', + Pragma: 'no-cache' + } + }).then(function(response) { + if (!response.ok) throw Error(response.statusText); - if (callback) callback(err, null); - } else { - if (callback) callback(null, data); - } + return response.json(); + }).then(function(data) { + if (data) Adapt.log.debug(data); - Adapt.wait.end(); - }); - }, + if (callback) callback(null, data); + }).catch(function(error) { + console.error('Error fetching data:', error); + scope.showErrorNotification(); - _getQueueById: function(id) { - let queue = this._queues[id]; + if (callback) callback(); + }); + } - if (!queue) { - queue = this._queues[id] = Async.queue(_.bind(function(id, callback) { - this.save(id, callback); - }, this), 1); + _getStates(callback) { + const scope = this; - queue.drain = function() { - Adapt.log.debug('State API queue cleared for ' + id); - }; + wait.begin(); + + this._fetchState(null, function(err, data) { + if (err) { + scope.showErrorNotification(); + + if (callback) callback(err, null); + } else { + if (callback) callback(null, data); } - return queue; - }, + wait.end(); + }); + } - _restoreComponentsData: function() { - this._restoreDataForState(this.get(COMPONENTS_KEY), Adapt.components.models); - }, + _getQueueById(id) { + let queue = this._queues[id]; - _restoreDurationsData: function() { - const models = [Adapt.course].concat(Adapt.contentObjects.models); + if (!queue) { + queue = this._queues[id] = Async.queue(_.bind(function(id, callback) { + this.save(id, callback); + }, this), 1); - this._restoreDataForState(this.get(DURATIONS_KEY), models); - }, + queue.drain = function() { + Adapt.log.debug('State API queue cleared for ' + id); + }; + } - _restoreDataForState: function(state, models) { - if (state.length > 0) { - state.forEach(function(data) { - const model = models.filter(function(model) { - return model.get('_id') === data._id; - })[0]; + return queue; + } - // account for models being removed in content without xAPI activityId or registration being changed - if (model) { - const restoreData = _.omit(data, '_id'); + _restoreComponentsData() { + this._restoreDataForState(this.get(COMPONENTS_KEY), Adapt.components.models); + } - model.set(restoreData); - } - }); - } - }, - - _setComponentsData: function(model, data) { - const stateId = COMPONENTS_KEY; - const state = this.get(stateId); - const modelId = model.get('_id'); - const modelIndex = this._getStateModelIndexFor(state, modelId); - - // responses won't properly be restored until https://github.com/adaptlearning/adapt_framework/issues/2522 is resolved - if (model.get('_isQuestionType') && !this._tracking._storeQuestionResponses) { - delete data._isInteractionComplete; - delete data._userAnswer; - delete data._isSubmitted; - delete data._score; - delete data._isCorrect; - delete data._attemptsLeft; - } + _restoreDurationsData() { + const models = [Adapt.course].concat(Adapt.contentObjects.models); - (modelIndex === null) ? state.push(data) : state[modelIndex] = data; + this._restoreDataForState(this.get(DURATIONS_KEY), models); + } - this.set(stateId, state); - }, + _restoreDataForState(state, models) { + if (state.length > 0) { + state.forEach(function(data) { + const model = models.filter(function(model) { + return model.get('_id') === data._id; + })[0]; - _setDurationsData: function(model) { - const stateId = DURATIONS_KEY; - const state = this.get(stateId); - const modelId = model.get('_id'); - const modelIndex = this._getStateModelIndexFor(state, modelId); + // account for models being removed in content without xAPI activityId or registration being changed + if (model) { + const restoreData = _.omit(data, '_id'); - const data = { - _id: modelId, - _totalDuration: model.get('_totalDuration') - }; + model.set(restoreData); + } + }); + } + } + + _setComponentsData(model, data) { + const stateId = COMPONENTS_KEY; + const state = this.get(stateId); + const modelId = model.get('_id'); + const modelIndex = this._getStateModelIndexFor(state, modelId); + + // responses won't properly be restored until https://github.com/adaptlearning/adapt_framework/issues/2522 is resolved + if (model.get('_isQuestionType') && !this._tracking._storeQuestionResponses) { + delete data._isInteractionComplete; + delete data._userAnswer; + delete data._isSubmitted; + delete data._score; + delete data._isCorrect; + delete data._attemptsLeft; + } - (modelIndex === null) ? state.push(data) : state[modelIndex] = data; + (modelIndex === null) ? state.push(data) : state[modelIndex] = data; - this.set(stateId, state); - }, + this.set(stateId, state); + } - _getStateModelIndexFor: function(state, modelId) { - for (let i = 0, l = state.length; i < l; i++) { - const stateModel = state[i]; - if (stateModel._id === modelId) return i; - } + _setDurationsData(model) { + const stateId = DURATIONS_KEY; + const state = this.get(stateId); + const modelId = model.get('_id'); + const modelIndex = this._getStateModelIndexFor(state, modelId); - return null; - }, + const data = { + _id: modelId, + _totalDuration: model.get('_totalDuration') + }; - onDataReady: function() { - Adapt.wait.queue(_.bind(function() { - this.restore(); - }, this)); - }, + (modelIndex === null) ? state.push(data) : state[modelIndex] = data; - onAdaptInitialize: function() { - this.setupListeners(); + this.set(stateId, state); + } - this._isInitialized = true; - }, + _getStateModelIndexFor(state, modelId) { + for (let i = 0, l = state.length; i < l; i++) { + const stateModel = state[i]; + if (stateModel._id === modelId) return i; + } - onDurationChange: function(model) { - this._setDurationsData(model); - }, + return null; + } - onTrackableStateChange: function(model, state) { - if (model.get('_type') !== 'component') return; + onDataReady() { + const config = Adapt.config.get('_xapi'); + if (config?._isRestoreEnabled === false) return; + wait.queue(_.bind(function() { + this.restore(); + }, this)); + } - // don't actually need state._isCorrect and state._score for questions, but save trackable state as provided - this._setComponentsData(model, state); - }, + onAdaptInitialize() { + this.setupListeners(); - onStateReset: function() { - this.reset(); - }, + this._isInitialized = true; + } - // @todo: resetting could go against cmi5 spec, if course was previously completed - can't send multiple "cmi.defined" statements for some verbs - onLanguageChanged: function(lang, isStateReset) { - if (this._isInitialized) this.removeModelListeners(); + onDurationChange(model) { + this._setDurationsData(model); + } - this._isRestored = false; + onTrackableStateChange(model, state) { + if (model.get('_type') !== 'component') return; - if (!isStateReset) return; + // don't actually need state._isCorrect and state._score for questions, but save trackable state as provided + this._setComponentsData(model, state); + } - const scope = this; + onStateReset() { + this.reset(); + } - this._getStates(function(err, data) { - if (err) { - scope.showErrorNotification(); - } else { - Adapt.wait.begin(); + // @todo: resetting could go against cmi5 spec, if course was previously completed - can't send multiple "cmi.defined" statements for some verbs + onLanguageChanged(lang, isStateReset) { + if (this._isInitialized) this.removeModelListeners(); - const states = data; + this._isRestored = false; - const statesToReset = states.filter(function(id) { - return id !== 'lang'; - }); + if (!isStateReset) return; - Async.each(statesToReset, function(id, callback) { - scope.delete(id, callback); - }, function(err) { - if (err) scope.showErrorNotification(); + const scope = this; - const data = {}; - data[COMPONENTS_KEY] = []; - data[DURATIONS_KEY] = []; - scope.set(data, { silent: true }); + this._getStates(function(err, data) { + if (err) { + scope.showErrorNotification(); + } else { + wait.begin(); - Adapt.wait.end(); - }); - } - }); - } + const states = data; + + const statesToReset = states.filter(function(id) { + return id !== 'lang'; + }); - }); + Async.each(statesToReset, function(id, callback) { + scope.delete(id, callback); + }, function(err) { + if (err) scope.showErrorNotification(); + + const data = {}; + data[COMPONENTS_KEY] = []; + data[DURATIONS_KEY] = []; + scope.set(data, { silent: true }); + + wait.end(); + }); + } + }); + } - return StateModel; +} -}); +export default StateModel; diff --git a/js/statementModel.js b/js/statementModel.js index 9cc1101..237583b 100644 --- a/js/statementModel.js +++ b/js/statementModel.js @@ -1,442 +1,541 @@ -define([ - 'core/js/adapt', - 'core/js/enums/completionStateEnum', - './statements/initializedStatementModel', - './statements/terminatedStatementModel', - './statements/preferredLanguageStatementModel', - './statements/completedStatementModel', - './statements/experiencedStatementModel', - './statements/mcqStatementModel', - './statements/sliderStatementModel', - './statements/confidenceSliderStatementModel', - './statements/textInputStatementModel', - './statements/matchingStatementModel', - './statements/assessmentStatementModel', - './statements/resourceItemStatementModel', - './statements/favouriteStatementModel', - './statements/unfavouriteStatementModel' -], function(Adapt, COMPLETION_STATE, InitializedStatementModel, TerminatedStatementModel, PreferredLanguageStatementModel, CompletedStatementModel, ExperiencedStatementModel, McqStatementModel, SliderStatementModel, ConfidenceSliderStatementModel, TextInputStatementModel, MatchingStatementModel, AssessmentStatementModel, ResourceItemStatementModel, FavouriteStatementModel, UnfavouriteStatementModel) { - - const StatementModel = Backbone.Model.extend({ - - _tracking: { - _questionInteractions: true, - _assessmentsCompletion: false, - _assessmentCompletion: true - }, - - xAPIWrapper: null, - _isInitialized: false, - _hasLanguageChanged: false, - _courseSessionStartTime: null, - _currentPageModel: null, - _terminate: false, - - initialize: function(attributes, options) { +import Adapt from "core/js/adapt"; +import InitializedStatementModel from "./statements/initializedStatementModel"; +import TerminatedStatementModel from "./statements/terminatedStatementModel"; +import LanguageStatementModel from "./statements/languageStatementModel"; +import Visua11yStatementModel from "./statements/visua11yStatementModel"; +import CompletedStatementModel from "./statements/completedStatementModel"; +import ExperiencedStatementModel from "./statements/experiencedStatementModel"; +import InteractedStatementModel from "./statements/interactedStatementModel"; +import ReceivedStatementModel from "./statements/receivedStatementModel"; +import McqStatementModel from "./statements/mcqStatementModel"; +import SliderStatementModel from "./statements/sliderStatementModel"; +import ConfidenceSliderStatementModel from "./statements/confidenceSliderStatementModel"; +import TextInputStatementModel from "./statements/textInputStatementModel"; +import MatchingStatementModel from "./statements/matchingStatementModel"; +import AssessmentStatementModel from "./statements/assessmentStatementModel"; +import AccessedStatementModel from "./statements/accessedStatementModel"; +import ViewedStatementModel from "./statements/viewedStatementModel"; + +class StatementModel extends Backbone.Model { + + defaults() { + return { + _tracking: { + _questionInteractions: true, + _assessmentsCompletion: false, + _assessmentCompletion: true, + _navbar: true, + _trackingErrors: true, + _visua11y: false + }, + xAPIWrapper: null, + _isInitialized: false, + _hasLanguageChanged: false, + _courseSessionStartTime: null, + _currentPageModel: null, + _terminate: false, + }; + } + + initialize(attributes, options) { + this.listenTo(Adapt, { + 'adapt:initialize': this.onAdaptInitialize, + 'xapi:languageChanged': this.onLanguageChanged + }); + + // Instance Variables + this._tracking = { + ...this.defaults()._tracking, + ...options._tracking + }; + + this.xAPIWrapper = options.wrapper; + + _.extend(this._tracking, options._tracking); + } + + setupListeners() { + this.setupModelListeners(); + + // don't create new listeners for those which are still valid from initial course load + if (this._isInitialized) return; + + this._onVisibilityChange = _.bind(this.onVisibilityChange, this); + $(document).on('visibilitychange', this._onVisibilityChange); + + this._onWindowUnload = _.bind(this.onWindowUnload, this); + $(window).on('beforeunload unload', this._onWindowUnload); + + this.listenTo(Adapt, { + 'pageView:ready': this.onPageViewReady, + 'router:location': this.onRouterLocation, + 'resources:itemClicked': this.onResourceClicked, + 'glossary:termSelected': this.onGlossaryClicked, + 'tracking:complete': this.onTrackingComplete + }); + + if (this._tracking._questionInteractions) { this.listenTo(Adapt, { - 'adapt:initialize': this.onAdaptInitialize, - 'xapi:languageChanged': this.onLanguageChanged + 'questionView:recordInteraction': this.onQuestionInteraction }); + } - this.xAPIWrapper = options.wrapper; + // @todo: if only 1 Adapt.assessment._assessments, override so we never record both statements - leave to config.json for now? + if (this._tracking._assessmentsCompletion) { + this.listenTo(Adapt, { + 'assessments:complete': this.onAssessmentsComplete + }); + } - _.extend(this._tracking, options._tracking); + if (this._tracking._assessmentCompletion) { + this.listenTo(Adapt, { + 'assessment:complete': this.onAssessmentComplete + }); + } + + if (this._tracking._navbar) { + this.listenTo(Adapt, { + 'help:opened': this.onHelpOpened, + 'navigation:toggleDrawer': this.onDrawerOpened, + 'pageLevelProgress:toggleDrawer': this.onPLPDrawerOpened + }); + } + + if (this._tracking._visua11y) { + this.listenTo(Adapt, { + 'visua11y:opened': this.onVisua11yOpened, + 'visua11y:toggle': this.onVisua11yToggle + }); + } + + if (this._tracking._trackingErrors) { + this.listenTo(Adapt, { + 'tracking:initializeError': this.onInitializeError, + 'tracking:dataError': this.onDataError, + 'tracking:connectionError': this.onConnectionError, + 'tracking:terminationError': this.onTerminationError + }); + } + } - // this.loadRecipe(); - }, + setupModelListeners() { + this.listenTo(Adapt.contentObjects, { + 'change:_isComplete': this.onContentObjectComplete + }); - loadRecipe: function() { + this.listenTo(Adapt.components, { + 'change:_isComplete': this.onComponentComplete + }); + } - }, + removeModelListeners() { + this.stopListening(Adapt.contentObjects, { + 'change:_isComplete': this.onContentObjectComplete + }); + + this.stopListening(Adapt.components, { + 'change:_isComplete': this.onComponentComplete + }); + } + + showErrorNotification() { + Adapt.trigger('xapi:lrsError'); + } + + sendInitialized() { + const config = this.attributes; + const statementModel = new InitializedStatementModel(config); + const statement = statementModel.getData(Adapt.course); + + this.send(statement); + } + + sendTerminated() { + const model = Adapt.course; + + this.setModelDuration(model); + + const config = this.attributes; + const statementModel = new TerminatedStatementModel(config); + const statement = statementModel.getData(model); + + this.send(statement); + } + + sendPreferredLanguage() { + const config = this.attributes; + const statementModel = new LanguageStatementModel(config); + const statement = statementModel.getData(Adapt.course, Adapt.config.get('_activeLanguage')); + + this.send(statement); + } + + sendCompleted(model) { + const modelType = model.get('_type'); + if (modelType === 'course' || modelType === 'page') this.setModelDuration(model); + + const config = this.attributes; + const statementModel = new CompletedStatementModel(config); + const statement = statementModel.getData(model); + + this.send(statement); + } + + sendExperienced(model) { + this.setModelDuration(model); + + const config = this.attributes; + const statementModel = new ExperiencedStatementModel(config); + const statement = statementModel.getData(model); + + this.send(statement); + + model.unset('_sessionStartTime', { silent: true }); + model.unset('_sessionDuration', { silent: true }); + } + + sendInteracted(type) { + const model = Adapt.course; + + const config = this.attributes; + const statementModel = new InteractedStatementModel(config, { _type: type }); + const statement = statementModel.getData(model); + + this.send(statement); + } + + sendReceived(type) { + const model = Adapt.course; + + const config = this.attributes; + const statementModel = new ReceivedStatementModel(config, { _type: type }); + const statement = statementModel.getData(model); + + this.send(statement); + } + + sendQuestionAnswered(model) { + const config = this.attributes; + const questionType = model.get('_component'); + let statementClass; + + // better solution than this factory type pattern? + switch (questionType) { + case 'mcq': + case 'gmcq': + statementClass = McqStatementModel; + break; + case 'slider': + statementClass = SliderStatementModel; + break; + case 'confidenceSlider': + statementClass = ConfidenceSliderStatementModel; + break; + case 'textinput': + statementClass = TextInputStatementModel; + break; + case 'matching': + statementClass = MatchingStatementModel; + break; + } - setupListeners: function() { - this.setupModelListeners(); + const statementModel = new statementClass(config); + const statement = statementModel.getData(model); - // don't create new listeners for those which are still valid from initial course load - if (this._isInitialized) return; + this.send(statement); + } - this._onVisibilityChange = _.bind(this.onVisibilityChange, this); - $(document).on('visibilitychange', this._onVisibilityChange); + sendAssessmentCompleted(model, state) { + const config = this.attributes; + const statementModel = new AssessmentStatementModel(config); + const statement = statementModel.getData(model, state); - this._onWindowUnload = _.bind(this.onWindowUnload, this); - $(window).on('beforeunload unload', this._onWindowUnload); + this.send(statement); + } - this.listenTo(Adapt, { - 'pageView:ready': this.onPageViewReady, - 'router:location': this.onRouterLocation, - 'resources:itemClicked': this.onResourceClicked, - 'tracking:complete': this.onTrackingComplete - }); + sendResourceAccessed(model) { + const config = this.attributes; + const statementModel = new AccessedStatementModel(config); + const statement = statementModel.getData(model); - if (this._tracking._questionInteractions) { - this.listenTo(Adapt, { - 'questionView:recordInteraction': this.onQuestionInteraction - }); - } + this.send(statement); + } - // @todo: if only 1 Adapt.assessment._assessments, override so we never record both statements - leave to config.json for now? - if (this._tracking._assessmentsCompletion) { - this.listenTo(Adapt, { - 'assessments:complete': this.onAssessmentsComplete - }); - } + sendGlossaryViewed(model) { + const config = this.attributes; + const statementModel = new ViewedStatementModel(config); + const statement = statementModel.getData(model); - if (this._tracking._assessmentCompletion) { - this.listenTo(Adapt, { - 'assessment:complete': this.onAssessmentComplete - }); - } - }, + this.send(statement); + } - setupModelListeners: function() { - this.listenTo(Adapt.contentObjects, { - 'change:_isComplete': this.onContentObjectComplete - }); + sendVisua11yPreference(name, state) { + const model = Adapt.course; - this.listenTo(Adapt.components, { - 'change:_isComplete': this.onComponentComplete - }); - }, + const config = this.attributes; + const statementModel = new Visua11yStatementModel(config, { _name: name, _state: state }); + const statement = statementModel.getData(model); - removeModelListeners: function() { - this.stopListening(Adapt.contentObjects, { - 'change:_isComplete': this.onContentObjectComplete - }); + this.send(statement); + } - this.stopListening(Adapt.components, { - 'change:_isComplete': this.onComponentComplete - }); - }, - - showErrorNotification: function() { - Adapt.trigger('xapi:lrsError'); - }, - - sendInitialized: function() { - const config = this.attributes; - const statementModel = new InitializedStatementModel(config); - const statement = statementModel.getData(Adapt.course); - - this.send(statement); - }, - - sendTerminated: function() { - const model = Adapt.course; - - this.setModelDuration(model); - - const config = this.attributes; - const statementModel = new TerminatedStatementModel(config); - const statement = statementModel.getData(model); - - this.send(statement); - }, - - sendPreferredLanguage: function() { - const config = this.attributes; - const statementModel = new PreferredLanguageStatementModel(config); - const statement = statementModel.getData(Adapt.course, Adapt.config.get('_activeLanguage')); - - this.send(statement); - }, - - sendCompleted: function(model) { - const modelType = model.get('_type'); - if (modelType === 'course' || modelType === 'page') this.setModelDuration(model); - - const config = this.attributes; - const statementModel = new CompletedStatementModel(config); - const statement = statementModel.getData(model); - - this.send(statement); - }, - - sendExperienced: function(model) { - this.setModelDuration(model); - - const config = this.attributes; - const statementModel = new ExperiencedStatementModel(config); - const statement = statementModel.getData(model); - - this.send(statement); - - model.unset('_sessionStartTime', { silent: true }); - model.unset('_sessionDuration', { silent: true }); - }, - - sendQuestionAnswered: function(model) { - const config = this.attributes; - const questionType = model.get('_component'); - let statementClass; - - // better solution than this factory type pattern? - switch (questionType) { - case 'mcq': - case 'gmcq': - statementClass = McqStatementModel; - break; - case 'slider': - statementClass = SliderStatementModel; - break; - case 'confidenceSlider': - statementClass = ConfidenceSliderStatementModel; - break; - case 'textinput': - statementClass = TextInputStatementModel; - break; - case 'matching': - statementClass = MatchingStatementModel; - break; - } + /* + * @todo: Add Fetch API into xAPIWrapper - https://github.com/adlnet/xAPIWrapper/issues/166 + */ + send(statement) { + const config = Adapt.config.get('_xapi'); - const statementModel = new statementClass(config); - const statement = statementModel.getData(model); - - this.send(statement); - }, - - sendAssessmentCompleted: function(model, state) { - const config = this.attributes; - const statementModel = new AssessmentStatementModel(config); - const statement = statementModel.getData(model, state); - - this.send(statement); - }, - - sendResourceExperienced: function(model) { - const config = this.attributes; - const statementModel = new ResourceItemStatementModel(config); - const statement = statementModel.getData(model); - - this.send(statement); - }, - - sendFavourite: function(model) { - const config = this.attributes; - const statementModel = new FavouriteStatementModel(config); - const statement = statementModel.getData(model); - - this.send(statement); - }, - - sendUnfavourite: function(model) { - const config = this.attributes; - const statementModel = new UnfavouriteStatementModel(config); - const statement = statementModel.getData(model); - - this.send(statement); - }, - - /* - * @todo: Add Fetch API into xAPIWrapper - https://github.com/adlnet/xAPIWrapper/issues/166 - */ - send: function(statement) { - const lrs = this.xAPIWrapper.lrs; - const url = lrs.endpoint + 'statements'; - const data = JSON.stringify(statement); - const scope = this; - - fetch(url, { - keepalive: this._terminate, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: lrs.auth, - 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion - }, - body: data - }).then(function(response) { - Adapt.log.debug('[' + statement.id + ']: ' + response.status + ' - ' + response.statusText); - - if (!response.ok) throw Error(response.statusText); - - return response; - }).catch(function(error) { - scope.showErrorNotification(); - }); - }, + if (config?._isDebugModeEnabled) { + console.log(statement); + return; + } - setModelSessionStartTime: function(model, restoredTime) { - const time = restoredTime || new Date().getTime(); + const lrs = this.xAPIWrapper.lrs; + const url = lrs.endpoint + 'statements'; + const data = JSON.stringify(statement); + const scope = this; - model.set('_sessionStartTime', time); + fetch(url, { + keepalive: this._terminate, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: lrs.auth, + 'X-Experience-API-Version': this.xAPIWrapper.xapiVersion + }, + body: data + }).then(function(response) { + Adapt.log.debug('[' + statement.id + ']: ' + response.status + ' - ' + response.statusText); - // capture start time for course session as models are reloaded on a language change - if (model.get('_type') === 'course') this._courseSessionStartTime = time; - }, + if (!response.ok) throw Error(response.statusText); - setModelDuration: function(model) { - const elapsedTime = new Date().getTime() - model.get('_sessionStartTime'); + return response; + }).catch(function(error) { + scope.showErrorNotification(); + }); + } - // reset `_sessionStartTime` to prevent cumulative additions via multiple calls to this method within the same session - mostly affects course model - this.setModelSessionStartTime(model); + setModelSessionStartTime(model, restoredTime) { + const time = restoredTime || new Date().getTime(); - model.set({ - _sessionDuration: (model.get('_sessionDuration') || 0) + elapsedTime, - _totalDuration: (model.get('_totalDuration') || 0) + elapsedTime - }); - }, + model.set('_sessionStartTime', time); - onLanguageChanged: function(lang, isStateReset) { - this._hasLanguageChanged = true; + // capture start time for course session as models are reloaded on a language change + if (model.get('_type') === 'course') this._courseSessionStartTime = time; + } - if (this._isInitialized) { - this.removeModelListeners(); + setModelDuration(model) { + const elapsedTime = new Date().getTime() - model.get('_sessionStartTime'); - if (this._currentPageModel) { - // @todo: ideally this would fire before the Adapt collections have reset - not possible in earlier frameworks but might be possible in later by `listenTo('Adapt.data', 'loading')` which fires before reset - // send experienced statement to ensure statement is sent before preferred language - this.sendExperienced(this._currentPageModel); + // reset `_sessionStartTime` to prevent cumulative additions via multiple calls to this method within the same session - mostly affects course model + this.setModelSessionStartTime(model); - // due to models reloading `_currentPageModel` is not part of Adapt.contentObjects so the stateModel is not picking up the durations change - Adapt.trigger('xapi:durationsChange', this._currentPageModel); + model.set({ + _sessionDuration: (model.get('_sessionDuration') || 0) + elapsedTime, + _totalDuration: (model.get('_totalDuration') || 0) + elapsedTime + }); + } - // reset to bypass call in `onRouterLocation` so experienced statement is not sent - this._currentPageModel = null; - } + onLanguageChanged(lang, isStateReset) { + this._hasLanguageChanged = true; - // restore course session start time - if (!isStateReset) this.setModelSessionStartTime(Adapt.course, this._courseSessionStartTime); + if (this._isInitialized) { + this.removeModelListeners(); - // send statement if language has changed since the course was started - call in `onAdaptInitialize` is only used initially to ensure correct execution order of statements - this.sendPreferredLanguage(); + if (this._currentPageModel) { + // @todo: ideally this would fire before the Adapt collections have reset - not possible in earlier frameworks but might be possible in later by `listenTo('Adapt.data', 'loading')` which fires before reset + // send experienced statement to ensure statement is sent before preferred language + this.sendExperienced(this._currentPageModel); + + // due to models reloading `_currentPageModel` is not part of Adapt.contentObjects so the stateModel is not picking up the durations change + Adapt.trigger('xapi:durationsChange', this._currentPageModel); + + // reset to bypass call in `onRouterLocation` so experienced statement is not sent + this._currentPageModel = null; } - this.set('lang', lang); + // restore course session start time + if (!isStateReset) this.setModelSessionStartTime(Adapt.course, this._courseSessionStartTime); - // reset course session start time if the state has been reset - if (isStateReset) this.setModelSessionStartTime(Adapt.course); - }, + // send statement if language has changed since the course was started - call in `onAdaptInitialize` is only used initially to ensure correct execution order of statements + this.sendPreferredLanguage(); + } - onAdaptInitialize: function() { - if (!this._isInitialized) { - this.setModelSessionStartTime(Adapt.course); + this.set('lang', lang); - this.sendInitialized(); + // reset course session start time if the state has been reset + if (isStateReset) this.setModelSessionStartTime(Adapt.course); + } - // only called on initial launch if the course contains a language picker - call in `onLanguageChanged` is used for subsequent changes within the current browser session - if (this._hasLanguageChanged) { - this.sendPreferredLanguage(); + onAdaptInitialize() { + if (!this._isInitialized) { + this.setModelSessionStartTime(Adapt.course); - this._hasLanguageChanged = false; - } + this.sendInitialized(); + + // only called on initial launch if the course contains a language picker - call in `onLanguageChanged` is used for subsequent changes within the current browser session + if (this._hasLanguageChanged) { + this.sendPreferredLanguage(); + + this._hasLanguageChanged = false; } + } - this.setupListeners(); + this.setupListeners(); - this._isInitialized = true; - }, + this._isInitialized = true; + } - onPageViewReady: function(view) { - const model = view.model; + onPageViewReady(view) { + const model = view.model; - // store model so we have a reference to existing model following a language change - this._currentPageModel = model; + // store model so we have a reference to existing model following a language change + this._currentPageModel = model; - this.setModelSessionStartTime(model); - }, + this.setModelSessionStartTime(model); + } - onRouterLocation: function() { - const previousId = Adapt.location._previousId; + onRouterLocation() { + const previousId = Adapt.location._previousId; - // bypass if no page model or no previous location - if (!this._currentPageModel || !previousId) return; + // bypass if no page model or no previous location + if (!this._currentPageModel || !previousId) return; - const model = Adapt.findById(previousId); + const model = Adapt.findById(previousId); - if (model && model.get('_type') === 'page') { - // only record experienced statements for pages - this.sendExperienced(model); - } + if (model && model.get('_type') === 'page') { + // only record experienced statements for pages + this.sendExperienced(model); + } - this._currentPageModel = null; - }, + this._currentPageModel = null; + } - onContentObjectComplete: function(model) { - // since Adapt 5.5 the course model is treated as a contentObject - ignore as this is already handled by `onTrackingComplete` - if (model.get('_type') === 'course') return; + onContentObjectComplete(model) { + // since Adapt 5.5 the course model is treated as a contentObject - ignore as this is already handled by `onTrackingComplete` + if (model.get('_type') === 'course') return; - // @todo: if page contains an assessment which can be reset but the page completes regardless of pass/fail, the `_totalDuration` will increase cumulatively for each attempt - should we reset the duration when reset? - if (model.get('_isComplete') && !model.get('_isOptional')) { - this.sendCompleted(model); - } - }, + // @todo: if page contains an assessment which can be reset but the page completes regardless of pass/fail, the `_totalDuration` will increase cumulatively for each attempt - should we reset the duration when reset? + if (model.get('_isComplete') && !model.get('_isOptional')) { + this.sendCompleted(model); + } + } - onComponentComplete: function(model) { - if (model.get('_isComplete') && model.get('_recordCompletion')) { - this.sendCompleted(model); - } - }, - - onAssessmentsComplete: function(state, model) { - // defer as triggered before last question triggers questionView:recordInteraction - _.defer(_.bind(this.sendAssessmentCompleted, this), model, state); - }, - - onAssessmentComplete: function(state) { - // create model based on Adapt.course._assessment, otherwise use Adapt.course as base - let model; - const assessmentConfig = Adapt.course.get('_assessment'); - - if (assessmentConfig && assessmentConfig._id && assessmentConfig.title) { - model = new Backbone.Model(assessmentConfig); - } else { - model = Adapt.course; - } + onComponentComplete(model) { + if (model.get('_isComplete') && model.get('_recordCompletion')) { + this.sendCompleted(model); + } + } - _.defer(_.bind(this.sendAssessmentCompleted, this), model, state); - }, + onAssessmentComplete(state) { + // create model based on Adapt.course._assessment, otherwise use Adapt.course as base + let model; + const assessmentConfig = Adapt.course.get('_assessment'); - onTrackingComplete: function(completionData) { - this.sendCompleted(Adapt.course); + if (assessmentConfig && assessmentConfig._id && assessmentConfig.title) { + model = new Backbone.Model(assessmentConfig); + } else { + model = Adapt.course; + } - // no need to use completionData.assessment due to assessment:complete listener, which isn't restricted to only firing on tracking:complete - }, + _.defer(_.bind(this.sendAssessmentCompleted, this), model, state); + } - onQuestionInteraction: function(view) { - this.sendQuestionAnswered(view.model); - }, + onAssessmentsComplete(state, model) { + // defer as triggered before last question triggers questionView:recordInteraction + _.defer(_.bind(this.sendAssessmentCompleted, this), model, state); + } - onResourceClicked: function(data) { - const model = new Backbone.Model(); + onTrackingComplete() { + this.sendCompleted(Adapt.course); - model.set({ - _id: (data.type === 'document') ? data.filename : '?link=' + data._link, - title: data.title, - description: data.description, - url: (data.type === 'document') ? data.filename : data._link - }); + // no need to use completionData.assessment due to assessment:complete listener, which isn't restricted to only firing on tracking:complete + } - this.sendResourceExperienced(model); - }, + onQuestionInteraction(view) { + this.sendQuestionAnswered(view.model); + } - onVisibilityChange: function() { - // set durations to ensure State loss is minimised for durations data, if terminate didn't fire - if (document.visibilityState === 'hidden' && !this._terminate) { - if (this._currentPageModel) this.setModelDuration(this._currentPageModel); + onResourceClicked(data) { + const model = new Backbone.Model(); - this.setModelDuration(Adapt.course); - } - }, + model.set({ + _id: (data.type === 'document') ? data.filename : '?link=' + data._link, + title: data.title, + description: data.description, + url: (data.type === 'document') ? data.filename : data._link + }); - onWindowUnload: function() { - $(window).off('beforeunload unload', this._onWindowUnload); + this.sendResourceAccessed(model); + } - if (!this._terminate) { - Adapt.terminate = this._terminate = true; + onGlossaryClicked(data) { + const model = new Backbone.Model(); - const model = Adapt.findById(Adapt.location._currentId); + model.set({ + term: data.attributes.term, + description: data.attributes.description + }); - if (model && model.get('_type') !== 'course') { - this.sendExperienced(model); - } + this.sendGlossaryViewed(model); + } - this.sendTerminated(); - } + onDrawerOpened() { + this.sendInteracted('drawer'); + } + + onPLPDrawerOpened() { + this.sendInteracted('pageLevelProgress'); + } + + onVisua11yOpened() { + this.sendInteracted('accessibility'); + } + + onVisua11yToggle(model, name, state) { + this.sendVisua11yPreference(model, name, state); + } + + onInitializeError() { + this.sendReceived('Initialization Error'); + } + + onDataError() { + this.sendReceived('Data Error'); + } + + onConnectionError() { + this.sendReceived('Connection Error'); + } + + onTerminationError() { + this.sendReceived('Termination Error'); + } + + onVisibilityChange() { + // set durations to ensure State loss is minimised for durations data, if terminate didn't fire + if (document.visibilityState === 'hidden' && !this._terminate) { + if (this._currentPageModel) this.setModelDuration(this._currentPageModel); + + this.setModelDuration(Adapt.course); } + } + + onWindowUnload() { + $(window).off('beforeunload unload', this._onWindowUnload); + + if (!this._terminate) { + Adapt.terminate = this._terminate = true; - }); + const model = Adapt.findById(Adapt.location._currentId); - return StatementModel; + if (model && model.get('_type') !== 'course') { + this.sendExperienced(model); + } + + this.sendTerminated(); + } + } +} -}); +export default StatementModel; diff --git a/js/statements/interactedStatementModel.js b/js/statements/interactedStatementModel.js new file mode 100644 index 0000000..8adda66 --- /dev/null +++ b/js/statements/interactedStatementModel.js @@ -0,0 +1,38 @@ +import AbstractStatementModel from "./abstractStatementModel"; + +class InteractedStatementModel extends AbstractStatementModel { + + initialize(attributes, options) { + this._type = null; + this._type = options._type; + + AbstractStatementModel.prototype.initialize.apply(this, arguments); + } + + getVerb(model) { + const verb = { + id: 'http://adlnet.gov/expapi/verbs/interacted', + display: {} + }; + + verb.display[super.get('recipeLang')] = 'interacted'; + + return verb; + } + + getActivityType(model) { + return ADL.activityTypes.interaction; + } + + getContextExtensions(model) { + const extensions = AbstractStatementModel.prototype.getContextExtensions.apply(this, arguments); + + _.extend(extensions, { + 'http://id.tincanapi.com/extension/condition-type': this._type + }); + + return extensions; + } +} + +export default InteractedStatementModel; diff --git a/js/statements/languageStatementModel.js b/js/statements/languageStatementModel.js new file mode 100644 index 0000000..7afda8f --- /dev/null +++ b/js/statements/languageStatementModel.js @@ -0,0 +1,26 @@ +import PreferredStatementModel from './preferredStatementModel'; + +class LanguageStatementModel extends PreferredStatementModel { + + getData(model, lang) { + const statement = PreferredStatementModel.prototype.getData.apply(this, arguments); + + statement.result = this.getResult(model, lang); + + return statement; + } + + getActivityType(model) { + return ADL.activityTypes.course; + } + + getResult(model, lang) { + const result = { + response: lang + }; + + return result; + } +} + +export default LanguageStatementModel; diff --git a/js/statements/matchingStatementModel.js b/js/statements/matchingStatementModel.js index b7b7736..3da9aa3 100644 --- a/js/statements/matchingStatementModel.js +++ b/js/statements/matchingStatementModel.js @@ -1,40 +1,9 @@ import QuestionStatementModel from './questionStatementModel'; -class MatchingStatementModel extends QuestionStatementModel { - - /* - getInteractionObject(model) { - var interactionObject = model.getInteractionObject(); - - var definition = { - source: this.getSource(interactionObject.source), - target: this.getTarget(interactionObject.target), - correctResponsesPattern: interactionObject.correctResponsesPattern - }; - - return definition; - } - - getSource(sources) { - sources.forEach(function(source) { - var description = {}; - description[this.get('lang')] = source.description; - source.description = description; - }, this); - - return sources; - } +const ITEM_DELIMETER = '[,]'; +const PAIR_DELIMETER = '[,]'; - getTarget(targets) { - targets.forEach(function(target) { - var description = {}; - description[this.get('lang')] = target.description; - target.description = description; - }, this); - - return targets; - } - */ +class MatchingStatementModel extends QuestionStatementModel { getResponse(model) { return model.getResponse().replace(/\./g, ITEM_DELIMETER).replace(/,|#/g, PAIR_DELIMETER); @@ -43,57 +12,3 @@ class MatchingStatementModel extends QuestionStatementModel { } export default MatchingStatementModel; - - -define([ - './questionStatementModel' -], function(QuestionStatementModel) { - - const ITEM_DELIMETER = '[.]'; - const PAIR_DELIMETER = '[,]'; - - const MatchingStatementModel = QuestionStatementModel.extend({ - - /* - getInteractionObject: function(model) { - var interactionObject = model.getInteractionObject(); - - var definition = { - source: this.getSource(interactionObject.source), - target: this.getTarget(interactionObject.target), - correctResponsesPattern: interactionObject.correctResponsesPattern - }; - - return definition; - }, - - getSource: function(sources) { - sources.forEach(function(source) { - var description = {}; - description[this.get('lang')] = source.description; - source.description = description; - }, this); - - return sources; - }, - - getTarget: function(targets) { - targets.forEach(function(target) { - var description = {}; - description[this.get('lang')] = target.description; - target.description = description; - }, this); - - return targets; - }, - */ - - getResponse: function(model) { - return model.getResponse().replace(/\./g, ITEM_DELIMETER).replace(/,|#/g, PAIR_DELIMETER); - } - - }); - - return MatchingStatementModel; - -}); diff --git a/js/statements/mcqStatementModel.js b/js/statements/mcqStatementModel.js index 4aebf2e..c2defa6 100644 --- a/js/statements/mcqStatementModel.js +++ b/js/statements/mcqStatementModel.js @@ -1,29 +1,8 @@ import QuestionStatementModel from './questionStatementModel'; -class McqStatementModel extends QuestionStatementModel { - - /* - getInteractionObject(model) { - var interactionObject = model.getInteractionObject(); - - var definition = { - choices: this.getChoices(interactionObject.choices), - correctResponsesPattern: interactionObject.correctResponsesPattern - }; +const DELIMETER = '[,]'; - return definition; - } - - getChoices(choices) { - choices.forEach(function(choice) { - var description = {}; - description[this.get('lang')] = choice.description; - choice.description = description; - }, this); - - return choices; - } - */ +class McqStatementModel extends QuestionStatementModel { getResponse(model) { return model.getResponse().replace(/,|#/g, DELIMETER); diff --git a/js/statements/questionStatementModel.js b/js/statements/questionStatementModel.js index 548a8b7..575b9b8 100644 --- a/js/statements/questionStatementModel.js +++ b/js/statements/questionStatementModel.js @@ -85,6 +85,16 @@ class QuestionStatementModel extends AbstractStatementModel { return contextActivities; } + getContextExtensions(model) { + const extensions = AbstractStatementModel.prototype.getObjectExtensions.apply(this, arguments); + + _.extend(extensions, { + 'http://id.tincanapi.com/extension/attempt-id': this.getAttempt(model) + }); + + return extensions; + } + getAssessmentContextActivity(model) { const assessment = model.findAncestor('articles'); const object = AbstractStatementModel.prototype.getObject.call(this, assessment); @@ -114,6 +124,10 @@ class QuestionStatementModel extends AbstractStatementModel { getResponse(model) { return model.getResponse(); } + + getAttempt(model) { + return model.get('_attempts') - model.get('_attemptsLeft'); + } } diff --git a/js/statements/receivedStatementModel.js b/js/statements/receivedStatementModel.js new file mode 100644 index 0000000..b3c3b96 --- /dev/null +++ b/js/statements/receivedStatementModel.js @@ -0,0 +1,45 @@ +import AbstractStatementModel from './abstractStatementModel'; + +class ReceivedStatementModel extends AbstractStatementModel { + + initialize(attributes, options) { + this._type = null; + this._type = options._type; + + AbstractStatementModel.prototype.initialize.apply(this, arguments); + } + + getVerb(model) { + const verb = { + id: 'http://activitystrea.ms/schema/1.0/receive', + display: {} + }; + + verb.display[this.get('recipeLang')] = 'received'; + + return verb; + } + + getActivityType(model) { + return ADL.activityTypes.course; + } + + getName(model) { + const name = {}; + name[this.get('lang')] = this._type; + + return name; + } + + getContextExtensions(model) { + const extensions = AbstractStatementModel.prototype.getContextExtensions.apply(this, arguments); + + _.extend(extensions, { + 'http://id.tincanapi.com/extension/condition-type': this._type + }); + + return extensions; + } +} + +export default ReceivedStatementModel; diff --git a/js/statements/sliderStatementModel.js b/js/statements/sliderStatementModel.js index 69d02cf..317d00b 100644 --- a/js/statements/sliderStatementModel.js +++ b/js/statements/sliderStatementModel.js @@ -1,12 +1,8 @@ import QuestionStatementModel from './questionStatementModel'; -class SliderStatementModel extends QuestionStatementModel { +const DELIMETER = '[:]'; - defaults() { - return { - DELIMETER: '[:]' - } - } +class SliderStatementModel extends QuestionStatementModel { getInteractionObject(model) { const definition = { @@ -26,7 +22,7 @@ class SliderStatementModel extends QuestionStatementModel { const top = correctRange._top || ''; return [ - bottom + this.DELIMETER + top + bottom + DELIMETER + top ]; } } diff --git a/js/statements/viewedStatementModel.js b/js/statements/viewedStatementModel.js new file mode 100644 index 0000000..ce2272e --- /dev/null +++ b/js/statements/viewedStatementModel.js @@ -0,0 +1,58 @@ +import AbstractStatementModel from './abstractStatementModel'; + +class ViewedStatementModel extends AbstractStatementModel { + + getVerb(model) { + const verb = { + id: 'http://id.tincanapi.com/verb/viewed', + display: {} + }; + + verb.display[this.get('recipeLang')] = 'viewed'; + + return verb; + } + + getActivityType(model) { + return 'http://id.tincanapi.com/activitytype/vocabulary-word'; + } + + getName(model) { + const name = {}; + name[this.get('lang')] = model.get('term'); + + return name; + } + + getDescription(model) { + const description = {}; + description[this.get('lang')] = model.get('description'); + + return description; + } + + getContextExtensions(model) { + const extensions = AbstractStatementModel.prototype.getContextExtensions.apply(this, arguments); + + _.extend(extensions, { + 'http://id.tincanapi.com/extension/tags': { + term: model.get('term'), + description: model.get('description') + } + }); + + return extensions; + } + + getUniqueIri(model) { + let iri = this.get('activityId'); + + if (model && model.get('_type') !== 'course') { + iri += '/glossary'; + } + + return iri; + } +} + +export default ViewedStatementModel; diff --git a/js/statements/visua11yStatementModel.js b/js/statements/visua11yStatementModel.js new file mode 100644 index 0000000..71e8a1c --- /dev/null +++ b/js/statements/visua11yStatementModel.js @@ -0,0 +1,47 @@ +import AbstractStatementModel from './abstractStatementModel'; +import PreferredStatementModel from './preferredStatementModel'; + +class Visua11yStatementModel extends PreferredStatementModel { + + initialize(attributes, options) { + this._type = null; + this._name = options._name; + this._state = options._state; + + AbstractStatementModel.prototype.initialize.apply(this, arguments); + } + + getActivityType(model) { + return ADL.activityTypes.interaction; + } + + getName(model) { + const name = {}; + name[this.get('lang')] = this._name; + + return name; + } + + getContextExtensions(model) { + const extensions = AbstractStatementModel.prototype.getContextExtensions.apply(this, arguments); + + _.extend(extensions, { + 'http://id.tincanapi.com/extension/condition-type': this._name, + 'http://id.tincanapi.com/extension/condition-value': this._state + }); + + return extensions; + } + + getUniqueIri(model) { + let iri = this.get('activityId'); + + if (model && model.get('_type') !== 'course') { + iri += '/visua11y'; + } + + return iri; + } +} + +export default Visua11yStatementModel; diff --git a/js/utils.js b/js/utils.js index 15a83eb..667aba2 100644 --- a/js/utils.js +++ b/js/utils.js @@ -1,66 +1,61 @@ -define(function() { - - const Utils = { - - getISO8601Duration: function(milliseconds) { - const centiseconds = Math.round(milliseconds / 10); - const hours = parseInt(centiseconds / 360000, 10); - const minutes = parseInt((centiseconds % 360000) / 6000, 10); - const seconds = ((centiseconds % 360000) % 6000) / 100; - - let durationString = 'PT'; - if (hours > 0) durationString += hours + 'H'; - if (minutes > 0) durationString += minutes + 'M'; - durationString += seconds + 'S'; - - return durationString; - }, - - getTimestamp: function() { - const date = new Date(); - const ISODate = this.getISODate(date); - const ISOTime = this.getISOTime(date); - const ISOOffset = this.getISOOffset(date); - - return ISODate + 'T' + ISOTime + ISOOffset; - }, - - getISODate: function(date) { - const year = date.getFullYear(); - const month = this.padZeros(date.getMonth() + 1); - const monthDay = this.padZeros(date.getDate()); - - return year + '-' + month + '-' + monthDay; - }, - - getISOTime: function(date) { - const hours = this.padZeros(date.getHours()); - const minutes = this.padZeros(date.getMinutes()); - const seconds = this.padZeros(date.getSeconds()); - const milliseconds = this.padZeros(date.getMilliseconds()); - - return hours + ':' + minutes + ':' + seconds + '.' + milliseconds; - }, - - getISOOffset: function(date) { - const offset = date.getTimezoneOffset(); - - if (offset === 0) return 'Z'; - - const absOffset = Math.abs(offset); - const offsetHours = this.padZeros(Math.floor(absOffset / 60)); - const offsetMinutes = this.padZeros(Math.floor(absOffset % 60)); - const offsetSign = offset > 0 ? '-' : '+'; - - return offsetSign + offsetHours + ':' + offsetMinutes; - }, - - padZeros: function(num) { - return num < 10 ? '0' + num : num.toString(); - } - - }; - - return Utils; - -}); +const Utils = { + + getISO8601Duration(milliseconds) { + const centiseconds = Math.round(milliseconds / 10); + const hours = parseInt(centiseconds / 360000, 10); + const minutes = parseInt((centiseconds % 360000) / 6000, 10); + const seconds = ((centiseconds % 360000) % 6000) / 100; + + let durationString = 'PT'; + if (hours > 0) durationString += hours + 'H'; + if (minutes > 0) durationString += minutes + 'M'; + durationString += seconds + 'S'; + + return durationString; + }, + + getTimestamp() { + const date = new Date(); + const ISODate = this.getISODate(date); + const ISOTime = this.getISOTime(date); + const ISOOffset = this.getISOOffset(date); + + return ISODate + 'T' + ISOTime + ISOOffset; + }, + + getISODate(date) { + const year = date.getFullYear(); + const month = this.padZeros(date.getMonth() + 1); + const monthDay = this.padZeros(date.getDate()); + + return `${year}-${month}-${monthDay}`; + }, + + getISOTime(date) { + const hours = this.padZeros(date.getHours()); + const minutes = this.padZeros(date.getMinutes()); + const seconds = this.padZeros(date.getSeconds()); + const milliseconds = this.padZeros(date.getMilliseconds()); + + return `${hours}:${minutes}:${seconds}.${milliseconds}`; + }, + + getISOOffset(date) { + const offset = date.getTimezoneOffset(); + + if (offset === 0) return 'Z'; + + const absOffset = Math.abs(offset); + const offsetHours = this.padZeros(Math.floor(absOffset / 60)); + const offsetMinutes = this.padZeros(Math.floor(absOffset % 60)); + const offsetSign = offset > 0 ? '-' : '+'; + + return `${offsetSign}${offsetHours}:${offsetMinutes}`; + }, + + padZeros(num) { + return num < 10 ? '0' + num : num.toString(); + } +} + +export default Utils; diff --git a/properties.schema b/properties.schema index ffecf5d..5977dca 100644 --- a/properties.schema +++ b/properties.schema @@ -25,6 +25,24 @@ "validators": [], "help": "If enabled, the course will be tracked by xAPI." }, + "_isRestoreEnabled": { + "type": "boolean", + "required": true, + "default": false, + "title": "Restore Progress from LRS", + "inputType": "Checkbox", + "validators": [], + "help": "If enabled, the course will use xAPI data to restore course progress." + }, + "_isDebugModeEnabled": { + "type": "boolean", + "required": true, + "default": false, + "title": "Developer Mode", + "inputType": "Checkbox", + "validators": [], + "help": "If enabled, the course will continue to generate statements for testing but it will instead post the statement to the console instead of the LRS." + }, "_activityId": { "type": "string", "required": true, @@ -83,6 +101,33 @@ "inputType": "Checkbox", "validators": [], "help": "If enabled, the course will send a statement when the assessment have been completed. If using multiple assessments, the statement will be sent when all assessments have been completed." + }, + "_navbar": { + "type": "boolean", + "required": false, + "default": true, + "title": "Record Navigational Bar Button Selections", + "inputType": "Checkbox", + "validators": [], + "help": "If enabled, the course will send a statement whenever a selection has been made with 'pageLevelProgress', 'drawer', 'help' or 'visua11y'." + }, + "_trackingErrors": { + "type": "boolean", + "required": false, + "default": false, + "title": "Record SCORM tracking errors", + "inputType": "Checkbox", + "validators": [], + "help": "If enabled, the course will send a statement whenever a connection issue occurs with the spoor plug-in." + }, + "_visua11y": { + "type": "boolean", + "required": false, + "default": false, + "title": "Record preference selections within visua11y", + "inputType": "Checkbox", + "validators": [], + "help": "If enabled, the course will send a statement whenever a preference has been selected within the visua11y options." } } }, diff --git a/required/launch.html b/required/launch.html index b30b585..a5d05bc 100644 --- a/required/launch.html +++ b/required/launch.html @@ -9,15 +9,15 @@ window.location = 'index.html?endpoint=' + getLRSEndpoint() + '&actor=' + getActor() + '&auth=' + getAuth() + '&activity_id=' + getActivityId() + '®istration=' + getRegistration(); function getLRSEndpoint() { - var endpoint = "https://saas.learninglocker.net/data/xAPI/"; + var endpoint = "https://kineo.learninglocker.net/data/xAPI"; return encodeURIComponent(endpoint); } function getActor() { var actor = { - mbox: "mailto:username@domain.com", - name: "Test User" + mbox: "mailto:joe.replin@kineo.com", + name: "Joe Replin" }; return JSON.stringify(actor); @@ -25,7 +25,7 @@ function getAuth() { // "Basic " + toBase64('username:password'); - var auth = ""; + var auth = "Basic ZDNlNzY5MWVjMzNjYzAzYWU0MmQxNDE0MDY1NzE4ZGUxZGY4Njk3ODo0ODQyMmMyZDA4M2JkZTNjYTc1MzhlNWZkMjQ1ZGQyNGM1MWE5Y2M3"; return auth; } @@ -46,4 +46,4 @@ - \ No newline at end of file + diff --git a/schema/config.schema.json b/schema/config.schema.json index ab9a608..c2d8844 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -19,6 +19,18 @@ "description": "If enabled, the course will be tracked by xAPI.", "default": true }, + "_isRestoreEnabled": { + "type": "boolean", + "title": "Restore Progress from LRS", + "description": "If enabled, the course will use xAPI data to restore course progress.", + "default": false + }, + "_isDebugModeEnabled": { + "type": "boolean", + "title": "Developer Mode", + "description": "If enabled, the course will continue to generate statements for testing but it will instead post the statement to the console instead of the LRS.", + "default": false + }, "_activityId": { "type": "string", "title": "Activity ID", @@ -59,6 +71,24 @@ "title": "Record assessment completion", "description": "If enabled, the course will send a statement when the assessment have been completed. If using multiple assessments, the statement will be sent when all assessments have been completed.", "default": true + }, + "_navbar": { + "type": "boolean", + "title": "Record Navigational Bar Button Selections", + "description": "If enabled, the course will send a statement whenever a selection has been made with 'pageLevelProgress', 'drawer', 'help' or 'visua11y'.", + "default": true + }, + "_trackingErrors": { + "type": "boolean", + "title": "Record SCORM tracking errors", + "description": "If enabled, the course will send a statement whenever a connection issue occurs with the spoor plug-in.", + "default": true + }, + "_visua11y": { + "type": "boolean", + "title": "Record preference selections within visua11y", + "description": "If enabled, the course will send a statement whenever a preference has been selected within the visua11y options.", + "default": false } } },