From 87a4534e534f7091455b122726229027fc2452bb Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 31 Mar 2020 17:03:14 +0100 Subject: [PATCH 01/57] issue/2709 Converted some classes to ES6 for future works --- src/core/js/adapt.js | 198 ++++++++-------- src/core/js/collections/adaptCollection.js | 27 ++- src/core/js/data.js | 112 ++++----- src/core/js/models/adaptModel.js | 6 +- src/core/js/models/buildModel.js | 26 ++- src/core/js/models/configModel.js | 55 ++--- src/core/js/models/courseModel.js | 17 +- src/core/js/models/itemModel.js | 32 ++- src/core/js/models/itemsComponentModel.js | 72 +++--- src/core/js/models/itemsQuestionModel.js | 151 ++++++------ src/core/js/models/menuModel.js | 9 + src/core/js/models/questionModel.js | 154 +++++++------ src/core/js/models/routerModel.js | 24 +- src/core/js/router.js | 53 +++-- src/core/js/scrolling.js | 87 +++---- src/core/js/views/articleView.js | 8 +- src/core/js/views/blockView.js | 8 +- src/core/js/views/componentView.js | 36 +-- src/core/js/views/contentObjectView.js | 126 ++++++++++ src/core/js/views/menuItemView.js | 22 +- src/core/js/views/menuView.js | 68 +----- src/core/js/views/navigationView.js | 8 +- src/core/js/views/pageView.js | 67 +----- src/core/js/views/questionView.js | 255 +++++++++++---------- 24 files changed, 832 insertions(+), 789 deletions(-) create mode 100644 src/core/js/views/contentObjectView.js diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index b5400fa47..8c0f0439a 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -3,30 +3,37 @@ define([ 'core/js/models/lockingModel' ], function(Wait) { - var Adapt = Backbone.Model.extend({ + class Adapt extends Backbone.Model { - loadScript: window.__loadScript, - location: {}, - componentStore: {}, + initialize() { + this.loadScript = window.__loadScript; + this.location = {}; + this.componentStore = {}; + this.setupWait(); + } - defaults: { - _canScroll: true, // to stop scrollTo behaviour, - _outstandingCompletionChecks: 0, - _pluginWaitCount: 0, - _isStarted: false - }, + defaults() { + return { + _canScroll: true, // to stop scrollTo behaviour, + _outstandingCompletionChecks: 0, + _pluginWaitCount: 0, + _isStarted: false + }; + } - lockedAttributes: { - _canScroll: false - }, + lockedAttributes() { + return { + _canScroll: false + }; + } - init: function() { + init() { this.addDirection(); this.disableAnimation(); this.trigger('adapt:preInitialize'); // wait until no more completion checking - this.deferUntilCompletionChecked(function() { + this.deferUntilCompletionChecked(() => { // start adapt in a full restored state this.trigger('adapt:start'); @@ -39,78 +46,68 @@ define([ this.trigger('adapt:initialize'); - }.bind(this)); - }, - - initialize: function () { - this.setupWait(); - }, + }); + } /** * call when entering an asynchronous completion check */ - checkingCompletion: function() { - var outstandingChecks = this.get('_outstandingCompletionChecks'); - this.set('_outstandingCompletionChecks', ++outstandingChecks); - }, + checkingCompletion() { + const outstandingChecks = this.get('_outstandingCompletionChecks'); + this.set('_outstandingCompletionChecks', outstandingChecks + 1); + } /** * call when exiting an asynchronous completion check */ - checkedCompletion: function() { - var outstandingChecks = this.get('_outstandingCompletionChecks'); - this.set('_outstandingCompletionChecks', --outstandingChecks); - }, + checkedCompletion() { + const outstandingChecks = this.get('_outstandingCompletionChecks'); + this.set('_outstandingCompletionChecks', outstandingChecks - 1); + } /** * wait until there are no outstanding completion checks * @param {Function} callback Function to be called after all completion checks have been completed */ - deferUntilCompletionChecked: function(callback) { + deferUntilCompletionChecked(callback) { if (this.get('_outstandingCompletionChecks') === 0) return callback(); - var checkIfAnyChecksOutstanding = function(model, outstandingChecks) { + const checkIfAnyChecksOutstanding = (model, outstandingChecks) => { if (outstandingChecks !== 0) return; - this.off('change:_outstandingCompletionChecks', checkIfAnyChecksOutstanding); - callback(); }; this.on('change:_outstandingCompletionChecks', checkIfAnyChecksOutstanding); - }, + } - setupWait: function() { + setupWait() { this.wait = new Wait(); // Setup legacy events and handlers - var beginWait = function () { - this.log.warn("DEPRECATED - Use Adapt.wait.begin() as Adapt.trigger('plugin:beginWait') may be removed in the future"); + const beginWait = () => { + this.log.deprecated("Use Adapt.wait.begin() as Adapt.trigger('plugin:beginWait') may be removed in the future"); this.wait.begin(); - }.bind(this); + }; - var endWait = function() { - this.log.warn("DEPRECATED - Use Adapt.wait.end() as Adapt.trigger('plugin:endWait') may be removed in the future"); + const endWait = () => { + this.log.deprecated("Use Adapt.wait.end() as Adapt.trigger('plugin:endWait') may be removed in the future"); this.wait.end(); - }.bind(this); - - var ready = function() { + }; + const ready = () => { if (this.wait.isWaiting()) { return; } - - var isEventListening = (this._events['plugins:ready']); + const isEventListening = (this._events['plugins:ready']); if (!isEventListening) { return; } - - this.log.warn("DEPRECATED - Use Adapt.wait.queue(callback) as Adapt.on('plugins:ready', callback) may be removed in the future"); + this.log.deprecated("Use Adapt.wait.queue(callback) as Adapt.on('plugins:ready', callback) may be removed in the future"); this.trigger('plugins:ready'); - - }.bind(this); + }; this.listenTo(this.wait, 'ready', ready); this.listenTo(this, { @@ -118,20 +115,20 @@ define([ 'plugin:endWait': endWait }); - }, + } - isWaitingForPlugins: function() { - this.log.warn('DEPRECATED - Use Adapt.wait.isWaiting() as Adapt.isWaitingForPlugins() may be removed in the future'); + isWaitingForPlugins() { + this.log.deprecated('Use Adapt.wait.isWaiting() as Adapt.isWaitingForPlugins() may be removed in the future'); return this.wait.isWaiting(); - }, + } - checkPluginsReady: function() { - this.log.warn('DEPRECATED - Use Adapt.wait.isWaiting() as Adapt.checkPluginsReady() may be removed in the future'); + checkPluginsReady() { + this.log.deprecated('Use Adapt.wait.isWaiting() as Adapt.checkPluginsReady() may be removed in the future'); if (this.isWaitingForPlugins()) { return; } this.trigger('plugins:ready'); - }, + } /** * Allows a selector to be passed in and Adapt will navigate to this element @@ -139,14 +136,12 @@ define([ * @param {object} [settings] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). * You may also include a `replace` property that you can set to `true` if you want to update the URL without creating an entry in the browser's history. */ - navigateToElement: function(selector, settings) { - settings = (settings || {}); - + navigateToElement(selector, settings = {}) { // Removes . symbol from the selector to find the model - var currentModelId = selector.replace(/\./g, ''); - var currentModel = this.data.findById(currentModelId); + const currentModelId = selector.replace(/\./g, ''); + const currentModel = this.data.findById(currentModelId); // Get current page to check whether this is the current page - var currentPage = (currentModel._siblings === 'contentObjects') ? currentModel : currentModel.findAncestor('contentObjects'); + const currentPage = (currentModel._siblings === 'contentObjects') ? currentModel : currentModel.findAncestor('contentObjects'); // If current page - scrollTo element if (currentPage.get('_id') === this.location._currentId) { @@ -155,23 +150,23 @@ define([ // If the element is on another page navigate and wait until pageView:ready is fired // Then scrollTo element - this.once('pageView:ready', _.debounce(function() { + this.once('pageView:ready', _.debounce(() => { this.router.set('_shouldNavigateFocus', true); this.scrollTo(selector, settings); - }.bind(this), 1)); + }, 1)); - var shouldReplaceRoute = settings.replace || false; + const shouldReplaceRoute = settings.replace || false; this.router.set('_shouldNavigateFocus', false); Backbone.history.navigate('#/id/' + currentPage.get('_id'), { trigger: true, replace: shouldReplaceRoute }); - }, + } /** * Used to register components with the Adapt 'component store' * @param {string} name The name of the component to be registered * @param {object} object Object containing properties `model` and `view` or (legacy) an object representing the view */ - register: function(name, object) { + register(name, object) { if (this.componentStore[name]) { throw Error('The component "' + name + '" already exists in your project'); } @@ -187,20 +182,20 @@ define([ this.componentStore[name] = object; return object; - }, + } /** * Fetches a component view class from the componentStore. For a usage example, see either HotGraphic or Narrative * @param {string} name The name of the componentView you want to fetch e.g. `"hotgraphic"` * @returns {ComponentView} Reference to the view class */ - getViewClass: function(name) { - var object = this.componentStore[name]; + getViewClass(name) { + const object = this.componentStore[name]; if (!object) { throw Error('The component "' + name + '" doesn\'t exist in your project'); } return object.view || object; - }, + } /** * Looks up which collection a model belongs to @@ -212,43 +207,43 @@ define([ * - "articles" * - "components" */ - mapById: function(id) { + mapById(id) { return this.data.mapById(id); - }, + } /** * Looks up a model by its `_id` property * @param {string} id The id of the item e.g. "co-05" * @return {Backbone.Model} */ - findById: function(id) { + findById(id) { return this.data.findById(id); - }, + } - findViewByModelId: function(id) { - var model = this.data.findById(id); + findViewByModelId(id) { + const model = this.data.findById(id); if (!model) return; if (model === this.parentView.model) return this.parentView; - var idPathToView = [id]; - var currentLocationId = this.location._currentId; - var currentLocationModel = _.find(model.getAncestorModels(), function(model) { - var modelId = model.get('_id'); + const idPathToView = [id]; + const currentLocationId = this.location._currentId; + const currentLocationModel = model.getAncestorModels().find(model => { + const modelId = model.get('_id'); if (modelId === currentLocationId) return true; idPathToView.unshift(modelId); }); if (!currentLocationModel) { - return console.warn('Adapt.findViewByModelId() unable to find view for model id: ' + id); + return console.warn(`Adapt.findViewByModelId() unable to find view for model id: ${id}`); } - var foundView = _.reduce(idPathToView, function(view, currentId) { + const foundView = idPathToView.reduce((view, currentId) => { return view && view.childViews && view.childViews[currentId]; }, this.parentView); return foundView; - }, + } /** * Relative strings describe the number and type of hops in the model hierarchy @@ -260,19 +255,19 @@ define([ * } * Trickle uses this function to determine where it should scrollTo after it unlocks */ - parseRelativeString: function(relativeString) { + parseRelativeString(relativeString) { if (relativeString[0] === '@') { relativeString = relativeString.substr(1); } - var type = relativeString.match(/(component|block|article|page|menu)/); + let type = relativeString.match(/(component|block|article|page|menu)/); if (!type) { this.log.error('Adapt.parseRelativeString() could not match relative type', relativeString); return; } type = type[0]; - var offset = parseInt(relativeString.substr(type.length).trim() || 0); + const offset = parseInt(relativeString.substr(type.length).trim() || 0); if (isNaN(offset)) { this.log.error('Adapt.parseRelativeString() could not parse relative offset', relativeString); return; @@ -283,43 +278,42 @@ define([ offset: offset }; - }, + } - addDirection: function() { - var defaultDirection = this.config.get('_defaultDirection'); + addDirection() { + const defaultDirection = this.config.get('_defaultDirection'); $('html') .addClass('dir-' + defaultDirection) .attr('dir', defaultDirection); - }, + } - disableAnimation: function() { - var disableAnimationArray = this.config.get('_disableAnimationFor'); - var disableAnimation = this.config.get('_disableAnimation'); + disableAnimation() { + const disableAnimationArray = this.config.get('_disableAnimationFor'); + const disableAnimation = this.config.get('_disableAnimation'); // Check if animations should be disabled if (disableAnimationArray && disableAnimationArray.length > 0) { - for (var i = 0; i < disableAnimationArray.length; i++) { + for (let i = 0; i < disableAnimationArray.length; i++) { if ($('html').is(disableAnimationArray[i])) { this.config.set('_disableAnimation', true); $('html').addClass('disable-animation'); console.log('Animation disabled.'); } } - } else if (disableAnimation === true) { - $('html').addClass('disable-animation'); - } else { - $('html').removeClass('disable-animation'); + return; } - }, - remove: function() { + $('html').toggleClass('disable-animation', (disableAnimation === true)); + } + + remove() { this.trigger('preRemove'); this.trigger('remove'); _.defer(this.trigger.bind(this), 'postRemove'); } - }); + } return new Adapt(); }); diff --git a/src/core/js/collections/adaptCollection.js b/src/core/js/collections/adaptCollection.js index b4501833b..95430ec0e 100644 --- a/src/core/js/collections/adaptCollection.js +++ b/src/core/js/collections/adaptCollection.js @@ -2,26 +2,25 @@ define([ 'core/js/adapt' ], function(Adapt) { - var AdaptCollection = Backbone.Collection.extend({ - initialize: function(models, options) { - this.url = options.url; + class AdaptCollection extends Backbone.Collection { + initialize(models, options) { + this.url = options.url; this.once('reset', this.loadedData, this); - if (this.url) { - this.fetch({ - reset: true, - error: _.bind(function(model, xhr, options) { - console.error('ERROR: unable to load file ' + this.url); - }, this) - }); - } - }, + if (!this.url) return; + this.fetch({ + reset: true, + error: () => { + console.error('ERROR: unable to load file ' + this.url); + } + }); + } - loadedData: function() { + loadedData() { Adapt.trigger('adaptCollection:dataLoaded'); } - }); + } return AdaptCollection; diff --git a/src/core/js/data.js b/src/core/js/data.js index 4aa0cd3c8..818ce110f 100644 --- a/src/core/js/data.js +++ b/src/core/js/data.js @@ -14,67 +14,69 @@ define([ 'core/js/startController' ], function(Adapt, AdaptCollection, ArticleModel, BlockModel, ConfigModel, MenuModel, PageModel, ComponentModel, CourseModel, QuestionModel) { - var Data = Backbone.Controller.extend({ + class Data extends Backbone.Controller { - mappedIds: {}, + initialize() { + this.mappedIds = {}; + } - init: function () { + init () { Adapt.build.whenReady().then(this.onBuildDataLoaded.bind(this)); - }, + } - onBuildDataLoaded: function() { + onBuildDataLoaded() { $('html').attr('data-adapt-framework-version', Adapt.build.get('package').version); Adapt.config = new ConfigModel(null, { url: 'course/config.' + Adapt.build.get('jsonext'), reset: true }); - Adapt.config.on({ - 'change:_activeLanguage': this.onLanguageChange.bind(this), - 'change:_defaultDirection': this.onDirectionChange.bind(this) + this.listenTo(Adapt.config, { + 'change:_activeLanguage': this.onLanguageChange, + 'change:_defaultDirection': this.onDirectionChange }); // Events that are triggered by the main Adapt content collections and models - Adapt.once('configModel:loadCourseData', this.onLoadCourseData.bind(this)); - }, + this.listenToOnce(Adapt, 'configModel:loadCourseData', this.onLoadCourseData); + } - onLanguageChange: function(model, language) { + onLanguageChange(model, language) { Adapt.offlineStorage.set('lang', language); this.loadCourseData(this.triggerDataReady.bind(this), language); - }, + } - onDirectionChange: function(model, direction) { + onDirectionChange(model, direction) { if (direction === 'rtl') { $('html').removeClass('dir-ltr').addClass('dir-rtl').attr('dir', 'rtl'); } else { $('html').removeClass('dir-rtl').addClass('dir-ltr').attr('dir', 'ltr'); } - }, + } /** * Before we actually go to load the course data, we first need to check to see if a language has been set * If it has we can go ahead and start loading; if it hasn't, apply the defaultLanguage from config.json */ - onLoadCourseData: function() { + onLoadCourseData() { if (Adapt.config.get('_activeLanguage')) { this.loadCourseData(this.triggerDataReady.bind(this)); } else { Adapt.config.set('_activeLanguage', Adapt.config.get('_defaultLanguage')); } - }, + } - loadCourseData: function(callback, newLanguage) { - Adapt.on('adaptCollection:dataLoaded courseModel:dataLoaded', function() { + loadCourseData(callback, newLanguage) { + this.listenTo(Adapt, 'adaptCollection:dataLoaded courseModel:dataLoaded', () => { this.checkDataIsLoaded(callback, newLanguage); - }.bind(this)); + }); // All code that needs to run before adapt starts should go here - var language = Adapt.config.get('_activeLanguage'); - var jsonext = Adapt.build.get('jsonext'); - var courseFolder = 'course/' + language + '/'; + const language = Adapt.config.get('_activeLanguage'); + const jsonext = Adapt.build.get('jsonext'); + const courseFolder = 'course/' + language + '/'; $('html').attr('lang', language); Adapt.course = new CourseModel(null, { url: courseFolder + 'course.' + jsonext, reset: true }); Adapt.contentObjects = new AdaptCollection(null, { - model: function(json) { + model: json => { switch (json._type) { case 'page': return new PageModel(json); @@ -96,10 +98,10 @@ define([ }); Adapt.components = new AdaptCollection(null, { - model: function(json) { + model: json => { // use view+model object - var ViewModelObject = Adapt.componentStore[json._component]; + const ViewModelObject = Adapt.componentStore[json._component]; if (!ViewModelObject) { throw new Error('One or more components of type "' + json._component + '" were included in the course - but no component of that type is installed...'); @@ -111,7 +113,7 @@ define([ return new ViewModelObject.model(json); } - var View = ViewModelObject.view || ViewModelObject; + const View = ViewModelObject.view || ViewModelObject; // if question type use question model if (View._isQuestionType) { return new QuestionModel(json); @@ -122,9 +124,9 @@ define([ }, url: courseFolder + 'components.' + jsonext }); - }, + } - checkDataIsLoaded: function(callback, newLanguage) { + checkDataIsLoaded(callback, newLanguage) { if (Adapt.contentObjects.models.length > 0 && Adapt.articles.models.length > 0 && Adapt.blocks.models.length > 0 && @@ -143,44 +145,44 @@ define([ this.setupMapping(); - Adapt.wait.queue(function() { + Adapt.wait.queue(() => { callback(newLanguage); }); } - }, + } - mapAdaptIdsToObjects: function () { + mapAdaptIdsToObjects() { Adapt.contentObjects._byAdaptID = Adapt.contentObjects.groupBy('_id'); Adapt.articles._byAdaptID = Adapt.articles.groupBy('_id'); Adapt.blocks._byAdaptID = Adapt.blocks.groupBy('_id'); Adapt.components._byAdaptID = Adapt.components.groupBy('_id'); - }, + } - setupMapping: function() { + setupMapping() { this.mappedIds = {}; // Setup course Id this.mappedIds[Adapt.course.get('_id')] = 'course'; - var collections = ['contentObjects', 'articles', 'blocks', 'components']; + const collections = ['contentObjects', 'articles', 'blocks', 'components']; - collections.forEach(function(collection) { - Adapt[collection].models.forEach(function(model) { - var id = model.get('_id'); + collections.forEach(collection => { + Adapt[collection].models.forEach(model => { + const id = model.get('_id'); this.mappedIds[id] = collection; - }.bind(this)); - }.bind(this)); - }, + }); + }); + } - triggerDataReady: function(newLanguage) { + triggerDataReady(newLanguage) { if (newLanguage) { Adapt.trigger('app:languageChanged', newLanguage); - _.defer(function() { + _.defer(() => { Adapt.startController.loadCourseData(); - var hash = '#/'; + let hash = '#/'; if (Adapt.startController.isEnabled()) { hash = Adapt.startController.getStartHash(true); @@ -200,20 +202,20 @@ define([ Adapt.wait.queue(this.triggerInit.bind(this)); - }, + } - triggerInit: function() { + triggerInit() { this.isReady = true; this.trigger('ready'); - }, + } - whenReady: function() { + whenReady() { if (this.isReady) return Promise.resolve(); - return new Promise(function (resolve) { + return new Promise(resolve => { this.once('ready', resolve); - }.bind(this)); - }, + }); + } /** * Looks up which collection a model belongs to @@ -225,21 +227,21 @@ define([ * - "articles" * - "components" */ - mapById: function(id) { + mapById(id) { return this.mappedIds[id]; - }, + } /** * Looks up a model by its `_id` property * @param {string} id The id of the item e.g. "co-05" * @return {Backbone.Model} */ - findById: function(id) { + findById(id) { if (id === Adapt.course.get('_id')) { return Adapt.course; } - var collectionType = Adapt.mapById(id); + const collectionType = Adapt.mapById(id); if (!collectionType) { console.warn('Adapt.findById() unable to find collection type for id: ' + id); @@ -249,7 +251,7 @@ define([ return Adapt[collectionType]._byAdaptID[id][0]; } - }); + } return (Adapt.data = new Data()); diff --git a/src/core/js/models/adaptModel.js b/src/core/js/models/adaptModel.js index 16fb36f22..b598db73b 100644 --- a/src/core/js/models/adaptModel.js +++ b/src/core/js/models/adaptModel.js @@ -574,7 +574,7 @@ define([ * @deprecated since v3.2.3 - please use `model.set('_isOptional', value)` instead */ setOptional(value) { - Adapt.log.warn(`DEPRECATED - Use model.set('_isOptional', value) as setOptional() may be removed in the future`); + Adapt.log.deprecated(`Use model.set('_isOptional', value) as setOptional() may be removed in the future`); this.set({ _isOptional: value }); } @@ -635,10 +635,6 @@ define([ const children = this.getAvailableChildModels(); children.forEach(child => { child.set('_isLocked', this.shouldLock(child)); - - if (child.get('_type') === 'menu') { - child.checkLocking(); - } }); } diff --git a/src/core/js/models/buildModel.js b/src/core/js/models/buildModel.js index ddf17d8cf..142186fa6 100644 --- a/src/core/js/models/buildModel.js +++ b/src/core/js/models/buildModel.js @@ -3,37 +3,39 @@ define([ 'core/js/logging' ], function (Adapt) { - var BuildModel = Backbone.Model.extend({ + class BuildModel extends Backbone.Model { - defaults: { - jsonext: 'json' - }, + defaults() { + return { + jsonext: 'json' + }; + } - initialize: function(attrs, options) { + initialize(attrs, options) { this.url = options.url; // Fetch data & if successful trigger event to enable plugins to stop course files loading // Then check if course files can load // 'configModel:loadCourseData' event starts the core content collections and models being fetched this.fetch({ - success: _.bind(function() { + success: () => { this.isLoaded = true; Adapt.trigger('buildModel:dataLoaded'); - }, this), - error: function() { + }, + error: () => { console.log('Unable to load adapt/js/build.js'); Adapt.trigger('buildModel:dataLoaded'); } }); - }, + } - whenReady: function() { + whenReady() { if (this.isLoaded) return Promise.resolve(); - return new Promise(function (resolve) { + return new Promise(resolve => { Adapt.once('buildModel:dataLoaded', resolve); }); } - }); + } return (Adapt.build = new BuildModel(null, { url: 'adapt/js/build.min.js', reset: true })); diff --git a/src/core/js/models/configModel.js b/src/core/js/models/configModel.js index 28dd43fd8..7263f8b50 100644 --- a/src/core/js/models/configModel.js +++ b/src/core/js/models/configModel.js @@ -2,49 +2,44 @@ define([ 'core/js/adapt' ], function (Adapt) { - var ConfigModel = Backbone.Model.extend({ - - defaults: { - screenSize: { - large: 900, - medium: 760, - small: 520 - }, - _forceRouteLocking: false, - _canLoadData: true, - _disableAnimation: false - }, + class ConfigModel extends Backbone.Model { + + defaults() { + return { + screenSize: { + large: 900, + medium: 760, + small: 520 + }, + _forceRouteLocking: false, + _canLoadData: true, + _disableAnimation: false + }; + } - initialize: function(attrs, options) { + initialize(attrs, options) { this.url = options.url; // Fetch data & if successful trigger event to enable plugins to stop course files loading // Then check if course files can load // 'configModel:loadCourseData' event starts the core content collections and models being fetched this.fetch({ - success: function() { + success: () => { Adapt.trigger('offlineStorage:prepare'); - - Adapt.wait.queue(function() { - + Adapt.wait.queue(() => { Adapt.trigger('configModel:dataLoaded'); - - if (this.get('_canLoadData')) { - Adapt.trigger('configModel:loadCourseData'); - } - - }.bind(this)); - }.bind(this), - error: function() { + if (!this.get('_canLoadData')) return; + Adapt.trigger('configModel:loadCourseData'); + }); + }, + error: () => { console.log('Unable to load course/config.json'); } }); - }, - - loadData: function() { - } - }); + loadData() {} + + } return ConfigModel; diff --git a/src/core/js/models/courseModel.js b/src/core/js/models/courseModel.js index 7ce7930ff..adba20605 100644 --- a/src/core/js/models/courseModel.js +++ b/src/core/js/models/courseModel.js @@ -16,24 +16,21 @@ define([ initialize(attrs, options) { super.initialize(arguments); Adapt.trigger('courseModel:dataLoading'); - this.url = options.url; - this.on('sync', this.loadedData, this); - if (this.url) { - this.fetch({ - error: (model, xhr, options) => { - console.error('ERROR: unable to load file ' + this.url); - } - }); - } + if (!this.url) return; + this.fetch({ + error: () => { + console.error(`ERROR: unable to load file ${this.url}`); + } + }); } loadedData() { Adapt.trigger('courseModel:dataLoaded'); } - }; + } return CourseModel; diff --git a/src/core/js/models/itemModel.js b/src/core/js/models/itemModel.js index c748cdb6c..4afc4fa04 100644 --- a/src/core/js/models/itemModel.js +++ b/src/core/js/models/itemModel.js @@ -1,33 +1,27 @@ define(function() { - var ItemModel = Backbone.Model.extend({ + class ItemModel extends Backbone.Model { - defaults: { - _isActive: false, - _isVisited: false - }, + defaults() { + return { + _isActive: false, + _isVisited: false + }; + } - reset: function() { + reset() { this.set({ _isActive: false, _isVisited: false }); - }, - - toggleActive: function(isActive) { - if (isActive === undefined) { - isActive = !this.get('_isActive'); - } + } + toggleActive(isActive = !this.get('_isActive')) { this.set('_isActive', isActive); - }, - - toggleVisited: function(isVisited) { - if (isVisited === undefined) { - isVisited = !this.get('_isVisited'); - } + } + toggleVisited(isVisited = !this.get('_isVisited')) { this.set('_isVisited', isVisited); } - }); + } return ItemModel; diff --git a/src/core/js/models/itemsComponentModel.js b/src/core/js/models/itemsComponentModel.js index 1bdcc6c4d..3739a0802 100644 --- a/src/core/js/models/itemsComponentModel.js +++ b/src/core/js/models/itemsComponentModel.js @@ -3,76 +3,72 @@ define([ 'core/js/models/itemModel' ], function(ComponentModel, ItemModel) { - var ItemsComponentModel = ComponentModel.extend({ + class ItemsComponentModel extends ComponentModel { - toJSON: function() { - var json = _.clone(this.attributes); + toJSON() { + const json = _.clone(this.attributes); json._items = this.get('_children').toJSON(); - return json; - }, + } - init: function() { + init() { this.setUpItems(); - this.listenTo(this.get('_children'), { 'all': this.onAll, 'change:_isVisited': this.checkCompletionStatus }); - }, + } - setUpItems: function() { - var items = this.get('_items') || []; // see https://github.com/adaptlearning/adapt_framework/issues/2480 + setUpItems() { + // see https://github.com/adaptlearning/adapt_framework/issues/2480 + const items = this.get('_items') || []; items.forEach(function(item, index) { item._index = index; }); - this.set('_children', new Backbone.Collection(items, { model: ItemModel })); - }, + } - getItem: function(index) { + getItem(index) { return this.get('_children').findWhere({ _index: index }); - }, + } - getVisitedItems: function() { + getVisitedItems() { return this.get('_children').where({ _isVisited: true }); - }, + } - getActiveItems: function() { + getActiveItems() { return this.get('_children').where({ _isActive: true }); - }, + } - getActiveItem: function() { + getActiveItem() { return this.get('_children').findWhere({ _isActive: true }); - }, + } - areAllItemsCompleted: function() { + areAllItemsCompleted() { return this.getVisitedItems().length === this.get('_children').length; - }, - - checkCompletionStatus: function() { - if (this.areAllItemsCompleted()) { - this.setCompletionStatus(); - } - }, + } - reset: function(type, force) { - this.get('_children').each(function(item) { item.reset(); }); + checkCompletionStatus() { + if (!this.areAllItemsCompleted()) return; + this.setCompletionStatus(); + } - ComponentModel.prototype.reset.call(this, type, force); - }, + reset(type, force) { + this.get('_children').each(item => item.reset()); + super.reset(type, force); + } - resetActiveItems: function() { - this.get('_children').each(function(item) { item.toggleActive(false); }); - }, + resetActiveItems() { + this.get('_children').each(item => item.toggleActive(false)); + } - setActiveItem: function(index) { - var activeItem = this.getActiveItem(); + setActiveItem(index) { + const activeItem = this.getActiveItem(); if (activeItem) activeItem.toggleActive(false); this.getItem(index).toggleActive(true); } - }); + } return ItemsComponentModel; diff --git a/src/core/js/models/itemsQuestionModel.js b/src/core/js/models/itemsQuestionModel.js index 1919f5326..34ae90444 100644 --- a/src/core/js/models/itemsQuestionModel.js +++ b/src/core/js/models/itemsQuestionModel.js @@ -4,25 +4,35 @@ define([ 'core/js/models/itemsComponentModel' ], function(Adapt, QuestionModel, ItemsComponentModel) { - var ItemsComponentModelFunctions = _.extendOwn({}, ItemsComponentModel.prototype); - delete ItemsComponentModelFunctions.constructor; - var BlendedModel = QuestionModel.extend(ItemsComponentModelFunctions); + class BlendedItemsComponentQuestionModel extends QuestionModel { - var ItemsQuestionModel = BlendedModel.extend({ - - init: function() { - QuestionModel.prototype.init.call(this); + init() { + super.init(); ItemsComponentModel.prototype.init.call(this); + } + + } + // extend BlendedItemsComponentQuestionModel with ItemsComponentModel + Object.getOwnPropertyNames(ItemsComponentModel.prototype).forEach(name => { + if (name === 'constructor') return; + Object.defineProperty(BlendedItemsComponentQuestionModel.prototype, name, { + value: ItemsComponentModel.prototype[name] + }); + }); + + class ItemsQuestionModel extends BlendedItemsComponentQuestionModel { + init() { + super.init(); this.set('_isRadio', this.isSingleSelect()); - }, + } - restoreUserAnswers: function() { + restoreUserAnswers() { if (!this.get('_isSubmitted')) return; - var itemModels = this.getChildren(); - var userAnswer = this.get('_userAnswer'); - itemModels.each(function(item, index) { + const itemModels = this.getChildren(); + const userAnswer = this.get('_userAnswer'); + itemModels.each(item => { item.toggleActive(userAnswer[item.get('_index')]); }); @@ -30,45 +40,45 @@ define([ this.markQuestion(); this.setScore(); this.setupFeedback(); - }, + } - setupRandomisation: function() { + setupRandomisation() { if (!this.get('_isRandom') || !this.get('_isEnabled')) return; - var children = this.getChildren(); + const children = this.getChildren(); children.set(children.shuffle()); - }, + } // check if the user is allowed to submit the question - canSubmit: function() { - var activeItems = this.getActiveItems(); + canSubmit() { + const activeItems = this.getActiveItems(); return activeItems.length > 0; - }, + } // This is important for returning or showing the users answer // This should preserve the state of the users answers - storeUserAnswer: function() { - var items = this.getChildren().slice(0); - items.sort(function(a, b) { + storeUserAnswer() { + const items = this.getChildren().slice(0); + items.sort((a, b) => { return a.get('_index') - b.get('_index'); }); - var userAnswer = items.map(function(itemModel) { + const userAnswer = items.map(itemModel => { return itemModel.get('_isActive'); }); this.set('_userAnswer', userAnswer); - }, + } - isCorrect: function() { + isCorrect() { - var props = { + const props = { _numberOfRequiredAnswers: 0, _numberOfIncorrectAnswers: 0, _isAtLeastOneCorrectSelection: false, _numberOfCorrectAnswers: 0 }; - this.getChildren().each(function(itemModel) { - var itemShouldBeActive = itemModel.get('_shouldBeSelected'); + this.getChildren().each(itemModel => { + const itemShouldBeActive = itemModel.get('_shouldBeSelected'); if (itemShouldBeActive) { props._numberOfRequiredAnswers++; } @@ -87,22 +97,22 @@ define([ this.set(props); - var hasRightNumberOfCorrectAnswers = (props._numberOfCorrectAnswers === props._numberOfRequiredAnswers); - var hasNoIncorrectAnswers = !props._numberOfIncorrectAnswers; + const hasRightNumberOfCorrectAnswers = (props._numberOfCorrectAnswers === props._numberOfRequiredAnswers); + const hasNoIncorrectAnswers = !props._numberOfIncorrectAnswers; return hasRightNumberOfCorrectAnswers && hasNoIncorrectAnswers; - }, + } // Sets the score based upon the questionWeight // Can be overwritten if the question needs to set the score in a different way - setScore: function() { - var questionWeight = this.get('_questionWeight'); - var answeredCorrectly = this.get('_isCorrect'); - var score = answeredCorrectly ? questionWeight : 0; + setScore() { + const questionWeight = this.get('_questionWeight'); + const answeredCorrectly = this.get('_isCorrect'); + const score = answeredCorrectly ? questionWeight : 0; this.set('_score', score); - }, + } - setupFeedback: function() { + setupFeedback() { if (!this.has('_feedback')) return; if (this.get('_isCorrect')) { @@ -116,103 +126,102 @@ define([ } // apply individual item feedback - var activeItem = this.getActiveItem(); + const activeItem = this.getActiveItem(); if (this.isSingleSelect() && activeItem.get('feedback')) { this.setupIndividualFeedback(activeItem); return; } this.setupIncorrectFeedback(); - }, + } - setupIndividualFeedback: function(selectedItem) { + setupIndividualFeedback(selectedItem) { this.set({ feedbackTitle: this.getFeedbackTitle(this.get('_feedback')), feedbackMessage: selectedItem.get('feedback') }); - }, + } - isPartlyCorrect: function() { + isPartlyCorrect() { return this.get('_isAtLeastOneCorrectSelection'); - }, + } - resetUserAnswer: function() { + resetUserAnswer() { this.set('_userAnswer', []); - }, + } - isAtActiveLimit: function() { - var selectedItems = this.getActiveItems(); + isAtActiveLimit() { + const selectedItems = this.getActiveItems(); return (selectedItems.length === this.get('_selectable')); - }, + } - isSingleSelect: function() { + isSingleSelect() { return (this.get('_selectable') === 1); - }, + } - getLastActiveItem: function() { - var selectedItems = this.getActiveItems(); + getLastActiveItem() { + const selectedItems = this.getActiveItems(); return selectedItems[selectedItems.length - 1]; - }, + } - resetItems: function() { + resetItems() { this.resetActiveItems(); this.set('_isAtLeastOneCorrectSelection', false); - }, + } - reset: function(type, force) { + reset(type, force) { QuestionModel.prototype.reset.apply(this, arguments); ItemsComponentModel.prototype.reset.apply(this, arguments); - }, + } - getInteractionObject: function() { - var interactions = { + getInteractionObject() { + const interactions = { correctResponsesPattern: [], choices: [] }; - interactions.choices = this.getChildren().map(function(itemModel) { + interactions.choices = this.getChildren().map(itemModel => { return { id: (itemModel.get('_index') + 1).toString(), description: itemModel.get('text') }; }); - var correctItems = this.getChildren().filter(function(itemModel) { + const correctItems = this.getChildren().filter(itemModel => { return itemModel.get('_shouldBeSelected'); }); interactions.correctResponsesPattern = [ - correctItems.map(function(itemModel) { + correctItems.map(itemModel => { // indexes are 0-based, we need them to be 1-based for cmi.interactions return String(itemModel.get('_index') + 1); - }) - .join('[,]') + }).join('[,]') ]; return interactions; - }, + } /** * used by adapt-contrib-spoor to get the user's answers in the format required by the cmi.interactions.n.student_response data field * returns the user's answers as a string in the format '1,5,2' */ - getResponse: function() { - var activeItems = this.getActiveItems(); - var activeIndexes = activeItems.map(function(itemModel) { + getResponse() { + const activeItems = this.getActiveItems(); + const activeIndexes = activeItems.map(itemModel => { // indexes are 0-based, we need them to be 1-based for cmi.interactions return itemModel.get('_index') + 1; }); return activeIndexes.join(','); - }, + } /** * used by adapt-contrib-spoor to get the type of this question in the format required by the cmi.interactions.n.type data field */ - getResponseType: function() { + getResponseType() { return 'choice'; } - }); + } return ItemsQuestionModel; diff --git a/src/core/js/models/menuModel.js b/src/core/js/models/menuModel.js index 0d81319b2..756b960de 100644 --- a/src/core/js/models/menuModel.js +++ b/src/core/js/models/menuModel.js @@ -8,6 +8,15 @@ define([ return 'contentObjects'; } + setCustomLocking() { + const children = this.getAvailableChildModels(); + children.forEach(child => { + child.set('_isLocked', this.shouldLock(child)); + if (!(child instanceof MenuModel)) return; + child.checkLocking(); + }); + } + } return MenuModel; diff --git a/src/core/js/models/questionModel.js b/src/core/js/models/questionModel.js index 32d50abd0..dcfd4a285 100644 --- a/src/core/js/models/questionModel.js +++ b/src/core/js/models/questionModel.js @@ -4,14 +4,14 @@ define([ 'core/js/enums/buttonStateEnum' ], function(Adapt, ComponentModel, BUTTON_STATE) { - var QuestionModel = ComponentModel.extend({ + class QuestionModel extends ComponentModel { /// /// // Setup question types /// / // Used to set model defaults - defaults: function() { + defaults() { // Extend from the ComponentModel defaults return ComponentModel.resultExtend('defaults', { _isQuestionType: true, @@ -23,31 +23,33 @@ define([ _questionWeight: Adapt.config.get('_questionWeight'), _items: [] }); - }, + } // Extend from the ComponentModel trackable - trackable: ComponentModel.resultExtend('trackable', [ - '_isSubmitted', - '_score', - '_isCorrect', - '_attemptsLeft' - ]), - - init: function() { + trackable() { + return ComponentModel.resultExtend('trackable', [ + '_isSubmitted', + '_score', + '_isCorrect', + '_attemptsLeft' + ]); + } + + init() { this.setupDefaultSettings(); this.listenToOnce(Adapt, 'adapt:initialize', this.onAdaptInitialize); - }, + } // Calls default methods to setup on questions - setupDefaultSettings: function() { + setupDefaultSettings() { // Not sure this is needed anymore, keeping to maintain API this.setupWeightSettings(); this.setupButtonSettings(); - }, + } // Used to setup either global or local button text - setupButtonSettings: function() { - var globalButtons = Adapt.course.get('_buttons'); + setupButtonSettings() { + const globalButtons = Adapt.course.get('_buttons'); // Check if '_buttons' attribute exists and if not use the globally defined buttons. if (!this.has('_buttons')) { @@ -55,9 +57,9 @@ define([ } else { // Check all the components buttons. // If they are empty use the global defaults. - var componentButtons = this.get('_buttons'); + const componentButtons = this.get('_buttons'); - for (var key in componentButtons) { + for (let key in componentButtons) { if (typeof componentButtons[key] === 'object') { // Button text. if (!componentButtons[key].buttonText && globalButtons[key].buttonText) { @@ -75,24 +77,24 @@ define([ } } } - }, + } // Used to setup either global or local question weight/score - setupWeightSettings: function() { + setupWeightSettings() { // Not needed as handled by model defaults, keeping to maintain API - }, + } /// /// // Selection restoration process /// / // Used to add post-load changes to the model - onAdaptInitialize: function() { + onAdaptInitialize() { this.restoreUserAnswers(); - }, + } // Used to restore the user answers - restoreUserAnswers: function() {}, + restoreUserAnswers() {} /// /// // Submit process @@ -100,30 +102,30 @@ define([ // Use to check if the user is allowed to submit the question // Maybe the user has to select an item? - canSubmit: function() {}, + canSubmit() {} // Used to update the amount of attempts the user has left - updateAttempts: function() { + updateAttempts() { if (!this.get('_attemptsLeft')) { this.set('_attemptsLeft', this.get('_attempts')); } this.set('_attemptsLeft', this.get('_attemptsLeft') - 1); - }, + } // Used to set _isEnabled and _isSubmitted on the model - setQuestionAsSubmitted: function() { + setQuestionAsSubmitted() { this.set({ _isEnabled: false, _isSubmitted: true }); - }, + } // This is important for returning or showing the users answer // This should preserve the state of the users answers - storeUserAnswer: function() {}, + storeUserAnswer() {} // Sets _isCorrect:true/false based upon isCorrect method below - markQuestion: function() { + markQuestion() { if (this.isCorrect()) { this.set('_isCorrect', true); @@ -131,19 +133,19 @@ define([ this.set('_isCorrect', false); } - }, + } // Should return a boolean based upon whether to question is correct or not - isCorrect: function() {}, + isCorrect() {} // Used to set the score based upon the _questionWeight - setScore: function() {}, + setScore() {} // Checks if the question should be set to complete // Calls setCompletionStatus and adds complete classes - checkQuestionCompletion: function() { + checkQuestionCompletion() { - var isComplete = (this.get('_isCorrect') || this.get('_attemptsLeft') === 0); + const isComplete = (this.get('_isCorrect') || this.get('_attemptsLeft') === 0); if (isComplete) { this.setCompletionStatus(); @@ -151,17 +153,17 @@ define([ return isComplete; - }, + } // Updates buttons based upon question state by setting // _buttonState on the model which buttonsView listens to - updateButtons: function() { + updateButtons() { - var isInteractionComplete = this.get('_isInteractionComplete'); - var isCorrect = this.get('_isCorrect'); - var isEnabled = this.get('_isEnabled'); - var buttonState = this.get('_buttonState'); - var canShowModelAnswer = this.get('_canShowModelAnswer'); + const isInteractionComplete = this.get('_isInteractionComplete'); + const isCorrect = this.get('_isCorrect'); + const isEnabled = this.get('_isEnabled'); + const buttonState = this.get('_buttonState'); + const canShowModelAnswer = this.get('_canShowModelAnswer'); if (isInteractionComplete) { @@ -191,10 +193,10 @@ define([ } } - }, + } // Used to setup the correct, incorrect and partly correct feedback - setupFeedback: function() { + setupFeedback() { if (!this.has('_feedback')) return; if (this.get('_isCorrect')) { @@ -204,87 +206,87 @@ define([ } else { this.setupIncorrectFeedback(); } - }, + } // Used by the question to determine if the question is incorrect or partly correct // Should return a boolean - isPartlyCorrect: function() {}, + isPartlyCorrect() {} - setupCorrectFeedback: function() { + setupCorrectFeedback() { this.set({ feedbackTitle: this.getFeedbackTitle(), feedbackMessage: this.get('_feedback').correct }); - }, + } - setupPartlyCorrectFeedback: function() { - var feedback = this.get('_feedback')._partlyCorrect; + setupPartlyCorrectFeedback() { + const feedback = this.get('_feedback')._partlyCorrect; if (feedback && feedback.final) { this.setAttemptSpecificFeedback(feedback); } else { this.setupIncorrectFeedback(); } - }, + } - setupIncorrectFeedback: function() { + setupIncorrectFeedback() { this.setAttemptSpecificFeedback(this.get('_feedback')._incorrect); - }, + } - setAttemptSpecificFeedback: function(feedback) { - var body = (this.get('_attemptsLeft') && feedback.notFinal) || feedback.final; + setAttemptSpecificFeedback(feedback) { + const body = (this.get('_attemptsLeft') && feedback.notFinal) || feedback.final; this.set({ feedbackTitle: this.getFeedbackTitle(), feedbackMessage: body }); - }, + } - getFeedbackTitle: function() { + getFeedbackTitle() { return this.get('_feedback').title || this.get('displayTitle') || this.get('title') || ''; - }, + } /** * Used to determine whether the learner is allowed to interact with the question component or not. * @return {Boolean} */ - isInteractive: function() { + isInteractive() { return !this.get('_isComplete') || (this.get('_isEnabled') && !this.get('_isSubmitted')); - }, + } // Reset the model to let the user have another go (not the same as attempts) - reset: function(type, force) { + reset(type, force) { if (!this.get('_canReset') && !force) return; type = type || true; // hard reset by default, can be "soft", "hard"/true ComponentModel.prototype.reset.call(this, type, force); - var attempts = this.get('_attempts'); + const attempts = this.get('_attempts'); this.set({ _attemptsLeft: attempts, _isCorrect: undefined, _isSubmitted: false, _buttonState: BUTTON_STATE.SUBMIT }); - }, + } // Reset question for subsequent attempts - setQuestionAsReset: function() { + setQuestionAsReset() { this.set({ _isEnabled: true, _isSubmitted: false }); - }, + } // Used by the question view to reset the stored user answer - resetUserAnswer: function() {}, + resetUserAnswer() {} - refresh: function() { + refresh() { this.trigger('question:refresh'); - }, + } - getButtonState: function() { + getButtonState() { if (this.get('_isCorrect')) { return BUTTON_STATE.CORRECT; } @@ -294,23 +296,23 @@ define([ } return this.get('_isSubmitted') ? BUTTON_STATE.RESET : BUTTON_STATE.SUBMIT; - }, + } // Returns an object specific to the question type, e.g. if the question // is a 'choice' this should contain an object with: // - correctResponsesPattern[] // - choices[] - getInteractionObject: function() { + getInteractionObject() { return {}; - }, + } // Returns a string detailing how the user answered the question. - getResponse: function() {}, + getResponse() {} // Returns a string describing the type of interaction: "choice" and "matching" supported (see scorm wrapper) - getResponseType: function() {} + getResponseType() {} - }); + } return QuestionModel; diff --git a/src/core/js/models/routerModel.js b/src/core/js/models/routerModel.js index cdaf3f61c..60e9d2f5a 100644 --- a/src/core/js/models/routerModel.js +++ b/src/core/js/models/routerModel.js @@ -1,20 +1,24 @@ define([ 'core/js/adapt' -], function (Adapt) { +], function(Adapt) { - var RouterModel = Backbone.Model.extend({ + class RouterModel extends Backbone.Model { - defaults: { - _canNavigate: true, - _shouldNavigateFocus: true - }, + defaults() { + return { + _canNavigate: true, + _shouldNavigateFocus: true + }; + } - lockedAttributes: { - _canNavigate: false, - _shouldNavigateFocus: false + lockedAttributes() { + return { + _canNavigate: false, + _shouldNavigateFocus: false + }; } - }); + } return RouterModel; diff --git a/src/core/js/router.js b/src/core/js/router.js index 598404ab5..0e766fab2 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -7,6 +7,13 @@ define([ class Router extends Backbone.Router { + routes() { + return { + '': 'handleRoute', + 'id/:id': 'handleRoute', + ':pluginName(/*location)(/*action)': 'handleRoute' + }; + } initialize({ model }) { this.model = model; @@ -26,14 +33,6 @@ define([ this.listenTo(Adapt, 'router:navigateTo', this.navigateToArguments); } - routes() { - return { - '': 'handleRoute', - 'id/:id': 'handleRoute', - ':pluginName(/*location)(/*action)': 'handleRoute' - }; - } - pruneArguments(args) { if (args.length !== 0) { // Remove any null arguments. @@ -93,7 +92,7 @@ define([ } handlePluginRouter(pluginName, location, action) { - var pluginLocation = pluginName; + let pluginLocation = pluginName; if (location) { pluginLocation = pluginLocation + '-' + location; @@ -114,7 +113,7 @@ define([ handleCourse() { if (Adapt.course.has('_start')) { // Do not allow access to the menu when the start controller is enabled. - var startController = Adapt.course.get('_start'); + const startController = Adapt.course.get('_start'); if (startController._isEnabled === true && startController._isMenuDisabled === true) { return; @@ -141,8 +140,8 @@ define([ } handleId(id) { - var currentModel = Adapt.findById(id); - var type = ''; + const currentModel = Adapt.findById(id); + let type = ''; if (!currentModel) { this.model.set('_canNavigate', true, { pluginName: 'adapt' }); @@ -165,7 +164,7 @@ define([ } else { this.showLoading(); this.removeViews(() => { - var location; + let location; this.setContentObjectToVisited(currentModel); if (type === 'page') { @@ -213,7 +212,7 @@ define([ navigateToArguments(args) { args = this.pruneArguments(args); - var options = { trigger: false, replace: false }; + const options = { trigger: false, replace: false }; switch (args.length) { case 0: @@ -271,8 +270,8 @@ define([ if (!Adapt.location._currentId) { return; } - var currentId = Adapt.location._currentId; - var route = (currentId === Adapt.course.get('_id')) ? '#/' : '#/id/' + currentId; + const currentId = Adapt.location._currentId; + const route = (currentId === Adapt.course.get('_id')) ? '#/' : '#/id/' + currentId; this.navigate(route, { trigger: true, replace: true }); } @@ -280,8 +279,8 @@ define([ if (!this.model.get('_canNavigate') && !force) { return; } - var parentId = Adapt.contentObjects.findWhere({ _id: Adapt.location._currentId }).get('_parentId'); - var route = (parentId === Adapt.course.get('_id')) ? '#/' : '#/id/' + parentId; + const parentId = Adapt.contentObjects.findWhere({ _id: Adapt.location._currentId }).get('_parentId'); + const route = (parentId === Adapt.course.get('_id')) ? '#/' : '#/id/' + parentId; this.navigate(route, { trigger: true }); } @@ -316,16 +315,16 @@ define([ Adapt.location._currentLocation = currentLocation; - var locationModel = Adapt.findById(id) || Adapt.course; - var htmlClasses = (locationModel && locationModel.get('_htmlClasses')) || ''; + const locationModel = Adapt.findById(id) || Adapt.course; + const htmlClasses = (locationModel && locationModel.get('_htmlClasses')) || ''; - var classes = (Adapt.location._currentId) ? 'location-' + + const classes = (Adapt.location._currentId) ? 'location-' + Adapt.location._contentType + ' location-id-' + Adapt.location._currentId : 'location-' + Adapt.location._currentLocation; - var previousClasses = Adapt.location._previousClasses; + const previousClasses = Adapt.location._previousClasses; if (previousClasses) { this.$html.removeClass(previousClasses); } @@ -353,19 +352,19 @@ define([ setDocumentTitle() { if (!Adapt.location._currentId) return; - var currentModel = Adapt.findById(Adapt.location._currentId); - var pageTitle = ''; + const currentModel = Adapt.findById(Adapt.location._currentId); + let pageTitle = ''; if (currentModel && currentModel.get('_type') !== 'course') { - var currentTitle = currentModel.get('title'); + const currentTitle = currentModel.get('title'); if (currentTitle) { pageTitle = ' | ' + currentTitle; } } - var courseTitle = Adapt.course.get('title'); - var documentTitle = $('
' + courseTitle + pageTitle + '
').text(); + const courseTitle = Adapt.course.get('title'); + const documentTitle = $('
' + courseTitle + pageTitle + '
').text(); this.listenToOnce(Adapt, 'pageView:ready menuView:ready', () => { document.title = documentTitle; diff --git a/src/core/js/scrolling.js b/src/core/js/scrolling.js index a42646bbb..9033ba01c 100644 --- a/src/core/js/scrolling.js +++ b/src/core/js/scrolling.js @@ -2,18 +2,17 @@ define([ 'core/js/adapt' ], function(Adapt) { - var Scrolling = Backbone.Controller.extend({ + class Scrolling extends Backbone.Controller { - $html: null, - $app: null, - isLegacyScrolling: true, - - initialize: function() { + initialize() { + this.$html = null; + this.$app = null; + this.isLegacyScrolling = true; this._checkApp(); Adapt.once('configModel:dataLoaded', this._loadConfig.bind(this)); - }, + } - _checkApp: function() { + _checkApp() { this.$html = $('html'); this.$app = $('#app'); if (this.$app.length) return; @@ -21,61 +20,61 @@ define([ $('body').append(this.$app); this.$app.append($('#wrapper')); Adapt.log.warn('UPDATE - Your html file needs to have #app adding. See https://github.com/adaptlearning/adapt_framework/issues/2168'); - }, + } - _loadConfig: function() { - var config = Adapt.config.get('_scrollingContainer'); + _loadConfig() { + const config = Adapt.config.get('_scrollingContainer'); if (!config || !config._isEnabled) return; - var limitTo = config._limitToSelector; - var isIncluded = !limitTo || (this.$html.is(limitTo) || this.$html.hasClass(limitTo)); + const limitTo = config._limitToSelector; + const isIncluded = !limitTo || (this.$html.is(limitTo) || this.$html.hasClass(limitTo)); if (!isIncluded) return; this.isLegacyScrolling = false; this._addStyling(); this._fixJQuery(); this._fixScrollTo(); this._fixBrowser(); - }, + } - _addStyling: function() { + _addStyling() { this.$html.addClass('adapt-scrolling'); - }, + } - _fixJQuery: function() { - var selectorScrollTop = $.fn.scrollTop; - var $app = Adapt.scrolling.$app; + _fixJQuery() { + const selectorScrollTop = $.fn.scrollTop; + const $app = Adapt.scrolling.$app; $.fn.scrollTop = function() { if (this[0] === window || this[0] === document.body) { return selectorScrollTop.apply($app, arguments); } return selectorScrollTop.apply(this, arguments); }; - var selectorOffset = $.fn.offset; + const selectorOffset = $.fn.offset; $.fn.offset = function(coordinates) { if (coordinates) { return selectorOffset.apply(this, arguments); } - var $app = Adapt.scrolling.$app; - var $element = this; - var elementOffset = selectorOffset.call($element); - var isCorrectedContainer = $element.parents().add($element).filter('html,body,#app').length; + const $app = Adapt.scrolling.$app; + const $element = this; + const elementOffset = selectorOffset.call($element); + const isCorrectedContainer = $element.parents().add($element).filter('html,body,#app').length; if (!isCorrectedContainer) { // Do not adjust the offset measurement as not in $app container and isn't html or body return elementOffset; } // Adjust measurement by scrolling and offset of $app container - var scrollTop = parseInt($app.scrollTop()); - var scrollLeft = parseInt($app.scrollLeft()); - var appOffset = selectorOffset.call($app); + const scrollTop = parseInt($app.scrollTop()); + const scrollLeft = parseInt($app.scrollLeft()); + const appOffset = selectorOffset.call($app); elementOffset.top += (scrollTop - appOffset.top); elementOffset.left += (scrollLeft - appOffset.left); return elementOffset; }; - }, + } - _fixScrollTo: function() { - var selectorScrollTo = $.fn.scrollTo; - var scrollTo = $.scrollTo; - var $app = Adapt.scrolling.$app; + _fixScrollTo() { + const selectorScrollTo = $.fn.scrollTo; + const scrollTo = $.scrollTo; + const $app = Adapt.scrolling.$app; $.fn.scrollTo = function(target, duration, settings) { if (this[0] === window || this[0] === document.body) { return selectorScrollTo.apply($app, arguments); @@ -85,22 +84,22 @@ define([ $.scrollTo = function(target, duration, settings) { return selectorScrollTo.apply($app, arguments); }; - _.extend($.scrollTo, scrollTo); - }, + Object.assign($.scrollTo, scrollTo); + } - _fixBrowser: function() { - var app = Adapt.scrolling.$app[0]; + _fixBrowser() { + const app = Adapt.scrolling.$app[0]; window.scrollTo = function(x, y) { app.scrollTop = y || 0; app.scrollLeft = x || 0; }; - var $window = $(window); - this.$app.on('scroll', function() { + const $window = $(window); + this.$app.on('scroll', () => { $window.scroll(); }); } - }); + } Adapt.scrolling = new Scrolling(); @@ -109,19 +108,19 @@ define([ settings = {}; } // Get the current location - this is set in the router - var location = (Adapt.location._contentType) ? + const location = (Adapt.location._contentType) ? Adapt.location._contentType : Adapt.location._currentLocation; // Trigger initial scrollTo event Adapt.trigger(location + ':scrollTo', selector); // Setup duration variable passed upon argumentsß - var disableScrollToAnimation = Adapt.config.has('_disableAnimation') ? Adapt.config.get('_disableAnimation') : false; + const disableScrollToAnimation = Adapt.config.has('_disableAnimation') ? Adapt.config.get('_disableAnimation') : false; if (disableScrollToAnimation) { settings.duration = 0; } else if (!settings.duration) { settings.duration = $.scrollTo.defaults.duration; } - var offsetTop = 0; + let offsetTop = 0; if (Adapt.scrolling.isLegacyScrolling) { offsetTop = -$('.nav').outerHeight(); // prevent scroll issue when component description aria-label coincident with top of component @@ -143,10 +142,12 @@ define([ // Trigger an event after animation // 300 milliseconds added to make sure queue has finished - _.delay(function() { + _.delay(() => { Adapt.a11y.focusNext(selector); Adapt.trigger(location + ':scrolledTo', selector); }, settings.duration + 300); }; + return Adapt.scrolling; + }); diff --git a/src/core/js/views/articleView.js b/src/core/js/views/articleView.js index 6a7c3ef3f..25b176d5f 100644 --- a/src/core/js/views/articleView.js +++ b/src/core/js/views/articleView.js @@ -3,9 +3,9 @@ define([ 'core/js/views/blockView' ], function(AdaptView, BlockView) { - var ArticleView = AdaptView.extend({ + class ArticleView extends AdaptView { - className: function() { + className() { return [ 'article', this.model.get('_id'), @@ -17,7 +17,9 @@ define([ ].join(' '); } - }, { + } + + Object.assign(ArticleView, { childContainer: '.block__container', childView: BlockView, type: 'article', diff --git a/src/core/js/views/blockView.js b/src/core/js/views/blockView.js index 6d5d1f284..0c367f9b4 100644 --- a/src/core/js/views/blockView.js +++ b/src/core/js/views/blockView.js @@ -2,9 +2,9 @@ define([ 'core/js/views/adaptView' ], function(AdaptView) { - var BlockView = AdaptView.extend({ + class BlockView extends AdaptView { - className: function() { + className() { return [ 'block', this.model.get('_id'), @@ -16,7 +16,9 @@ define([ ].join(' '); } - }, { + } + + Object.assign(BlockView, { childContainer: '.component__container', type: 'block', template: 'block' diff --git a/src/core/js/views/componentView.js b/src/core/js/views/componentView.js index ad3d2d069..dfc98af0a 100644 --- a/src/core/js/views/componentView.js +++ b/src/core/js/views/componentView.js @@ -3,9 +3,9 @@ define([ 'core/js/views/adaptView' ], function(Adapt, AdaptView) { - var ComponentView = AdaptView.extend({ + class ComponentView extends AdaptView { - attributes: function() { + attributes() { if (!this.model.get('_isA11yRegionEnabled')) { return AdaptView.resultExtend('attributes', {}, this); } @@ -13,9 +13,9 @@ define([ 'aria-labelledby': this.model.get('_id') + '-heading', 'role': 'region' }, this); - }, + } - className: function() { + className() { return [ 'component', this.model.get('_component').toLowerCase(), @@ -27,11 +27,11 @@ define([ (this.model.get('_isComplete') ? 'is-complete' : ''), (this.model.get('_isOptional') ? 'is-optional' : '') ].join(' '); - }, + } - renderState: function() { + renderState() { Adapt.log.warn('REMOVED - renderState is removed and moved to item title'); - }, + } /** * Allows components that want to use inview for completion to set that up @@ -41,20 +41,20 @@ define([ * you want to perform additional checks before setting the component to completed - see adapt-contrib-assessmentResults * for an example. Defaults to `view.setCompletionStatus` if not specified. */ - setupInviewCompletion: function(inviewElementSelector, callback) { + setupInviewCompletion(inviewElementSelector, callback) { this.$inviewElement = this.$(inviewElementSelector || '.component__inner'); this.inviewCallback = (callback || this.setCompletionStatus); this.$inviewElement.on('inview.componentView', this.onInview.bind(this)); - }, + } - removeInviewListener: function() { + removeInviewListener() { if (!this.$inviewElement) return; this.$inviewElement.off('inview.componentView'); this.$inviewElement = null; - }, + } - onInview: function(event, visible, visiblePartX, visiblePartY) { + onInview(event, visible, visiblePartX, visiblePartY) { if (!visible) return; switch (visiblePartY) { @@ -75,19 +75,19 @@ define([ if (this.model.get('_isComplete')) { this.removeInviewListener(); } - }, + } - postRender: function() {}, + postRender() {} - remove: function() { + remove() { this.removeInviewListener(); AdaptView.prototype.remove.call(this); } - }, { - type: 'component' - }); + } + + ComponentView.type = 'component'; return ComponentView; diff --git a/src/core/js/views/contentObjectView.js b/src/core/js/views/contentObjectView.js new file mode 100644 index 000000000..094b6ef7e --- /dev/null +++ b/src/core/js/views/contentObjectView.js @@ -0,0 +1,126 @@ +define([ + 'core/js/adapt', + 'core/js/views/adaptView' +], function(Adapt, AdaptView) { + + class ContentObjectView extends AdaptView { + + attributes() { + return AdaptView.resultExtend('attributes', { + 'role': 'main', + 'aria-labelledby': `${this.model.get('_id')}-heading` + }, this); + } + + className() { + return _.filter([ + this.constructor.type, + 'contentobject', + this.constructor.className, + this.model.get('_id'), + this.model.get('_classes'), + this.setVisibility(), + (this.model.get('_isComplete') ? 'is-complete' : ''), + (this.model.get('_isOptional') ? 'is-optional' : '') + ], Boolean).join(' '); + } + + preRender() { + $.inview.lock(this.constructor.type + 'View'); + this.disableAnimation = Adapt.config.has('_disableAnimation') ? Adapt.config.get('_disableAnimation') : false; + this.$el.css('opacity', 0); + this.listenTo(this.model, 'change:_isReady', this.isReady); + } + + render() { + const type = this.constructor.type; + Adapt.trigger(`${type}View:preRender`, this); + + const data = this.model.toJSON(); + data.view = this; + const template = Handlebars.templates[this.constructor.template]; + this.$el.html(template(data)); + + Adapt.trigger(`${type}View:render`, this); + + _.defer(() => { + // don't call postRender after remove + if (this._isRemoved) return; + + this.postRender(); + Adapt.trigger(`${type}View:postRender`, this); + }); + + return this; + } + + isReady() { + if (!this.model.get('_isReady')) return; + + const performIsReady = () => { + $('.js-loading').hide(); + $(window).scrollTop(0); + const type = this.constructor.type; + Adapt.trigger(`${type}View:ready`, this); + $.inview.unlock(`${type}View`); + const styleOptions = { opacity: 1 }; + if (this.disableAnimation) { + this.$el.css(styleOptions); + $.inview(); + } else { + this.$el.velocity(styleOptions, { + duration: 'fast', + complete: () => { + $.inview(); + } + }); + } + $(window).scroll(); + }; + + Adapt.wait.queue(() => { + _.defer(performIsReady); + }); + } + + preRemove() { + Adapt.trigger(`${this.constructor.type}View:preRemove`, this); + } + + remove() { + const type = this.constructor.type; + this.preRemove(); + Adapt.trigger(`${type}View:remove`, this); + this._isRemoved = true; + + Adapt.wait.for(end => { + + this.$el.off('onscreen.adaptView'); + this.model.setOnChildren('_isReady', false); + this.model.set('_isReady', false); + super.remove(); + + Adapt.trigger(`${type}View:postRemove`, this); + + end(); + }); + + return this; + } + + destroy() { + this.findDescendantViews().reverse().forEach(view => { + view.remove(); + }); + this.childViews = []; + this.remove(); + if (Adapt.parentView === this) { + Adapt.parentView = null; + } + } + + } + + return ContentObjectView; + +}); diff --git a/src/core/js/views/menuItemView.js b/src/core/js/views/menuItemView.js index 1ac240030..767ef6676 100644 --- a/src/core/js/views/menuItemView.js +++ b/src/core/js/views/menuItemView.js @@ -2,16 +2,16 @@ define([ 'core/js/views/adaptView' ], function(AdaptView) { - var MenuItemView = AdaptView.extend({ + class MenuItemView extends AdaptView { - attributes: function() { + attributes() { return AdaptView.resultExtend('attributes', { 'role': 'listitem', 'aria-labelledby': this.model.get('_id') + '-heading' }, this); - }, + } - className: function() { + className() { return [ 'menu-item', this.constructor.className, @@ -24,20 +24,20 @@ define([ (this.model.get('_isLocked') ? 'is-locked' : ''), (this.model.get('_isOptional') ? 'is-optional' : '') ].join(' '); - }, + } - preRender: function() { + preRender() { this.model.checkCompletionStatus(); this.model.checkInteractionCompletionStatus(); - }, + } - postRender: function() { + postRender() { this.$el.imageready(this.setReadyStatus.bind(this)); } - }, { - type: 'menuItem' - }); + } + + MenuItemView.type = 'menuItem'; return MenuItemView; diff --git a/src/core/js/views/menuView.js b/src/core/js/views/menuView.js index 849f6d7c0..010ffe3b4 100644 --- a/src/core/js/views/menuView.js +++ b/src/core/js/views/menuView.js @@ -1,66 +1,16 @@ define([ - 'core/js/adapt', - 'core/js/views/adaptView', + 'core/js/views/contentObjectView', 'core/js/views/menuItemView' -], function(Adapt, AdaptView, MenuItemView) { +], function(ContentObjectView, MenuItemView) { - var MenuView = AdaptView.extend({ + class MenuView extends ContentObjectView {} - attributes: function() { - return AdaptView.resultExtend('attributes', { - 'role': 'main', - 'aria-labelledby': this.model.get('_id') + '-heading' - }, this); - }, - - className: function() { - return [ - 'menu', - this.constructor.className, - this.model.get('_id'), - this.model.get('_classes'), - this.setVisibility(), - (this.model.get('_isComplete') ? 'is-complete' : ''), - (this.model.get('_isOptional') ? 'is-optional' : '') - ].join(' '); - }, - - preRender: function() { - $.inview.lock('menuView'); - this.disableAnimation = Adapt.config.has('_disableAnimation') ? Adapt.config.get('_disableAnimation') : false; - this.$el.css('opacity', 0); - this.listenTo(this.model, 'change:_isReady', this.isReady); - }, - - isReady: function() { - if (!this.model.get('_isReady')) return; - - var performIsReady = function() { - $('.js-loading').hide(); - $(window).scrollTop(0); - Adapt.trigger('menuView:ready', this); - $.inview.unlock('menuView'); - var styleOptions = { opacity: 1 }; - if (this.disableAnimation) { - this.$el.css(styleOptions); - $.inview(); - } else { - this.$el.velocity(styleOptions, { - duration: 'fast', - complete: function() { - $.inview(); - } - }); - } - $(window).scroll(); - }.bind(this); - - Adapt.wait.queue(function() { - _.defer(performIsReady); - }); - } - - }, { + Object.assign(MenuView, { + /** + * TODO: + * child view here should not be fixed to the MenuItemView + * menus may currently rely on this + */ childContainer: '.js-children', childView: MenuItemView, type: 'menu', diff --git a/src/core/js/views/navigationView.js b/src/core/js/views/navigationView.js index 5b9b495c7..820bb391b 100644 --- a/src/core/js/views/navigationView.js +++ b/src/core/js/views/navigationView.js @@ -34,10 +34,10 @@ define([ } render() { - var template = Handlebars.templates[this.constructor.template]; + const template = Handlebars.templates[this.constructor.template]; this.$el.html(template({ - _globals: Adapt.course.get('_globals'), - _accessibility: Adapt.config.get('_accessibility') + _globals: Adapt.course.get('_globals'), + _accessibility: Adapt.config.get('_accessibility') })).insertBefore('#app'); _.defer(() => { @@ -49,7 +49,7 @@ define([ triggerEvent(event) { event.preventDefault(); - var currentEvent = $(event.currentTarget).attr('data-event'); + const currentEvent = $(event.currentTarget).attr('data-event'); Adapt.trigger('navigation:' + currentEvent); switch (currentEvent) { case 'backButton': diff --git a/src/core/js/views/pageView.js b/src/core/js/views/pageView.js index 5c616aeb6..74dbba011 100644 --- a/src/core/js/views/pageView.js +++ b/src/core/js/views/pageView.js @@ -1,72 +1,21 @@ define([ 'core/js/adapt', - 'core/js/views/adaptView', + 'core/js/views/contentObjectView', 'core/js/views/articleView' -], function(Adapt, AdaptView, ArticleView) { +], function(Adapt, ContentObjectView, ArticleView) { - var PageView = AdaptView.extend({ + class PageView extends ContentObjectView { - attributes: function() { - return AdaptView.resultExtend('attributes', { - 'aria-labelledby': this.model.get('_id') + '-heading', - 'role': 'main' - }, this); - }, - - className: function() { - return [ - 'page', - this.model.get('_id'), - this.model.get('_classes'), - this.setVisibility(), - (this.model.get('_isComplete') ? 'is-complete' : ''), - (this.model.get('_isOptional') ? 'is-optional' : '') - ].join(' '); - }, - - preRender: function() { - $.inview.lock('pageView'); - this.disableAnimation = Adapt.config.has('_disableAnimation') ? Adapt.config.get('_disableAnimation') : false; - this.$el.css('opacity', 0); - this.listenTo(this.model, 'change:_isReady', this.isReady); - }, - - isReady: function() { - if (!this.model.get('_isReady')) return; - - var performIsReady = function() { - $('.js-loading').hide(); - $(window).scrollTop(0); - Adapt.trigger('pageView:ready', this); - $.inview.unlock('pageView'); - var styleOptions = { opacity: 1 }; - if (this.disableAnimation) { - this.$el.css(styleOptions); - $.inview(); - } else { - this.$el.velocity(styleOptions, { - duration: 'fast', - complete: function() { - $.inview(); - } - }); - } - $(window).scroll(); - }.bind(this); - - Adapt.wait.queue(function() { - _.defer(performIsReady); - }); - }, - - remove: function() { + remove() { if (this.$pageLabel) { this.$pageLabel.remove(); } - AdaptView.prototype.remove.call(this); + super.remove(); } - }, { + } + + Object.assign(PageView, { childContainer: '.article__container', childView: ArticleView, type: 'page', diff --git a/src/core/js/views/questionView.js b/src/core/js/views/questionView.js index 6d45037c4..784ee5478 100644 --- a/src/core/js/views/questionView.js +++ b/src/core/js/views/questionView.js @@ -6,11 +6,11 @@ define([ 'core/js/enums/buttonStateEnum' ], function(Adapt, ComponentView, ButtonsView, QuestionModel, BUTTON_STATE) { - var useQuestionModelOnly = false; + const useQuestionModelOnly = false; - var QuestionView = ComponentView.extend({ + class QuestionView extends ComponentView { - className: function() { + className() { return [ 'component', 'is-question', @@ -25,9 +25,9 @@ define([ (this.model.get('_canShowFeedback') ? 'can-show-feedback' : ''), (this.model.get('_canShowMarking') ? 'can-show-marking' : '') ].join(' '); - }, + } - preRender: function() { + preRender() { // Setup listener for _isEnabled this.listenTo(this.model, 'change:_isEnabled', this.onEnabledChanged); @@ -40,10 +40,10 @@ define([ // Blank method for setting up questions before rendering this.setupQuestion(); - }, + } // Used in the question view to disabled the question when _isEnabled has been set to false - onEnabledChanged: function(model, changedAttribute) { + onEnabledChanged(model, changedAttribute) { // If isEnabled == false add disabled class // else remove disabled class @@ -55,18 +55,18 @@ define([ this.enableQuestion(); } - }, + } // Used by the question to disable the question during submit and complete stages - disableQuestion: function() {}, + disableQuestion() {} // Used by the question to enable the question during interactions - enableQuestion: function() {}, + enableQuestion() {} // Used to check if the question should reset on revisit - checkIfResetOnRevisit: function() { + checkIfResetOnRevisit() { - var isResetOnRevisit = this.model.get('_isResetOnRevisit'); + const isResetOnRevisit = this.model.get('_isResetOnRevisit'); // If reset is enabled set defaults // Call blank method for question to handle @@ -75,56 +75,56 @@ define([ this.model.reset(isResetOnRevisit, true); // Defer is added to allow the component to render - _.defer(_.bind(function() { + _.defer(() => { this.resetQuestionOnRevisit(isResetOnRevisit); - }, this)); + }); } else { // If complete - display users answer // or reset the question if not complete - var isInteractionComplete = this.model.get('_isInteractionComplete'); + const isInteractionComplete = this.model.get('_isInteractionComplete'); if (isInteractionComplete) { this.model.set('_buttonState', BUTTON_STATE.HIDE_CORRECT_ANSWER); // Defer is added to allow the component to render - _.defer(_.bind(function() { + _.defer(() => { this.onHideCorrectAnswerClicked(); - }, this)); + }); } else { this.model.set('_buttonState', BUTTON_STATE.SUBMIT); // Defer is added to allow the component to render - _.defer(_.bind(function() { + _.defer(() => { this.onResetClicked(); - }, this)); + }); } } - }, + } // Used by the question to reset the question when revisiting the component - resetQuestionOnRevisit: function(type) {}, + resetQuestionOnRevisit(type) {} // Left blank for question setup - should be used instead of preRender - setupQuestion: function() {}, + setupQuestion() {} // Calls default methods to setup after the question is rendered - postRender: function() { + postRender() { this.addButtonsView(); this.onQuestionRendered(); - }, + } // Used to setup buttonsView and sets up the internal events for the question - addButtonsView: function() { + addButtonsView() { this.buttonsView = new ButtonsView({ model: this.model, el: this.$('.btn__container') }); this.listenTo(this.buttonsView, 'buttons:stateUpdate', this.onButtonStateUpdate); - }, + } - onButtonStateUpdate: function(buttonState) { + onButtonStateUpdate(buttonState) { switch (buttonState) { case BUTTON_STATE.SUBMIT: @@ -144,17 +144,17 @@ define([ break; } - }, + } // Blank method used just like postRender is for presentational components - onQuestionRendered: function() {}, + onQuestionRendered() {} // Triggered when the submit button is clicked - onSubmitClicked: function() { + onSubmitClicked() { // canSubmit is setup in questions and should return a boolean // If the question stops the user form submitting - show instruction error // and give a blank method, onCannotSubmit to the question - var canSubmit = this._runModelCompatibleFunction('canSubmit'); + const canSubmit = this._runModelCompatibleFunction('canSubmit'); if (!canSubmit) { this.showInstructionError(); @@ -212,56 +212,56 @@ define([ this._runModelCompatibleFunction('updateButtons'); this.onSubmitted(); - }, + } // Adds a validation error class when the canSubmit returns false - showInstructionError: function() { + showInstructionError() { this.$('.component__instruction-inner').addClass('validation-error'); Adapt.a11y.focusFirst(this.$el, { defer: true }); - }, + } // Blank method for question to fill out when the question cannot be submitted - onCannotSubmit: function() {}, + onCannotSubmit() {} // Blank method for question to fill out when the question was successfully submitted - onSubmitted: function() {}, + onSubmitted() {} // Used to set _isEnabled and _isSubmitted on the model // Also adds a 'submitted' class to the widget - setQuestionAsSubmitted: function() { + setQuestionAsSubmitted() { this.model.setQuestionAsSubmitted(); this.$('.component__widget').addClass('is-submitted'); - }, + } // Removes validation error class when the user canSubmit - removeInstructionError: function() { + removeInstructionError() { this.$('.component__instruction-inner').removeClass('validation-error'); - }, + } // This is important and should give the user feedback on how they answered the question // Normally done through ticks and crosses by adding classes - showMarking: function() {}, + showMarking() {} // Checks if the question should be set to complete // Calls setCompletionStatus and adds complete classes - checkQuestionCompletion: function() { + checkQuestionCompletion() { - var isComplete = this.model.checkQuestionCompletion(); + const isComplete = this.model.checkQuestionCompletion(); if (isComplete) { this.$('.component__widget').addClass('is-complete show-user-answer'); } - }, + } - recordInteraction: function() { + recordInteraction() { if (this.model.get('_recordInteraction') === true || !this.model.has('_recordInteraction')) { Adapt.trigger('questionView:recordInteraction', this); } - }, + } // Used to show feedback based upon whether _canShowFeedback is true - showFeedback: function() { + showFeedback() { if (this.model.get('_canShowFeedback')) { Adapt.trigger('questionView:showFeedback', this); @@ -269,9 +269,9 @@ define([ Adapt.trigger('questionView:disabledFeedback', this); } - }, + } - onResetClicked: function() { + onResetClicked() { this.setQuestionAsReset(); this._runModelCompatibleFunction('updateButtons'); @@ -285,26 +285,26 @@ define([ // then the button was clicked, focus on the first tabbable element if (!this.model.get('_isReady')) return; // Attempt to get the current page location - var currentModel = Adapt.findById(Adapt.location._currentId); + const currentModel = Adapt.findById(Adapt.location._currentId); // Make sure the page is ready if (!currentModel || !currentModel.get('_isReady')) return; // Focus on the first readable item in this element Adapt.a11y.focusNext(this.$el); - }, + } - setQuestionAsReset: function() { + setQuestionAsReset() { this.model.setQuestionAsReset(); this.$('.component__widget').removeClass('is-submitted'); - }, + } // Used by the question view to reset the look and feel of the component. // This could also include resetting item data // This is triggered when the reset button is clicked so it shouldn't // be a full reset - resetQuestion: function() {}, + resetQuestion() {} - refresh: function() { + refresh() { this.model.set('_buttonState', this.model.getButtonState()); if (this.model.get('_canShowMarking') && this.model.get('_isInteractionComplete') && this.model.get('_isSubmitted')) { @@ -312,59 +312,58 @@ define([ } if (this.buttonsView) { - _.defer(_.bind(this.buttonsView.refresh, this.buttonsView)); + _.defer(this.buttonsView.refresh.bind(this.buttonsView)); } - }, + } - onShowCorrectAnswerClicked: function() { + onShowCorrectAnswerClicked() { this.setQuestionAsShowCorrect(); this._runModelCompatibleFunction('updateButtons'); this.showCorrectAnswer(); - }, + } - setQuestionAsShowCorrect: function() { + setQuestionAsShowCorrect() { this.$('.component__widget') .addClass('is-submitted show-correct-answer') .removeClass('show-user-answer'); - }, + } // Used by the question to display the correct answer to the user - showCorrectAnswer: function() {}, + showCorrectAnswer() {} - onHideCorrectAnswerClicked: function() { + onHideCorrectAnswerClicked() { this.setQuestionAsHideCorrect(); this._runModelCompatibleFunction('updateButtons'); this.hideCorrectAnswer(); - }, + } - setQuestionAsHideCorrect: function() { + setQuestionAsHideCorrect() { this.$('.component__widget') .addClass('is-submitted show-user-answer') .removeClass('show-correct-answer'); - }, + } // Used by the question to display the users answer and // hide the correct answer // Should use the values stored in storeUserAnswer - hideCorrectAnswer: function() {}, + hideCorrectAnswer() {} // Time elapsed between the time the interaction was made available to the learner for response and the time of the first response - getLatency: function() { + getLatency() { return null; - }, + } // This function is overridden if useQuestionModeOnly: false. see below. - _runModelCompatibleFunction: function(name, lookForViewOnlyFunction) { + _runModelCompatibleFunction(name, lookForViewOnlyFunction) { return this.model[name](); // questionModel Only } + } - }, { - _isQuestionType: true - }); + QuestionView._isQuestionType = true; // allows us to turn on and off the questionView style and use the separated questionModel+questionView style only if (useQuestionModelOnly) return QuestionView; @@ -374,7 +373,7 @@ define([ * Remove this section in when all components use questionModel and there is no need to have model behaviour in the questionView */ - var viewOnlyCompatibleQuestionView = { + class ViewOnlyQuestionViewCompatibilityLayer extends QuestionView { /* All of these functions have been moved to the questionModel.js file. * On the rare occasion that they have not been overridden by the component and @@ -386,59 +385,69 @@ define([ */ // Returns an object specific to the question type. - getInteractionObject: function() { + getInteractionObject() { + Adapt.log.deprecated('QuestionView.getInteractionObject, please use QuestionModel.getInteractionObject'); return this.model.getInteractionObject(); - }, + } // Retturns a string detailing how the user answered the question. - getResponse: function() { + getResponse() { + Adapt.log.deprecated('QuestionView.getInteractionObject, please use QuestionModel.getInteractionObject'); return this.model.getResponse(); - }, + } // Returns a string describing the type of interaction: "choice" and "matching" supported (see scorm wrapper) - getResponseType: function() { + getResponseType() { + Adapt.log.deprecated('QuestionView.getResponseType, please use QuestionModel.getResponseType'); return this.model.getResponseType(); - }, + } // Calls default methods to setup on questions - setupDefaultSettings: function() { + setupDefaultSettings() { + Adapt.log.deprecated('QuestionView.setupDefaultSettings, please use QuestionModel.setupDefaultSettings'); return this.model.setupDefaultSettings(); - }, + } // Used to setup either global or local button text - setupButtonSettings: function() { + setupButtonSettings() { + Adapt.log.deprecated('QuestionView.setupButtonSettings, please use QuestionModel.setupButtonSettings'); return this.model.setupButtonSettings(); - }, + } // Used to setup either global or local question weight/score - setupWeightSettings: function() { + setupWeightSettings() { + Adapt.log.deprecated('QuestionView.setupWeightSettings, please use QuestionModel.setupWeightSettings'); return this.model.setupWeightSettings(); - }, + } // Use to check if the user is allowed to submit the question // Maybe the user has to select an item? - canSubmit: function() { + canSubmit() { + Adapt.log.deprecated('QuestionView.canSubmit, please use QuestionModel.canSubmit'); return this.model.canSubmit(); - }, + } // Used to update the amount of attempts the user has left - updateAttempts: function() { + updateAttempts() { + Adapt.log.deprecated('QuestionView.updateAttempts, please use QuestionModel.updateAttempts'); return this.model.updateAttempts(); - }, + } // This is important for returning or showing the users answer // This should preserve the state of the users answers - storeUserAnswer: function() { + storeUserAnswer() { + Adapt.log.deprecated('QuestionView.storeUserAnswer, please use QuestionModel.storeUserAnswer'); return this.model.storeUserAnswer(); - }, + } // Used by the question view to reset the stored user answer - resetUserAnswer: function() { + resetUserAnswer() { + Adapt.log.deprecated('QuestionView.resetUserAnswer, please use QuestionModel.resetUserAnswer'); return this.model.resetUserAnswer(); - }, + } // Sets _isCorrect:true/false based upon isCorrect method below - markQuestion: function() { + markQuestion() { if (this._isInViewOnlyCompatibleMode('isCorrect')) { @@ -451,26 +460,29 @@ define([ } else { return this.model.markQuestion(); } - }, + } // Should return a boolean based upon whether to question is correct or not - isCorrect: function() { + isCorrect() { + Adapt.log.deprecated('QuestionView.isCorrect, please use QuestionModel.isCorrect'); return this.model.isCorrect(); - }, + } // Used to set the score based upon the _questionWeight - setScore: function() { + setScore() { + Adapt.log.deprecated('QuestionView.setScore, please use QuestionModel.setScore'); return this.model.setScore(); - }, + } // Updates buttons based upon question state by setting // _buttonState on the model which buttonsView listens to - updateButtons: function() { + updateButtons() { + Adapt.log.deprecated('QuestionView.updateButtons, please use QuestionModel.updateButtons'); return this.model.updateButtons(); - }, + } // Used to setup the correct, incorrect and partly correct feedback - setupFeedback: function() { + setupFeedback() { if (this._isInViewOnlyCompatibleMode('isPartlyCorrect')) { @@ -488,46 +500,50 @@ define([ this.model.setupFeedback(); } - }, + } // Used by the question to determine if the question is incorrect or partly correct // Should return a boolean - isPartlyCorrect: function() { + isPartlyCorrect() { + Adapt.log.deprecated('QuestionView.isPartlyCorrect, please use QuestionModel.isPartlyCorrect'); return this.model.isPartlyCorrect(); - }, + } - setupCorrectFeedback: function() { + setupCorrectFeedback() { + Adapt.log.deprecated('QuestionView.setupCorrectFeedback, please use QuestionModel.setupCorrectFeedback'); return this.model.setupCorrectFeedback(); - }, + } - setupPartlyCorrectFeedback: function() { + setupPartlyCorrectFeedback() { + Adapt.log.deprecated('QuestionView.setupPartlyCorrectFeedback, please use QuestionModel.setupPartlyCorrectFeedback'); return this.model.setupPartlyCorrectFeedback(); - }, + } - setupIncorrectFeedback: function() { + setupIncorrectFeedback() { + Adapt.log.deprecated('QuestionView.setupIncorrectFeedback, please use QuestionModel.setupIncorrectFeedback'); return this.model.setupIncorrectFeedback(); - }, + } // Helper functions for compatibility layer - _runModelCompatibleFunction: function(name, lookForViewOnlyFunction) { + _runModelCompatibleFunction(name, lookForViewOnlyFunction) { if (this._isInViewOnlyCompatibleMode(name, lookForViewOnlyFunction)) { return this[name](); // questionView } else { return this.model[name](); // questionModel } - }, + } - _isInViewOnlyCompatibleMode: function(name, lookForViewOnlyFunction) { + _isInViewOnlyCompatibleMode(name, lookForViewOnlyFunction) { // return false uses the model function questionModel // return true uses the view only function questionView - var checkForFunction = (lookForViewOnlyFunction || name); + const checkForFunction = (lookForViewOnlyFunction || name); // if the function does NOT exist on the view at all, use the model only if (!this.constructor.prototype[checkForFunction]) return false; // questionModel // if the function DOES exist on the view and MATCHES the compatibility function above, use the model only - if (this.constructor.prototype[checkForFunction] === viewOnlyCompatibleQuestionView[checkForFunction]) { + if (this.constructor.prototype[checkForFunction] === ViewOnlyQuestionViewCompatibilityLayer.prototype[checkForFunction]) { switch (checkForFunction) { case 'setupFeedback': case 'markQuestion': @@ -537,15 +553,14 @@ define([ } // if the function DOES exist on the view and does NOT match the compatibility function above, use the view function + Adapt.log.deprecated(`QuestionView.${name}, please use QuestionModel.${name}`); return true; // questionView } }; // return question view class extended with the compatibility layer - return QuestionView.extend(viewOnlyCompatibleQuestionView, { - _isQuestionType: true - }); + return ViewOnlyQuestionViewCompatibilityLayer; /* END OF BACKWARDS COMPATIBILITY SECTION */ From 80787b126a75c0132c8ca3470eab28dc7597aed6 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 31 Mar 2020 17:34:34 +0100 Subject: [PATCH 02/57] issue/2709 fixed locking model for ES6 classes --- src/core/js/models/lockingModel.js | 75 ++++++++++++++---------------- 1 file changed, 35 insertions(+), 40 deletions(-) diff --git a/src/core/js/models/lockingModel.js b/src/core/js/models/lockingModel.js index d055d65f7..704d78c6a 100644 --- a/src/core/js/models/lockingModel.js +++ b/src/core/js/models/lockingModel.js @@ -1,43 +1,35 @@ define(function() { - var set = Backbone.Model.prototype.set; + const set = Backbone.Model.prototype.set; - _.extend(Backbone.Model.prototype, { + Object.assign(Backbone.Model.prototype, { - set: function(attrName, attrVal, options) { - var stopProcessing = !this.lockedAttributes || typeof attrName === 'object' || typeof attrVal !== 'boolean' || !this.isLocking(attrName); + set: function(attrName, attrVal, options = {}) { + const stopProcessing = (typeof attrName === 'object' || typeof attrVal !== 'boolean' || !this.isLocking(attrName)); if (stopProcessing) return set.apply(this, arguments); - options = options || {}; - - var isSettingValueForSpecificPlugin = options && options.pluginName; + const isSettingValueForSpecificPlugin = options && options.pluginName; if (!isSettingValueForSpecificPlugin) { console.error('Must supply a pluginName to change a locked attribute'); options.pluginName = 'compatibility'; } - var pluginName = options.pluginName; + const pluginName = options.pluginName; if (this.defaults[attrName] !== undefined) { - this.lockedAttributes[attrName] = !this.defaults[attrName]; + this._lockedAttributes[attrName] = !this.defaults[attrName]; } - var lockingValue = this.lockedAttributes[attrName]; - var isAttemptingToLock = (lockingValue === attrVal); + const lockingValue = this._lockedAttributes[attrName]; + const isAttemptingToLock = (lockingValue === attrVal); if (isAttemptingToLock) { - this.setLockState(attrName, true, { pluginName: pluginName, skipcheck: true }); - - // console.log(options.pluginName, "locking", attrName, "on", this.get("_id")); return set.call(this, attrName, lockingValue); - } this.setLockState(attrName, false, { pluginName: pluginName, skipcheck: true }); - var totalLockValue = this.getLockCount(attrName, { skipcheck: true }); - // console.log(options.pluginName, "attempting to unlock", attrName, "on", this.get("_id"), "lockValue", totalLockValue, this._lockedAttributesValues[attrName]); + const totalLockValue = this.getLockCount(attrName, { skipcheck: true }); if (totalLockValue === 0) { - // console.log(options.pluginName, "unlocking", attrName, "on", this.get("_id")); return set.call(this, attrName, !lockingValue); } @@ -47,24 +39,24 @@ define(function() { setLocking: function(attrName, defaultLockValue) { if (this.isLocking(attrName)) return; - if (!this.lockedAttributes) this.lockedAttributes = {}; - this.lockedAttributes[attrName] = defaultLockValue; + if (!this._lockedAttributes) this._lockedAttributes = {}; + this._lockedAttributes[attrName] = defaultLockValue; }, unsetLocking: function(attrName) { if (!this.isLocking(attrName)) return; - if (!this.lockedAttributes) return; - delete this.lockedAttributes[attrName]; + if (!this._lockedAttributes) return; + delete this._lockedAttributes[attrName]; delete this._lockedAttributesValues[attrName]; - if (_.keys(this.lockedAttributes).length === 0) { - delete this.lockedAttributes; + if (_.keys(this._lockedAttributes).length === 0) { + delete this._lockedAttributes; delete this._lockedAttributesValues; } }, isLocking: function(attrName) { - var isCheckingGeneralLockingState = (attrName === undefined); - var isUsingLockedAttributes = (this.lockedAttributes !== undefined); + const isCheckingGeneralLockingState = (attrName === undefined); + const isUsingLockedAttributes = Boolean(this.lockedAttributes || this._lockedAttributes); if (isCheckingGeneralLockingState) { return isUsingLockedAttributes; @@ -72,14 +64,18 @@ define(function() { if (!isUsingLockedAttributes) return false; - var isAttributeALockingAttribute = this.lockedAttributes[attrName] !== undefined; + if (!this._lockedAttributes) { + this._lockedAttributes = _.result(this, 'lockedAttributes'); + } + + const isAttributeALockingAttribute = this._lockedAttributes.hasOwnProperty(attrName); if (!isAttributeALockingAttribute) return false; - if (this._lockedAttributesValues === undefined) { + if (!this._lockedAttributesValues) { this._lockedAttributesValues = {}; } - if (this._lockedAttributesValues[attrName] === undefined) { + if (!this._lockedAttributesValues[attrName]) { this._lockedAttributesValues[attrName] = {}; } @@ -87,9 +83,9 @@ define(function() { }, isLocked: function(attrName, options) { - var shouldSkipCheck = (options && options.skipcheck); + const shouldSkipCheck = (options && options.skipcheck); if (!shouldSkipCheck) { - var stopProcessing = !this.isLocking(attrName); + const stopProcessing = !this.isLocking(attrName); if (stopProcessing) return; } @@ -97,32 +93,31 @@ define(function() { }, getLockCount: function(attrName, options) { - var shouldSkipCheck = (options && options.skipcheck); + const shouldSkipCheck = (options && options.skipcheck); if (!shouldSkipCheck) { - var stopProcessing = !this.isLocking(attrName); + const stopProcessing = !this.isLocking(attrName); if (stopProcessing) return; } - var isGettingValueForSpecificPlugin = options && options.pluginName; + const isGettingValueForSpecificPlugin = options && options.pluginName; if (isGettingValueForSpecificPlugin) { - return this._lockedAttributesValues[attrName][options.pluginName] ? 1 : 0; } - var lockingAttributeValues = _.values(this._lockedAttributesValues[attrName]); - var lockingAttributeValuesSum = _.reduce(lockingAttributeValues, function(sum, value) { return sum + (value ? 1 : 0); }, 0); + const lockingAttributeValues = _.values(this._lockedAttributesValues[attrName]); + const lockingAttributeValuesSum = _.reduce(lockingAttributeValues, (sum, value) => sum + (value ? 1 : 0), 0); return lockingAttributeValuesSum; }, setLockState: function(attrName, value, options) { - var shouldSkipCheck = (options && options.skipcheck); + const shouldSkipCheck = (options && options.skipcheck); if (!shouldSkipCheck) { - var stopProcessing = !this.isLocking(attrName); + const stopProcessing = !this.isLocking(attrName); if (stopProcessing) return this; } - var isSettingValueForSpecificPlugin = options && options.pluginName; + const isSettingValueForSpecificPlugin = options && options.pluginName; if (!isSettingValueForSpecificPlugin) { console.error('Must supply a pluginName to set a locked attribute lock value'); options.pluginName = 'compatibility'; From 5a686db6b2b318b217859943d097089a45c26688 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 09:07:03 +0100 Subject: [PATCH 03/57] Recommendations --- src/core/js/models/itemsQuestionModel.js | 10 +++++----- src/core/js/router.js | 1 + src/core/js/views/componentView.js | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/core/js/models/itemsQuestionModel.js b/src/core/js/models/itemsQuestionModel.js index 34ae90444..9af2307de 100644 --- a/src/core/js/models/itemsQuestionModel.js +++ b/src/core/js/models/itemsQuestionModel.js @@ -11,6 +11,11 @@ define([ ItemsComponentModel.prototype.init.call(this); } + reset(type, force) { + super.reset(type, force); + ItemsComponentModel.prototype.reset.call(this, type, force); + } + } // extend BlendedItemsComponentQuestionModel with ItemsComponentModel Object.getOwnPropertyNames(ItemsComponentModel.prototype).forEach(name => { @@ -169,11 +174,6 @@ define([ this.set('_isAtLeastOneCorrectSelection', false); } - reset(type, force) { - QuestionModel.prototype.reset.apply(this, arguments); - ItemsComponentModel.prototype.reset.apply(this, arguments); - } - getInteractionObject() { const interactions = { correctResponsesPattern: [], diff --git a/src/core/js/router.js b/src/core/js/router.js index 0e766fab2..0afa82bf3 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -14,6 +14,7 @@ define([ ':pluginName(/*location)(/*action)': 'handleRoute' }; } + initialize({ model }) { this.model = model; diff --git a/src/core/js/views/componentView.js b/src/core/js/views/componentView.js index dfc98af0a..299935dee 100644 --- a/src/core/js/views/componentView.js +++ b/src/core/js/views/componentView.js @@ -30,7 +30,7 @@ define([ } renderState() { - Adapt.log.warn('REMOVED - renderState is removed and moved to item title'); + Adapt.log.removed('renderState is removed and moved to item title'); } /** From 603d332fe32708840d9e99d7749be130ebb1fb12 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 09:08:50 +0100 Subject: [PATCH 04/57] Recommendations --- src/core/js/views/componentView.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/js/views/componentView.js b/src/core/js/views/componentView.js index 299935dee..98118fbcc 100644 --- a/src/core/js/views/componentView.js +++ b/src/core/js/views/componentView.js @@ -81,8 +81,7 @@ define([ remove() { this.removeInviewListener(); - - AdaptView.prototype.remove.call(this); + super.remove(); } } From c36897116dcfbed6e3530cd07195451e811fa1c2 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 09:10:35 +0100 Subject: [PATCH 05/57] Recommendations --- src/core/js/models/questionModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/js/models/questionModel.js b/src/core/js/models/questionModel.js index dcfd4a285..f8988a8db 100644 --- a/src/core/js/models/questionModel.js +++ b/src/core/js/models/questionModel.js @@ -260,7 +260,7 @@ define([ type = type || true; // hard reset by default, can be "soft", "hard"/true - ComponentModel.prototype.reset.call(this, type, force); + super.reset(type, force); const attempts = this.get('_attempts'); this.set({ From ce81506283f190c82e2a306755aa86f57545d50a Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 12:25:26 +0100 Subject: [PATCH 06/57] Recommendation Co-Authored-By: tomgreenfield --- src/core/js/adapt.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index 8c0f0439a..67641e604 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -293,7 +293,7 @@ define([ const disableAnimation = this.config.get('_disableAnimation'); // Check if animations should be disabled - if (disableAnimationArray && disableAnimationArray.length > 0) { + if (disableAnimationArray) { for (let i = 0; i < disableAnimationArray.length; i++) { if ($('html').is(disableAnimationArray[i])) { this.config.set('_disableAnimation', true); From 66815972a8bf89189bb9275b9481791f3ffb18f1 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 12:25:44 +0100 Subject: [PATCH 07/57] Recommendation Co-Authored-By: tomgreenfield --- src/core/js/adapt.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index 67641e604..25cc6346d 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -294,7 +294,7 @@ define([ // Check if animations should be disabled if (disableAnimationArray) { - for (let i = 0; i < disableAnimationArray.length; i++) { + for (let i = 0, l = disableAnimationArray.length; i < l; i++) { if ($('html').is(disableAnimationArray[i])) { this.config.set('_disableAnimation', true); $('html').addClass('disable-animation'); From d50d1468ff0d928a2f03ba4fac68717ac3ef1f4d Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 17:20:00 +0100 Subject: [PATCH 08/57] Recommendations --- src/core/js/adapt.js | 13 ++++++------- src/core/js/collections/adaptCollection.js | 4 +--- src/core/js/models/configModel.js | 4 +--- src/core/js/models/courseModel.js | 4 +--- src/core/js/models/itemsComponentModel.js | 4 +--- src/core/js/models/itemsQuestionModel.js | 9 ++------- src/core/js/models/lockingModel.js | 6 +++--- 7 files changed, 15 insertions(+), 29 deletions(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index 25cc6346d..8d324f46a 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -88,12 +88,12 @@ define([ // Setup legacy events and handlers const beginWait = () => { - this.log.deprecated("Use Adapt.wait.begin() as Adapt.trigger('plugin:beginWait') may be removed in the future"); + this.log.deprecated(`Use Adapt.wait.begin() as Adapt.trigger('plugin:beginWait') may be removed in the future`); this.wait.begin(); }; const endWait = () => { - this.log.deprecated("Use Adapt.wait.end() as Adapt.trigger('plugin:endWait') may be removed in the future"); + this.log.deprecated(`Use Adapt.wait.end() as Adapt.trigger('plugin:endWait') may be removed in the future`); this.wait.end(); }; @@ -295,11 +295,10 @@ define([ // Check if animations should be disabled if (disableAnimationArray) { for (let i = 0, l = disableAnimationArray.length; i < l; i++) { - if ($('html').is(disableAnimationArray[i])) { - this.config.set('_disableAnimation', true); - $('html').addClass('disable-animation'); - console.log('Animation disabled.'); - } + if (!$('html').is(disableAnimationArray[i])) continue; + this.config.set('_disableAnimation', true); + $('html').addClass('disable-animation'); + console.log('Animation disabled.'); } return; } diff --git a/src/core/js/collections/adaptCollection.js b/src/core/js/collections/adaptCollection.js index 95430ec0e..03566c559 100644 --- a/src/core/js/collections/adaptCollection.js +++ b/src/core/js/collections/adaptCollection.js @@ -10,9 +10,7 @@ define([ if (!this.url) return; this.fetch({ reset: true, - error: () => { - console.error('ERROR: unable to load file ' + this.url); - } + error: () => console.error('ERROR: unable to load file ' + this.url) }); } diff --git a/src/core/js/models/configModel.js b/src/core/js/models/configModel.js index 7263f8b50..4693d4881 100644 --- a/src/core/js/models/configModel.js +++ b/src/core/js/models/configModel.js @@ -31,9 +31,7 @@ define([ Adapt.trigger('configModel:loadCourseData'); }); }, - error: () => { - console.log('Unable to load course/config.json'); - } + error: () => console.log('Unable to load course/config.json') }); } diff --git a/src/core/js/models/courseModel.js b/src/core/js/models/courseModel.js index adba20605..6db0459b4 100644 --- a/src/core/js/models/courseModel.js +++ b/src/core/js/models/courseModel.js @@ -20,9 +20,7 @@ define([ this.on('sync', this.loadedData, this); if (!this.url) return; this.fetch({ - error: () => { - console.error(`ERROR: unable to load file ${this.url}`); - } + error: () => console.error(`ERROR: unable to load file ${this.url}`) }); } diff --git a/src/core/js/models/itemsComponentModel.js b/src/core/js/models/itemsComponentModel.js index 3739a0802..d18bd2c03 100644 --- a/src/core/js/models/itemsComponentModel.js +++ b/src/core/js/models/itemsComponentModel.js @@ -22,9 +22,7 @@ define([ setUpItems() { // see https://github.com/adaptlearning/adapt_framework/issues/2480 const items = this.get('_items') || []; - items.forEach(function(item, index) { - item._index = index; - }); + items.forEach((item, index) => (item._index = index)); this.set('_children', new Backbone.Collection(items, { model: ItemModel })); } diff --git a/src/core/js/models/itemsQuestionModel.js b/src/core/js/models/itemsQuestionModel.js index 9af2307de..789959a78 100644 --- a/src/core/js/models/itemsQuestionModel.js +++ b/src/core/js/models/itemsQuestionModel.js @@ -63,13 +63,8 @@ define([ // This should preserve the state of the users answers storeUserAnswer() { const items = this.getChildren().slice(0); - items.sort((a, b) => { - return a.get('_index') - b.get('_index'); - }); - - const userAnswer = items.map(itemModel => { - return itemModel.get('_isActive'); - }); + items.sort((a, b) => a.get('_index') - b.get('_index')); + const userAnswer = items.map(itemModel => itemModel.get('_isActive')); this.set('_userAnswer', userAnswer); } diff --git a/src/core/js/models/lockingModel.js b/src/core/js/models/lockingModel.js index 704d78c6a..2ef0db476 100644 --- a/src/core/js/models/lockingModel.js +++ b/src/core/js/models/lockingModel.js @@ -48,7 +48,7 @@ define(function() { if (!this._lockedAttributes) return; delete this._lockedAttributes[attrName]; delete this._lockedAttributesValues[attrName]; - if (_.keys(this._lockedAttributes).length === 0) { + if (Object.keys(this._lockedAttributes).length === 0) { delete this._lockedAttributes; delete this._lockedAttributesValues; } @@ -104,8 +104,8 @@ define(function() { return this._lockedAttributesValues[attrName][options.pluginName] ? 1 : 0; } - const lockingAttributeValues = _.values(this._lockedAttributesValues[attrName]); - const lockingAttributeValuesSum = _.reduce(lockingAttributeValues, (sum, value) => sum + (value ? 1 : 0), 0); + const lockingAttributeValues = Object.values(this._lockedAttributesValues[attrName]); + const lockingAttributeValuesSum = lockingAttributeValues.reduce((sum, value) => sum + (value ? 1 : 0), 0); return lockingAttributeValuesSum; }, From d4aad08d601b38c2d1dff290f074d30fa7f88ca2 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 17:27:29 +0100 Subject: [PATCH 09/57] issue/2712 Simplfied router --- src/core/js/a11y.js | 6 +- src/core/js/adapt.js | 50 ++- src/core/js/headings.js | 4 +- src/core/js/router.js | 481 ++++++++++++------------- src/core/js/scrolling.js | 79 ++-- src/core/js/views/adaptView.js | 25 +- src/core/js/views/contentObjectView.js | 32 +- src/core/js/wait.js | 12 +- 8 files changed, 330 insertions(+), 359 deletions(-) diff --git a/src/core/js/a11y.js b/src/core/js/a11y.js index 9f5a8a412..6a65e4ce6 100644 --- a/src/core/js/a11y.js +++ b/src/core/js/a11y.js @@ -79,7 +79,7 @@ define([ Adapt.on('device:changed', this._setupNoSelect); this.listenTo(Adapt, { 'router:location': this._onNavigationStart, - 'pageView:ready menuView:ready router:plugin': this._onNavigationEnd + 'contentObjectView:ready router:plugin': this._onNavigationEnd }); }, @@ -137,7 +137,7 @@ define([ } // Stop document reading _.defer(function() { - Adapt.a11y.toggleHidden('.page, .menu', true); + Adapt.a11y.toggleHidden('.contentobject', true); }); }, @@ -147,7 +147,7 @@ define([ return; } // Allow document to be read - Adapt.a11y.toggleHidden('.page, .menu', false); + Adapt.a11y.toggleHidden('.contentobject', false); }, isActive: function() { diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index 8d324f46a..5eefd378f 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -17,7 +17,8 @@ define([ _canScroll: true, // to stop scrollTo behaviour, _outstandingCompletionChecks: 0, _pluginWaitCount: 0, - _isStarted: false + _isStarted: false, + _shouldDestroyContentObjects: true }; } @@ -133,33 +134,18 @@ define([ /** * Allows a selector to be passed in and Adapt will navigate to this element * @param {string} selector CSS selector of the Adapt element you want to navigate to e.g. `".co-05"` - * @param {object} [settings] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). + * @param {object} [settings={}] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). * You may also include a `replace` property that you can set to `true` if you want to update the URL without creating an entry in the browser's history. */ - navigateToElement(selector, settings = {}) { - // Removes . symbol from the selector to find the model - const currentModelId = selector.replace(/\./g, ''); - const currentModel = this.data.findById(currentModelId); - // Get current page to check whether this is the current page - const currentPage = (currentModel._siblings === 'contentObjects') ? currentModel : currentModel.findAncestor('contentObjects'); - - // If current page - scrollTo element - if (currentPage.get('_id') === this.location._currentId) { - return this.scrollTo(selector, settings); - } - - // If the element is on another page navigate and wait until pageView:ready is fired - // Then scrollTo element - this.once('pageView:ready', _.debounce(() => { - this.router.set('_shouldNavigateFocus', true); - this.scrollTo(selector, settings); - }, 1)); - - const shouldReplaceRoute = settings.replace || false; + navigateToElement() {} - this.router.set('_shouldNavigateFocus', false); - Backbone.history.navigate('#/id/' + currentPage.get('_id'), { trigger: true, replace: shouldReplaceRoute }); - } + /** + * Allows a selector to be passed in and Adapt will scroll to this element + * @param {string} selector CSS selector of the Adapt element you want to navigate to e.g. `".co-05"` + * @param {object} [settings={}] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). + * You may also include a `replace` property that you can set to `true` if you want to update the URL without creating an entry in the browser's history. + */ + scrollTo() {} /** * Used to register components with the Adapt 'component store' @@ -306,10 +292,16 @@ define([ $('html').toggleClass('disable-animation', (disableAnimation === true)); } - remove() { - this.trigger('preRemove'); - this.trigger('remove'); - _.defer(this.trigger.bind(this), 'postRemove'); + async remove() { + const currentView = this.parentView; + this.trigger('preRemove', currentView); + await this.wait.queue(); + // Facilitate contentObject transitions + if (this.parentView && this.get('_shouldDestroyContentObjects')) { + this.parentView.destroy(); + } + this.trigger('remove', currentView); + _.defer(this.trigger.bind(this), 'postRemove', currentView); } } diff --git a/src/core/js/headings.js b/src/core/js/headings.js index 176e6a59e..57d9bd8cd 100644 --- a/src/core/js/headings.js +++ b/src/core/js/headings.js @@ -6,9 +6,7 @@ define([ var Headings = Backbone.Controller.extend({ initialize: function() { - var types = [ 'menu', 'menuItem', 'page', 'article', 'block', 'component' ]; - var eventNames = types.concat(['']).join('View:render '); - this.listenTo(Adapt, eventNames, this.onViewRender); + this.listenTo(Adapt, 'view:render', this.onViewRender); }, onViewRender: function(view) { diff --git a/src/core/js/router.js b/src/core/js/router.js index 0afa82bf3..4dd1c0752 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -1,9 +1,12 @@ define([ 'core/js/adapt', 'core/js/models/routerModel', + 'core/js/models/courseModel', + 'core/js/models/contentObjectModel', + 'core/js/models/menuModel', 'core/js/views/pageView', 'core/js/startController' -], function(Adapt, RouterModel, PageView) { +], function(Adapt, RouterModel, CourseModel, ContentObjectModel, MenuModel, PageView) { class Router extends Backbone.Router { @@ -16,35 +19,64 @@ define([ } initialize({ model }) { - this.model = model; - + this._navigationRoot = Adapt.course; // Flag to indicate if the router has tried to redirect to the current location. this._isCircularNavigationInProgress = false; - this.showLoading(); - // Store #wrapper element and html to cache for later use. this.$wrapper = $('#wrapper'); this.$html = $('html'); + this.listenToOnce(Adapt, 'app:dataReady', this.setDocumentTitle); + this.listenTo(Adapt, 'router:navigateTo', this.navigateToArguments); + } + + get rootModel() { + return this._navigationRoot; + } + + set rootModel(model) { + this._navigationRoot = model; + } - this.listenToOnce(Adapt, 'app:dataReady', () => { - document.title = Adapt.course.get('title'); + showLoading() { + $('.js-loading').show(); + } + + hideLoading() { + $('.js-loading').hide(); + } + + setDocumentTitle() { + const currentModel = Adapt.location._currentModel; + const hasSubTitle = (currentModel && currentModel !== Adapt.router.rootModel && currentModel.get('title')); + const title = [ + this.rootModel.get('title') || null, + hasSubTitle ? currentModel.get('title') : null + ].filter(Boolean).join(' | '); + this.listenToOnce(Adapt, 'contentObjectView:preRender', () => { + const escapedTitle = $(`
${title}
`).text(); + document.title = escapedTitle; }); - this.listenTo(Adapt, 'router:navigateTo', this.navigateToArguments); } - pruneArguments(args) { - if (args.length !== 0) { - // Remove any null arguments. - args = args.filter(v => v !== null); + navigateToArguments(args) { + args = args.filter(v => v !== null); + const options = { trigger: false, replace: false }; + if (args.length === 1 && Adapt.findById(args[0])) { + this.navigate('#/id/' + args[0], options); + return; } - - return args; + if (args.length <= 3) { + this.navigate('#/' + args.join('/'), options); + return; + } + Adapt.log.deprecated(`Use Backbone.history.navigate or window.location.href instead of Adapt.trigger('router:navigateTo')`); + this.handleRoute(...args); } handleRoute(...args) { - args = this.pruneArguments(args); + args = args.filter(v => v !== null); if (this.model.get('_canNavigate')) { // Reset _isCircularNavigationInProgress protection as code is allowed to navigate away. @@ -62,18 +94,10 @@ define([ if (this.model.get('_canNavigate')) { // Disable navigation whilst rendering. this.model.set('_canNavigate', false, { pluginName: 'adapt' }); - - switch (args.length) { - case 1: - // If only one parameter assume it's the ID. - return this.handleId(...args); - case 2: - // If there are two parameters assume it's a plugin. - return this.handlePluginRouter(...args); - default: - // Route to course home page. - return this.handleCourse(); + if (args.length <= 1) { + return this.handleId(...args); } + return this.handlePluginRouter(...args); } if (this._isCircularNavigationInProgress) { @@ -92,291 +116,238 @@ define([ this.navigateToCurrentRoute(true); } - handlePluginRouter(pluginName, location, action) { - let pluginLocation = pluginName; + async handlePluginRouter(pluginName, location, action) { + const pluginLocation = [ + pluginName, + location ? `-${location}` : null, + action ? `-${action}` : null + ].filter(Boolean).join(''); + await this.updateLocation(pluginLocation, null, null, null); + + Adapt.trigger('router:plugin:' + pluginName, pluginName, location, action); + Adapt.trigger('router:plugin', pluginName, location, action); + this.model.set('_canNavigate', true, { pluginName: 'adapt' }); + } - if (location) { - pluginLocation = pluginLocation + '-' + location; + async handleId(id) { + const rootModel = Adapt.router.rootModel; + const model = (!id) ? rootModel : Adapt.findById(id); - if (action) { - pluginLocation = pluginLocation + '-' + action; - } + if (!model) { + // Bad id + this.model.set('_canNavigate', true, { pluginName: 'adapt' }); + return; } - this.updateLocation(pluginLocation, null, null, () => { - Adapt.trigger('router:plugin:' + pluginName, pluginName, location, action); - Adapt.trigger('router:plugin', pluginName, location, action); + id = model.get('_id'); + const isContentObject = (model instanceof ContentObjectModel); + if (!isContentObject) { + // Allow navigation. this.model.set('_canNavigate', true, { pluginName: 'adapt' }); - }); - } + // Scroll to element + Adapt.navigateToElement('.' + id, { replace: true }); + return; + } - handleCourse() { - if (Adapt.course.has('_start')) { + const isRoot = (model === rootModel); + if (isRoot && Adapt.course.has('_start')) { // Do not allow access to the menu when the start controller is enabled. - const startController = Adapt.course.get('_start'); - + var startController = Adapt.course.get('_start'); if (startController._isEnabled === true && startController._isMenuDisabled === true) { return; } } - this.showLoading(); - - this.removeViews(() => { - Adapt.course.set('_isReady', false); + if (isContentObject && model.get('_isLocked') && Adapt.config.get('_forceRouteLocking')) { + // Locked id + Adapt.log.warn('Unable to navigate to locked id: ' + id); + this.model.set('_canNavigate', true, { pluginName: 'adapt' }); + if (Adapt.location._previousId === undefined) { + return this.navigate('#/', { trigger: true, replace: true }); + } + return this.navigateBack(); + } - this.setContentObjectToVisited(Adapt.course); + // Move to a content object + this.showLoading(); + await Adapt.remove(); + + /** + * TODO: + * As the course object has separate location and type rules, + * it makes it more difficult to update the Adapt.location object + * should stop doing this. + */ + const isCourse = (model instanceof CourseModel); + const type = isCourse ? 'menu' : model.get('_type'); + const location = isCourse ? 'course' : `${type}-${id}`; - this.updateLocation('course', null, null, () => { - this.listenToOnce(Adapt, 'menuView:ready', () => { - // Allow navigation. - this.model.set('_canNavigate', true, { pluginName: 'adapt' }); - this.handleNavigationFocus(); - }); + model.set('_isVisited', true); + await this.updateLocation(location, type, id, model); - Adapt.trigger('router:menu', Adapt.course); - }); + Adapt.once('contentObjectView:ready', () => { + // Allow navigation. + this.model.set('_canNavigate', true, { pluginName: 'adapt' }); + this.handleNavigationFocus(); }); - } + Adapt.trigger(`router:${type} router:contentObject`, model); - handleId(id) { - const currentModel = Adapt.findById(id); - let type = ''; - - if (!currentModel) { - this.model.set('_canNavigate', true, { pluginName: 'adapt' }); + const isMenu = (model instanceof MenuModel); + if (isMenu) { return; } + this.$wrapper.append(new PageView({ model }).$el); + } + + async updateLocation(currentLocation, type, id, currentModel) { + // Handles updating the location. + Adapt.location._previousModel = Adapt.location._currentModel; + Adapt.location._previousId = Adapt.location._currentId; + Adapt.location._previousContentType = Adapt.location._contentType; + + Adapt.location._currentModel = currentModel; + Adapt.location._currentId = id; + Adapt.location._contentType = type; + Adapt.location._currentLocation = currentLocation; - type = currentModel.get('_type'); - - switch (type) { - case 'page': - case 'menu': - if (currentModel.get('_isLocked') && Adapt.config.get('_forceRouteLocking')) { - Adapt.log.warn('Unable to navigate to locked id: ' + id); - this.model.set('_canNavigate', true, { pluginName: 'adapt' }); - if (Adapt.location._previousId === undefined) { - return this.navigate('#/', { trigger: true, replace: true }); - } else { - return Backbone.history.history.back(); - } - } else { - this.showLoading(); - this.removeViews(() => { - let location; - this.setContentObjectToVisited(currentModel); - - if (type === 'page') { - location = 'page-' + id; - this.updateLocation(location, 'page', id, () => { - this.listenToOnce(Adapt, 'pageView:ready', () => { - // Allow navigation. - this.model.set('_canNavigate', true, { pluginName: 'adapt' }); - this.handleNavigationFocus(); - }); - Adapt.trigger('router:page', currentModel); - this.$wrapper.append(new PageView({ model: currentModel }).$el); - }); - } else { - location = 'menu-' + id; - this.updateLocation(location, 'menu', id, () => { - this.listenToOnce(Adapt, 'menuView:ready', () => { - // Allow navigation. - this.model.set('_canNavigate', true, { pluginName: 'adapt' }); - this.handleNavigationFocus(); - }); - Adapt.trigger('router:menu', currentModel); - }); - } - }); - } - break; - default: - // Allow navigation. - this.model.set('_canNavigate', true, { pluginName: 'adapt' }); - Adapt.navigateToElement('.' + id, { replace: true }); + /** + * TODO: + * this if block should be removed, + * these properties are unused in the framework + */ + if (type === 'menu') { + Adapt.location._lastVisitedType = 'menu'; + Adapt.location._lastVisitedMenu = id; + } else if (type === 'page') { + Adapt.location._lastVisitedType = 'page'; + Adapt.location._lastVisitedPage = id; } - } - removeViews(onComplete) { - Adapt.remove(); + this.setDocumentTitle(); + this.setGlobalClasses(); - Adapt.wait.queue(onComplete); - } + // Trigger event when location changes. + Adapt.trigger('router:location', Adapt.location); - showLoading() { - $('.js-loading').show(); + await Adapt.wait.queue(); } - navigateToArguments(args) { - args = this.pruneArguments(args); + setGlobalClasses() { + const currentModel = Adapt.location._currentModel; + const htmlClasses = (currentModel && currentModel.get('_htmlClasses')) || ''; - const options = { trigger: false, replace: false }; + const classes = (Adapt.location._currentId) ? + `location-${Adapt.location._contentType} location-id-${Adapt.location._currentId}` : + `location-${Adapt.location._currentLocation}`; - switch (args.length) { - case 0: - this.navigate('#/', options); - break; - case 1: - if (Adapt.findById(args[0])) { - this.navigate('#/id/' + args[0], options); - } else { - this.navigate('#/' + args[0], options); - } - break; - case 2: - case 3: - this.navigate('#/' + args.join('/'), options); - break; - default: - Adapt.log.deprecated(`Use Backbone.history.navigate or window.location.href instead of Adapt.trigger('router:navigateTo')`); - this.handleRoute(...args); - } + this.$html + .removeClass(Adapt.location._previousClasses) + .addClass(classes) + .addClass(htmlClasses) + .attr('data-location', Adapt.location._currentLocation); + + this.$wrapper + .removeClass() + .addClass(classes) + .attr('data-location', Adapt.location._currentLocation); + + Adapt.location._previousClasses = `${classes} ${htmlClasses}`; } - navigateToPreviousRoute(force) { - // Sometimes a plugin might want to stop the default navigation. - // Check whether default navigation has changed. + handleNavigationFocus() { + if (!this.model.get('_shouldNavigateFocus')) return; + // Body will be forced to accept focus to start the + // screen reader reading the page. + Adapt.a11y.focus('body'); + } + + navigateBack() { + Backbone.history.history.back() + } + + navigateToCurrentRoute(force) { if (!this.model.get('_canNavigate') && !force) { return; } if (!Adapt.location._currentId) { - return Backbone.history.history.back(); - } - if (Adapt.location._previousContentType === 'page' && Adapt.location._contentType === 'menu') { - return this.navigateToParent(); - } - if (Adapt.location._previousContentType === 'page') { - return Backbone.history.history.back(); - } - if (Adapt.location._currentLocation === 'course') { return; } - this.navigateToParent(); + const currentId = Adapt.location._currentModel.get('_id'); + const isRoot = (Adapt.location._currentModel === this.rootModel); + const route = isRoot ? '#/' : '#/id/' + currentId; + this.navigate(route, { trigger: true, replace: true }); } - navigateToHomeRoute(force) { + navigateToPreviousRoute(force) { + // Sometimes a plugin might want to stop the default navigation. + // Check whether default navigation has changed. if (!this.model.get('_canNavigate') && !force) { return; } - this.navigate('#/', { trigger: true }); - } - - navigateToCurrentRoute(force) { - if (!this.model.get('_canNavigate') && !force) { - return; + const currentModel = Adapt.location._currentModel; + const previousModel = Adapt.location._previousModel; + if (!currentModel) { + return this.navigateBack(); } - if (!Adapt.location._currentId) { - return; + if (Adapt.location._currentModel instanceof MenuModel) { + return this.navigateToParent(); } - const currentId = Adapt.location._currentId; - const route = (currentId === Adapt.course.get('_id')) ? '#/' : '#/id/' + currentId; - this.navigate(route, { trigger: true, replace: true }); + if (previousModel) { + return this.navigateBack(); + } + this.navigateToParent(); } navigateToParent(force) { if (!this.model.get('_canNavigate') && !force) { return; } - const parentId = Adapt.contentObjects.findWhere({ _id: Adapt.location._currentId }).get('_parentId'); - const route = (parentId === Adapt.course.get('_id')) ? '#/' : '#/id/' + parentId; + const parentId = Adapt.location._currentModel.get('_parentId'); + const parentModel = Adapt.findById(parentId); + const isRoot = (parentModel === this.rootModel); + const route = isRoot ? '#/' : '#/id/' + parentId; this.navigate(route, { trigger: true }); } - setContentObjectToVisited(model) { - model.set('_isVisited', true); - } - - updateLocation(currentLocation, type, id, onComplete) { - // Handles updating the location. - Adapt.location._previousId = Adapt.location._currentId; - Adapt.location._previousContentType = Adapt.location._contentType; - - if (currentLocation === 'course') { - Adapt.location._currentId = Adapt.course.get('_id'); - Adapt.location._contentType = 'menu'; - Adapt.location._lastVisitedMenu = currentLocation; - } else if (!type) { - Adapt.location._currentId = null; - Adapt.location._contentType = null; - } else if (_.isString(id)) { - Adapt.location._currentId = id; - Adapt.location._contentType = type; - - if (type === 'menu') { - Adapt.location._lastVisitedType = 'menu'; - Adapt.location._lastVisitedMenu = id; - } else if (type === 'page') { - Adapt.location._lastVisitedType = 'page'; - Adapt.location._lastVisitedPage = id; - } - } - - Adapt.location._currentLocation = currentLocation; - - const locationModel = Adapt.findById(id) || Adapt.course; - const htmlClasses = (locationModel && locationModel.get('_htmlClasses')) || ''; - - const classes = (Adapt.location._currentId) ? 'location-' + - Adapt.location._contentType + - ' location-id-' + - Adapt.location._currentId - : 'location-' + Adapt.location._currentLocation; - - const previousClasses = Adapt.location._previousClasses; - if (previousClasses) { - this.$html.removeClass(previousClasses); + navigateToHomeRoute(force) { + if (!this.model.get('_canNavigate') && !force) { + return; } - - Adapt.location._previousClasses = classes + ' ' + htmlClasses; - - this.$html - .addClass(classes) - .addClass(htmlClasses) - .attr('data-location', Adapt.location._currentLocation); - - this.$wrapper - .removeClass() - .addClass(classes) - .attr('data-location', Adapt.location._currentLocation); - - this.setDocumentTitle(); - - // Trigger event when location changes. - Adapt.trigger('router:location', Adapt.location); - - Adapt.wait.queue(onComplete); + this.navigate('#/', { trigger: true }); } - setDocumentTitle() { - if (!Adapt.location._currentId) return; - - const currentModel = Adapt.findById(Adapt.location._currentId); - let pageTitle = ''; - - if (currentModel && currentModel.get('_type') !== 'course') { - const currentTitle = currentModel.get('title'); - - if (currentTitle) { - pageTitle = ' | ' + currentTitle; - } + /** + * Allows a selector to be passed in and Adapt will navigate to this element + * @param {string} selector CSS selector of the Adapt element you want to navigate to e.g. `".co-05"` + * @param {object} [settings] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). + * You may also include a `replace` property that you can set to `true` if you want to update the URL without creating an entry in the browser's history. + */ + navigateToElement(selector, settings = {}) { + // Removes . symbol from the selector to find the model + const currentModelId = selector.replace(/\./g, ''); + const currentModel = Adapt.findById(currentModelId); + if (!currentModel) return; + + // Get current page to check whether this is the current page + const currentPage = currentModel instanceof ContentObjectModel ? currentModel : currentModel.findAncestor('contentObjects'); + const pageId = currentPage.get('_id'); + // If current page - scrollTo element + if (pageId === Adapt.location._currentId) { + return Adapt.scrollTo(selector, settings); } - const courseTitle = Adapt.course.get('title'); - const documentTitle = $('
' + courseTitle + pageTitle + '
').text(); + // If the element is on another page navigate and wait until pageView:ready is fired + // Then scrollTo element + Adapt.once('contentObjectView:ready', _.debounce(() => { + this.model.set('_shouldNavigateFocus', true, { pluginName: 'adapt' }); + Adapt.scrollTo(selector, settings); + }, 1)); - this.listenToOnce(Adapt, 'pageView:ready menuView:ready', () => { - document.title = documentTitle; - }); - } + const shouldReplaceRoute = settings.replace || false; - handleNavigationFocus() { - if (!this.model.get('_shouldNavigateFocus')) return; - // Body will be forced to accept focus to start the - // screen reader reading the page. - Adapt.a11y.focus('body'); + this.model.set('_shouldNavigateFocus', false, { pluginName: 'adapt' }); + this.navigate('#/id/' + pageId, { trigger: true, replace: shouldReplaceRoute }); } get(...args) { @@ -391,8 +362,12 @@ define([ } - return (Adapt.router = new Router({ + Adapt.router = new Router({ model: new RouterModel(null, { reset: true }) - })); + }); + + Adapt.navigateToElement = Adapt.router.navigateToElement.bind(Adapt.router); + + return Adapt.router; }); diff --git a/src/core/js/scrolling.js b/src/core/js/scrolling.js index 9033ba01c..351027c9f 100644 --- a/src/core/js/scrolling.js +++ b/src/core/js/scrolling.js @@ -99,54 +99,53 @@ define([ }); } - } + scrollTo(selector, settings = {}) { + // Get the current location - this is set in the router + const location = (Adapt.location._contentType) ? + Adapt.location._contentType : Adapt.location._currentLocation; + // Trigger initial scrollTo event + Adapt.trigger(`${location}:scrollTo`, selector); + // Setup duration variable passed upon argumentsß + const disableScrollToAnimation = Adapt.config.has('_disableAnimation') ? Adapt.config.get('_disableAnimation') : false; + if (disableScrollToAnimation) { + settings.duration = 0; + } else if (!settings.duration) { + settings.duration = $.scrollTo.defaults.duration; + } - Adapt.scrolling = new Scrolling(); + let offsetTop = 0; + if (Adapt.scrolling.isLegacyScrolling) { + offsetTop = -$('.nav').outerHeight(); + // prevent scroll issue when component description aria-label coincident with top of component + if ($(selector).hasClass('component')) { + offsetTop -= $(selector).find('.aria-label').height() || 0; + } + } - Adapt.scrollTo = function(selector, settings) { - if (!settings) { - settings = {}; - } - // Get the current location - this is set in the router - const location = (Adapt.location._contentType) ? - Adapt.location._contentType : Adapt.location._currentLocation; - // Trigger initial scrollTo event - Adapt.trigger(location + ':scrollTo', selector); - // Setup duration variable passed upon argumentsß - const disableScrollToAnimation = Adapt.config.has('_disableAnimation') ? Adapt.config.get('_disableAnimation') : false; - if (disableScrollToAnimation) { - settings.duration = 0; - } else if (!settings.duration) { - settings.duration = $.scrollTo.defaults.duration; - } + if (!settings.offset) settings.offset = { top: offsetTop, left: 0 }; + if (settings.offset.top === undefined) settings.offset.top = offsetTop; + if (settings.offset.left === undefined) settings.offset.left = 0; - let offsetTop = 0; - if (Adapt.scrolling.isLegacyScrolling) { - offsetTop = -$('.nav').outerHeight(); - // prevent scroll issue when component description aria-label coincident with top of component - if ($(selector).hasClass('component')) { - offsetTop -= $(selector).find('.aria-label').height() || 0; + if (settings.offset.left === 0) settings.axis = 'y'; + + if (Adapt.get('_canScroll') !== false) { + // Trigger scrollTo plugin + $.scrollTo(selector, settings); } - } - if (!settings.offset) settings.offset = { top: offsetTop, left: 0 }; - if (settings.offset.top === undefined) settings.offset.top = offsetTop; - if (settings.offset.left === undefined) settings.offset.left = 0; + // Trigger an event after animation + // 300 milliseconds added to make sure queue has finished + _.delay(() => { + Adapt.a11y.focusNext(selector); + Adapt.trigger(`${location}:scrolledTo`, selector); + }, settings.duration + 300); + } - if (settings.offset.left === 0) settings.axis = 'y'; + } - if (Adapt.get('_canScroll') !== false) { - // Trigger scrollTo plugin - $.scrollTo(selector, settings); - } + Adapt.scrolling = new Scrolling(); - // Trigger an event after animation - // 300 milliseconds added to make sure queue has finished - _.delay(() => { - Adapt.a11y.focusNext(selector); - Adapt.trigger(location + ':scrolledTo', selector); - }, settings.duration + 300); - }; + Adapt.scrollTo = Adapt.scrolling.scrollTo.bind(Adapt.scrolling); return Adapt.scrolling; diff --git a/src/core/js/views/adaptView.js b/src/core/js/views/adaptView.js index f2624154d..9d3a098eb 100644 --- a/src/core/js/views/adaptView.js +++ b/src/core/js/views/adaptView.js @@ -11,7 +11,6 @@ define([ } initialize() { - this.listenTo(Adapt, 'remove', this.remove); this.listenTo(this.model, { 'change:_isVisible': this.toggleVisibility, 'change:_isHidden': this.toggleHidden, @@ -40,21 +39,21 @@ define([ render() { const type = this.constructor.type; - Adapt.trigger(`${type}View:preRender`, this); + Adapt.trigger(`${type}View:preRender view:preRender`, this); const data = this.model.toJSON(); data.view = this; const template = Handlebars.templates[this.constructor.template]; this.$el.html(template(data)); - Adapt.trigger(`${type}View:render`, this); + Adapt.trigger(`${type}View:render view:render`, this); _.defer(() => { // don't call postRender after remove if (this._isRemoved) return; this.postRender(); - Adapt.trigger(`${type}View:postRender`, this); + Adapt.trigger(`${type}View:postRender view:postRender`, this); }); return this; @@ -67,15 +66,10 @@ define([ this.$el.addClass(`has-animation ${onscreen._classes}-before`); this.$el.on('onscreen.adaptView', (e, m) => { - if (!m.onscreen) return; - const minVerticalInview = onscreen._percentInviewVertical || 33; - if (m.percentInviewVertical < minVerticalInview) return; - this.$el.addClass(`${onscreen._classes}-after`).off('onscreen.adaptView'); - }); } @@ -139,20 +133,23 @@ define([ } } - preRemove() {} + preRemove() { + const type = this.constructor.type; + Adapt.trigger(`${type}View:preRemove view:preRemove`, this); + } remove() { - + const type = this.constructor.type; this.preRemove(); + Adapt.trigger(`${type}View:remove view:remove`, this); this._isRemoved = true; Adapt.wait.for(end => { - this.$el.off('onscreen.adaptView'); this.model.setOnChildren('_isReady', false); this.model.set('_isReady', false); - Backbone.View.prototype.remove.call(this); - + super.remove(); + Adapt.trigger(`${type}View:postRemove view:postRemove`, this); end(); }); diff --git a/src/core/js/views/contentObjectView.js b/src/core/js/views/contentObjectView.js index 094b6ef7e..3822c1446 100644 --- a/src/core/js/views/contentObjectView.js +++ b/src/core/js/views/contentObjectView.js @@ -34,74 +34,76 @@ define([ render() { const type = this.constructor.type; - Adapt.trigger(`${type}View:preRender`, this); + Adapt.trigger(`${type}View:preRender contentObjectView:preRender view:preRender`, this); const data = this.model.toJSON(); data.view = this; const template = Handlebars.templates[this.constructor.template]; this.$el.html(template(data)); - Adapt.trigger(`${type}View:render`, this); + Adapt.trigger(`${type}View:render contentObjectView:render view:render`, this); _.defer(() => { // don't call postRender after remove if (this._isRemoved) return; this.postRender(); - Adapt.trigger(`${type}View:postRender`, this); + Adapt.trigger(`${type}View:postRender contentObjectView:postRender view:postRender`, this); }); return this; } - isReady() { + async isReady() { if (!this.model.get('_isReady')) return; + const type = this.constructor.type; const performIsReady = () => { $('.js-loading').hide(); $(window).scrollTop(0); - const type = this.constructor.type; - Adapt.trigger(`${type}View:ready`, this); + Adapt.trigger(`${type}View:ready contentObjectView:ready view:ready`, this); $.inview.unlock(`${type}View`); const styleOptions = { opacity: 1 }; if (this.disableAnimation) { this.$el.css(styleOptions); $.inview(); + _.defer(() => { + Adapt.trigger(`${type}View:postReady contentObjectView:postReady view:postReady`, this); + }); } else { this.$el.velocity(styleOptions, { duration: 'fast', complete: () => { $.inview(); + Adapt.trigger(`${type}View:postReady contentObjectView:postReady view:postReady`, this); } }); } $(window).scroll(); }; - Adapt.wait.queue(() => { - _.defer(performIsReady); - }); + Adapt.trigger(`${type}View:preReady contentObjectView:preReady view:preReady`, this); + await Adapt.wait.queue(); + _.defer(performIsReady); } preRemove() { - Adapt.trigger(`${this.constructor.type}View:preRemove`, this); + const type = this.constructor.type; + Adapt.trigger(`${type}View:preRemove contentObjectView:preRemove view:preRemove`, this); } remove() { const type = this.constructor.type; this.preRemove(); - Adapt.trigger(`${type}View:remove`, this); + Adapt.trigger(`${type}View:remove contentObjectView:preRemove view:preRemove`, this); this._isRemoved = true; Adapt.wait.for(end => { - this.$el.off('onscreen.adaptView'); this.model.setOnChildren('_isReady', false); this.model.set('_isReady', false); super.remove(); - - Adapt.trigger(`${type}View:postRemove`, this); - + Adapt.trigger(`${type}View:postRemove contentObjectView:preRemove view:preRemove`, this); end(); }); diff --git a/src/core/js/wait.js b/src/core/js/wait.js index 8131366e2..85cda30e7 100644 --- a/src/core/js/wait.js +++ b/src/core/js/wait.js @@ -112,11 +112,19 @@ define(function() { /** * Queue this function until all open waits have been ended. * - * @param {Function} callback - * @return {Object} + * @param {Function} [callback] + * @return {Object|Promise} */ queue: function(callback) { + if (!callback) { + this.begin(); + return new Promise(resolve => { + this.once('ready', resolve); + this.end(); + }); + } + this.begin(); this.once('ready', callback); this.end(); From 8933d4e449cfe967bdfd0dc8169926067d3352ee Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 17:41:55 +0100 Subject: [PATCH 10/57] Fixed bugs added comments --- src/core/js/models/adaptModel.js | 13 ++++++++++++- src/core/js/router.js | 4 ++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/core/js/models/adaptModel.js b/src/core/js/models/adaptModel.js index b598db73b..9f76e6961 100644 --- a/src/core/js/models/adaptModel.js +++ b/src/core/js/models/adaptModel.js @@ -257,6 +257,10 @@ define([ const parent = this.getParent(); if (!parent) return; + /** + * TODO: + * look to remove hard coded model types + */ if (ancestorType === 'pages') { ancestorType = 'contentObjects'; } @@ -284,6 +288,10 @@ define([ descendants.slice(0, -1) ]; if (descendants === 'contentObjects') { + /** + * TODO: + * look to remove hard coded model types + */ types.push('page', 'menu'); } @@ -379,7 +387,10 @@ define([ * @return {array} */ findRelativeModel(relativeString, options) { - + /** + * TODO: + * look to remove hard coded model types + */ const types = [ 'menu', 'page', 'article', 'block', 'component' ]; options = options || {}; diff --git a/src/core/js/router.js b/src/core/js/router.js index 4dd1c0752..503d5b342 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -20,7 +20,7 @@ define([ initialize({ model }) { this.model = model; - this._navigationRoot = Adapt.course; + this._navigationRoot = null; // Flag to indicate if the router has tried to redirect to the current location. this._isCircularNavigationInProgress = false; this.showLoading(); @@ -32,7 +32,7 @@ define([ } get rootModel() { - return this._navigationRoot; + return this._navigationRoot || Adapt.course; } set rootModel(model) { From 0375144c5d828b420a8c31ad85d541352a324572 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 17:50:21 +0100 Subject: [PATCH 11/57] Added defer to postRemove events as expected --- src/core/js/router.js | 2 +- src/core/js/views/adaptView.js | 4 +++- src/core/js/views/contentObjectView.js | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/js/router.js b/src/core/js/router.js index 503d5b342..cf788ae1e 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -263,7 +263,7 @@ define([ } navigateBack() { - Backbone.history.history.back() + Backbone.history.history.back(); } navigateToCurrentRoute(force) { diff --git a/src/core/js/views/adaptView.js b/src/core/js/views/adaptView.js index 9d3a098eb..7e19b711a 100644 --- a/src/core/js/views/adaptView.js +++ b/src/core/js/views/adaptView.js @@ -149,7 +149,9 @@ define([ this.model.setOnChildren('_isReady', false); this.model.set('_isReady', false); super.remove(); - Adapt.trigger(`${type}View:postRemove view:postRemove`, this); + _.defer(() => { + Adapt.trigger(`${type}View:postRemove view:postRemove`, this); + }); end(); }); diff --git a/src/core/js/views/contentObjectView.js b/src/core/js/views/contentObjectView.js index 3822c1446..d86ede621 100644 --- a/src/core/js/views/contentObjectView.js +++ b/src/core/js/views/contentObjectView.js @@ -103,7 +103,9 @@ define([ this.model.setOnChildren('_isReady', false); this.model.set('_isReady', false); super.remove(); - Adapt.trigger(`${type}View:postRemove contentObjectView:preRemove view:preRemove`, this); + _.defer(() => { + Adapt.trigger(`${type}View:postRemove contentObjectView:preRemove view:preRemove`, this); + }); end(); }); From 4db8f124fbfdf2b9c5528932f26290037f071081 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 17:57:50 +0100 Subject: [PATCH 12/57] Removed bad condition --- src/core/js/router.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/js/router.js b/src/core/js/router.js index cf788ae1e..d429d0ac8 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -159,7 +159,7 @@ define([ } } - if (isContentObject && model.get('_isLocked') && Adapt.config.get('_forceRouteLocking')) { + if (model.get('_isLocked') && Adapt.config.get('_forceRouteLocking')) { // Locked id Adapt.log.warn('Unable to navigate to locked id: ' + id); this.model.set('_canNavigate', true, { pluginName: 'adapt' }); From 068d28d2a3cccacaee96bda75588bea5a3b3439d Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 18:02:10 +0100 Subject: [PATCH 13/57] Switched to cached value --- src/core/js/adapt.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index 5eefd378f..80a414e82 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -297,8 +297,8 @@ define([ this.trigger('preRemove', currentView); await this.wait.queue(); // Facilitate contentObject transitions - if (this.parentView && this.get('_shouldDestroyContentObjects')) { - this.parentView.destroy(); + if (currentView && this.get('_shouldDestroyContentObjects')) { + currentView.destroy(); } this.trigger('remove', currentView); _.defer(this.trigger.bind(this), 'postRemove', currentView); From 551d4583deaf16082d2aa89813a8713d98e4b131 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 2 Apr 2020 16:27:08 +0100 Subject: [PATCH 14/57] Recommendations Co-Authored-By: tomgreenfield --- src/core/js/router.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/js/router.js b/src/core/js/router.js index d429d0ac8..547035e06 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -51,8 +51,8 @@ define([ const currentModel = Adapt.location._currentModel; const hasSubTitle = (currentModel && currentModel !== Adapt.router.rootModel && currentModel.get('title')); const title = [ - this.rootModel.get('title') || null, - hasSubTitle ? currentModel.get('title') : null + this.rootModel.get('title'), + hasSubTitle && currentModel.get('title') ].filter(Boolean).join(' | '); this.listenToOnce(Adapt, 'contentObjectView:preRender', () => { const escapedTitle = $(`
${title}
`).text(); From 4911dbb4aff8bad0463662f8d359c6f7ae3b3849 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 2 Apr 2020 16:27:27 +0100 Subject: [PATCH 15/57] Recommendations Co-Authored-By: tomgreenfield --- src/core/js/router.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/js/router.js b/src/core/js/router.js index 547035e06..0da8ea325 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -119,8 +119,8 @@ define([ async handlePluginRouter(pluginName, location, action) { const pluginLocation = [ pluginName, - location ? `-${location}` : null, - action ? `-${action}` : null + location && `-${location}`, + action && `-${action}` ].filter(Boolean).join(''); await this.updateLocation(pluginLocation, null, null, null); From 749353fc4457c7783580ae96a5b415cb45273cfb Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 2 Apr 2020 16:30:56 +0100 Subject: [PATCH 16/57] Recommendations --- src/core/js/router.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/js/router.js b/src/core/js/router.js index 0da8ea325..deab47fc5 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -235,16 +235,16 @@ define([ setGlobalClasses() { const currentModel = Adapt.location._currentModel; + const htmlClasses = (currentModel && currentModel.get('_htmlClasses')) || ''; - const classes = (Adapt.location._currentId) ? `location-${Adapt.location._contentType} location-id-${Adapt.location._currentId}` : `location-${Adapt.location._currentLocation}`; + const currentClasses = `${classes} ${htmlClasses}`; this.$html .removeClass(Adapt.location._previousClasses) - .addClass(classes) - .addClass(htmlClasses) + .addClass(currentClasses) .attr('data-location', Adapt.location._currentLocation); this.$wrapper @@ -252,7 +252,7 @@ define([ .addClass(classes) .attr('data-location', Adapt.location._currentLocation); - Adapt.location._previousClasses = `${classes} ${htmlClasses}`; + Adapt.location._previousClasses = currentClasses; } handleNavigationFocus() { From 81cacf19472e216307f3b223eef768d3d045a804 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 2 Apr 2020 16:56:25 +0100 Subject: [PATCH 17/57] issue/2714 Decoupled menu, page, article, block and component --- src/core/js/adapt.js | 161 +++++++++++++++----- src/core/js/app.js | 1 + src/core/js/data.js | 85 +++++------ src/core/js/models/adaptModel.js | 174 ++++++++++++---------- src/core/js/models/articleModel.js | 16 +- src/core/js/models/blockModel.js | 16 +- src/core/js/models/componentModel.js | 15 ++ src/core/js/models/contentObjectModel.js | 11 ++ src/core/js/models/courseModel.js | 10 ++ src/core/js/models/itemsComponentModel.js | 8 + src/core/js/models/itemsQuestionModel.js | 8 + src/core/js/models/menuModel.js | 14 +- src/core/js/models/pageModel.js | 14 +- src/core/js/models/questionModel.js | 8 + src/core/js/mpabc.js | 9 ++ src/core/js/router.js | 9 +- src/core/js/views/adaptView.js | 3 +- src/core/js/views/articleView.js | 9 +- src/core/js/views/blockView.js | 5 +- src/core/js/views/pageView.js | 8 +- 20 files changed, 400 insertions(+), 184 deletions(-) create mode 100644 src/core/js/mpabc.js diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index 80a414e82..ebc64c6f2 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -8,7 +8,7 @@ define([ initialize() { this.loadScript = window.__loadScript; this.location = {}; - this.componentStore = {}; + this.store = {}; this.setupWait(); } @@ -28,6 +28,14 @@ define([ }; } + /** + * @deprecated since v6.0.0 - please use `Adapt.store` instead + */ + get componentStore() { + this.log && this.log.deprecated('Adapt.componentStore, please use Adapt.store instead'); + return this.store; + } + init() { this.addDirection(); this.disableAnimation(); @@ -148,39 +156,133 @@ define([ scrollTo() {} /** - * Used to register components with the Adapt 'component store' - * @param {string} name The name of the component to be registered + * Used to register models and views with `Adapt.store` + * @param {string|Array} name The name(s) of the model/view to be registered * @param {object} object Object containing properties `model` and `view` or (legacy) an object representing the view */ register(name, object) { - if (this.componentStore[name]) { - throw Error('The component "' + name + '" already exists in your project'); + if (name instanceof Array) { + // if an array is passed, iterate by recursive call + name.forEach(name => this.register(name, object)); + return object; + } else if (name.split(' ').length > 1) { + // if name with spaces is passed, split and pass as array + this.register(name.split(' '), object); + return object; } - if (object.view) { - // use view+model object - if (!object.view.template) object.view.template = name; - } else { - // use view object - if (!object.template) object.template = name; + if (!object.view && !object.model || object instanceof Backbone.View) { + this.log && this.log.deprecated('View-only registrations are no longer supported'); + object = { view: object }; } - this.componentStore[name] = object; + if (object.view && !object.view.template) { + object.view.template = name; + } + + const isModelSetAndInvalid = (object.model && + (!object.model.prototype instanceof Backbone.Model) && + !(object.model instanceof Function)); + if (isModelSetAndInvalid) { + throw new Error('The registered model is not a Backbone.Model or Function'); + } + + const isViewSetAndInvalid = (object.view && + (!object.view.prototype instanceof Backbone.View) && + !(object.view instanceof Function)); + if (isViewSetAndInvalid) { + throw new Error('The registered view is not a Backbone.View or Function'); + } + + this.store[name] = Object.assign({}, this.store[name], object); return object; } /** - * Fetches a component view class from the componentStore. For a usage example, see either HotGraphic or Narrative - * @param {string} name The name of the componentView you want to fetch e.g. `"hotgraphic"` - * @returns {ComponentView} Reference to the view class + * Parses a view class name. + * @param {string|Backbone.Model|Backbone.View|object} nameModelViewOrData The name of the view class you want to fetch e.g. `"hotgraphic"` or its model or its json data + */ + getViewName(nameModelViewOrData) { + if (typeof nameModelViewOrData === "string") { + return nameModelViewOrData; + } + if (nameModelViewOrData instanceof Backbone.Model) { + nameModelViewOrData = nameModelViewOrData.toJSON(); + } + if (nameModelViewOrData instanceof Backbone.View) { + let foundName = null; + _.find(this.store, (entry, name) => { + if (!entry || !entry.view) return; + if (!(nameModelViewOrData instanceof entry.view)) return; + foundName = name; + return true; + }); + return foundName; + } + if (nameModelViewOrData instanceof Object) { + return typeof nameModelViewOrData._view === 'string' && nameModelViewOrData._view || + typeof nameModelViewOrData._component === 'string' && nameModelViewOrData._component || + typeof nameModelViewOrData._type === 'string' && nameModelViewOrData._type; + } + throw new Error('Cannot derive view class name from input'); + } + + /** + * Fetches a view class from the store. For a usage example, see either HotGraphic or Narrative + * @param {string|Backbone.Model|Backbone.View|object} nameModelViewOrData The name of the view class you want to fetch e.g. `"hotgraphic"` or its model or its json data + * @returns {Backbone.View} Reference to the view class + */ + getViewClass(nameModelViewOrData) { + const name = this.getViewName(nameModelViewOrData); + const object = this.store[name]; + if (!object) { + this.log.warn(`A view for '${name}' isn't registered in your project`); + return; + } + const isBackboneView = (object.view && object.view.prototype instanceof Backbone.View); + if (!isBackboneView && object.view instanceof Function) { + return object.view(); + } + return object.view; + } + + /** + * Parses a model class name. + * @param {string|Backbone.Model|object} name The name of the model you want to fetch e.g. `"hotgraphic"`, the model to process or its json data + */ + getModelName(nameModelOrData) { + if (typeof nameModelOrData === "string") { + return nameModelOrData; + } + if (nameModelOrData instanceof Backbone.Model) { + nameModelOrData = nameModelOrData.toJSON(); + } + if (nameModelOrData instanceof Object) { + return typeof nameModelOrData._model === 'string' && nameModelOrData._model || + typeof nameModelOrData._component === 'string' && nameModelOrData._component || + typeof nameModelOrData._type === 'string' && nameModelOrData._type; + } + throw new Error('Cannot derive model class name from input'); + } + + /** + * Fetches a model class from the store. For a usage example, see either HotGraphic or Narrative + * @param {string|Backbone.Model|object} name The name of the model you want to fetch e.g. `"hotgraphic"` or its json data + * @returns {Backbone.Model} Reference to the view class */ - getViewClass(name) { - const object = this.componentStore[name]; + getModelClass(nameModelOrData) { + const name = this.getModelName(nameModelOrData); + const object = this.store[name]; if (!object) { - throw Error('The component "' + name + '" doesn\'t exist in your project'); + this.log.warn(`A model for '${name}' isn't registered in your project`); + return; + } + const isBackboneModel = (object.model && object.model.prototype instanceof Backbone.Model); + if (!isBackboneModel && object.model instanceof Function) { + return object.model(); } - return object.view || object; + return object.model; } /** @@ -242,28 +344,13 @@ define([ * Trickle uses this function to determine where it should scrollTo after it unlocks */ parseRelativeString(relativeString) { - if (relativeString[0] === '@') { - relativeString = relativeString.substr(1); - } - - let type = relativeString.match(/(component|block|article|page|menu)/); - if (!type) { - this.log.error('Adapt.parseRelativeString() could not match relative type', relativeString); - return; - } - type = type[0]; - - const offset = parseInt(relativeString.substr(type.length).trim() || 0); - if (isNaN(offset)) { - this.log.error('Adapt.parseRelativeString() could not parse relative offset', relativeString); - return; - } - + const splitIndex = relativeString.search(/[ \+\-\d]{1}/); + const type = relativeString.slice(0, splitIndex).replace(/^\@/, ''); + const offset = parseInt(relativeString.slice(splitIndex).trim() || 0); return { type: type, offset: offset }; - } addDirection() { diff --git a/src/core/js/app.js b/src/core/js/app.js index 88496ac2b..c918ec963 100644 --- a/src/core/js/app.js +++ b/src/core/js/app.js @@ -10,6 +10,7 @@ require([ 'core/js/notify', 'core/js/router', 'core/js/models/lockingModel', + 'core/js/mpabc', 'core/js/helpers', 'core/js/scrolling', 'core/js/headings', diff --git a/src/core/js/data.js b/src/core/js/data.js index 818ce110f..d2e2db290 100644 --- a/src/core/js/data.js +++ b/src/core/js/data.js @@ -1,18 +1,12 @@ define([ 'core/js/adapt', 'core/js/collections/adaptCollection', - 'core/js/models/articleModel', - 'core/js/models/blockModel', 'core/js/models/configModel', - 'core/js/models/menuModel', - 'core/js/models/pageModel', - 'core/js/models/componentModel', 'core/js/models/courseModel', - 'core/js/models/questionModel', 'core/js/models/lockingModel', 'core/js/models/buildModel', 'core/js/startController' -], function(Adapt, AdaptCollection, ArticleModel, BlockModel, ConfigModel, MenuModel, PageModel, ComponentModel, CourseModel, QuestionModel) { +], function(Adapt, AdaptCollection, ConfigModel, CourseModel) { class Data extends Backbone.Controller { @@ -69,60 +63,47 @@ define([ // All code that needs to run before adapt starts should go here const language = Adapt.config.get('_activeLanguage'); const jsonext = Adapt.build.get('jsonext'); - const courseFolder = 'course/' + language + '/'; $('html').attr('lang', language); - Adapt.course = new CourseModel(null, { url: courseFolder + 'course.' + jsonext, reset: true }); + const getContentObjectModel = json => { + const ModelClass = Adapt.getModelClass(json) || Adapt.getModelClass('menu'); + if (!ModelClass) { + throw new Error(`Cannot find model for: ${Adapt.getModelName(json)}`); + } + return new ModelClass(json); + }; + + const getPath = name => `course/${language}/${name}.${jsonext}`; + + const getModel = json => { + const ModelClass = Adapt.getModelClass(json); + if (!ModelClass) { + throw new Error(`Cannot find model for: ${Adapt.getModelName(json)}`); + } + return new ModelClass(json); + }; + + Adapt.course = new CourseModel(null, { url: getPath('course'), reset: true }); Adapt.contentObjects = new AdaptCollection(null, { - model: json => { - switch (json._type) { - case 'page': - return new PageModel(json); - case 'menu': - return new MenuModel(json); - } - }, - url: courseFolder + 'contentObjects.' + jsonext + model: getContentObjectModel, + url: getPath('contentObjects') }); Adapt.articles = new AdaptCollection(null, { - model: ArticleModel, - url: courseFolder + 'articles.' + jsonext + model: getModel, + url: getPath('articles') }); Adapt.blocks = new AdaptCollection(null, { - model: BlockModel, - url: courseFolder + 'blocks.' + jsonext + model: getModel, + url: getPath('blocks') }); Adapt.components = new AdaptCollection(null, { - model: json => { - - // use view+model object - const ViewModelObject = Adapt.componentStore[json._component]; - - if (!ViewModelObject) { - throw new Error('One or more components of type "' + json._component + '" were included in the course - but no component of that type is installed...'); - } - - // if model defined for component use component model - if (ViewModelObject.model) { - // eslint-disable-next-line new-cap - return new ViewModelObject.model(json); - } - - const View = ViewModelObject.view || ViewModelObject; - // if question type use question model - if (View._isQuestionType) { - return new QuestionModel(json); - } - - // otherwise use component model - return new ComponentModel(json); - }, - url: courseFolder + 'components.' + jsonext + model: getModel, + url: getPath('components') }); } @@ -251,6 +232,16 @@ define([ return Adapt[collectionType]._byAdaptID[id][0]; } + filter(filter) { + const result = []; + filter(Adapt.course) && result.push(Adapt.course); + result.push(...Adapt.contentObjects.filter(filter)); + result.push(...Adapt.articles.filter(filter)); + result.push(...Adapt.blocks.filter(filter)); + result.push(...Adapt.components.filter(filter)); + return result; + } + } return (Adapt.data = new Data()); diff --git a/src/core/js/models/adaptModel.js b/src/core/js/models/adaptModel.js index 9f76e6961..c70c3e48e 100644 --- a/src/core/js/models/adaptModel.js +++ b/src/core/js/models/adaptModel.js @@ -49,14 +49,14 @@ define([ } setupModel() { - if (this._children) { + if (this.hasManagedChildren) { this.setupChildListeners(); } this.init(); _.defer(() => { - if (this._children) { + if (this.hasManagedChildren) { this.checkCompletionStatus(); this.checkInteractionCompletionStatus(); @@ -247,6 +247,52 @@ define([ Adapt.checkedCompletion(); } + /** + * Returns a string describing the type group of this model. + * Strings should be lowercase and not plurlaized. + * i.e. 'page', 'menu', 'contentobject', 'component', 'article', 'block' + * Override in inheritance chain. + * @returns {string} + */ + getTypeGroup() {} + + /** + * Returns true if this model is of the type group described. + * Automatically manages pluralization typeGroup and matches lowercase only. + * Pluralized typeGroups is discouraged. + * @param {string} type Type group name i.e. course, contentobject, article, block, component + * @returns {boolean} + */ + isTypeGroup(typeGroup) { + const pluralizedLowerCaseTypes = [ + typeGroup, + (typeGroup.slice(-1) === 's') && typeGroup.slice(0, -1), // remove pluralization if ending in s + (typeGroup.slice(-1) !== 's') && `${typeGroup}s` // pluralize if not ending in s + ].filter(Boolean).map(s => s.toLowerCase()); + const typeGroups = this.getTypeGroups(); + if (_.intersection(pluralizedLowerCaseTypes, typeGroups).length) { + return true; + } + return false; + } + + /** + * Returns an array of strings describing the model type groups. + * All strings are lowercase and should not be pluralized. + * i.e. ['course', 'menu', 'contentobject'], ['page', 'contentobject'], ['component'] + * @returns {[string]} + */ + getTypeGroups() { + if (this._typeGroups) return this._typeGroups; + const typeGroups = [ this.get('_type') ]; + let parentClass = this; + while (parentClass = Object.getPrototypeOf(parentClass)) { + if (!parentClass.hasOwnProperty('getTypeGroup')) continue; + typeGroups.push( parentClass.getTypeGroup.call(this) ); + } + return (this._typeGroups = _.uniq(typeGroups.filter(Boolean).map(s => s.toLowerCase()))); + } + /** * Searches the model's ancestors to find the first instance of the specified ancestor type * @param {string} [ancestorType] Valid values are 'course', 'pages', 'contentObjects', 'articles' or 'blocks'. @@ -256,19 +302,9 @@ define([ findAncestor(ancestorType) { const parent = this.getParent(); if (!parent) return; - - /** - * TODO: - * look to remove hard coded model types - */ - if (ancestorType === 'pages') { - ancestorType = 'contentObjects'; - } - - if (!ancestorType || this._parent === ancestorType) { + if (!ancestorType || parent.isTypeGroup(ancestorType)) { return parent; } - return parent.findAncestor(ancestorType); } @@ -283,21 +319,9 @@ define([ * this.findDescendantModels('components', { where: { _isAvailable: true, _isOptional: false }}); */ findDescendantModels(descendants, options) { - - const types = [ - descendants.slice(0, -1) - ]; - if (descendants === 'contentObjects') { - /** - * TODO: - * look to remove hard coded model types - */ - types.push('page', 'menu'); - } - const allDescendantsModels = this.getAllDescendantModels(); const returnedDescendants = allDescendantsModels.filter(model => { - return types.includes(model.get('_type')); + return model.isTypeGroup(descendants); }); if (!options) { @@ -337,8 +361,7 @@ define([ const descendants = []; - if (this.get('_type') === 'component') { - descendants.push(this); + if (!this.hasManagedChildren) { return descendants; } @@ -346,11 +369,9 @@ define([ children.models.forEach(child => { - if (child.get('_type') === 'component') { - + if (!child.hasManagedChildren) { descendants.push(child); return; - } const subDescendants = child.getAllDescendantModels(isParentFirst); @@ -386,44 +407,29 @@ define([ * @param {boolean} options.loop * @return {array} */ - findRelativeModel(relativeString, options) { - /** - * TODO: - * look to remove hard coded model types - */ - const types = [ 'menu', 'page', 'article', 'block', 'component' ]; - - options = options || {}; - - const modelId = this.get('_id'); - const modelType = this.get('_type'); - + findRelativeModel(relativeString, options = {}) { // return a model relative to the specified one if opinionated - let rootModel = Adapt.course; - if (options.limitParentId) { - rootModel = Adapt.findById(options.limitParentId); - } + const rootModel = options.limitParentId ? + Adapt.findById(options.limitParentId) : + Adapt.course; const relativeDescriptor = Adapt.parseRelativeString(relativeString); - - const findAncestorType = (types.indexOf(modelType) > types.indexOf(relativeDescriptor.type)); - const findSiblingType = (modelType === relativeDescriptor.type); - const searchBackwards = (relativeDescriptor.offset < 0); let moveBy = Math.abs(relativeDescriptor.offset); let movementCount = 0; - const findDescendantType = (!findSiblingType && !findAncestorType); - - if (findDescendantType) { + const hasDescendantsOfType = Boolean(this.findDescendantModels(relativeDescriptor.type).length); + if (hasDescendantsOfType) { // move by one less as first found is considered next + // will find descendants on either side but not inside moveBy--; } let pageDescendants; if (searchBackwards) { // parents first [p1,a1,b1,c1,c2,a2,b2,c3,c4,p2,a3,b3,c6,c7,a4,b4,c8,c9] - pageDescendants = rootModel.getAllDescendantModels(true); + pageDescendants = [rootModel]; + pageDescendants.push(...rootModel.getAllDescendantModels(true)); // reverse so that we don't need a forward and a backward iterating loop // reversed [c9,c8,b4,a4,c7,c6,b3,a3,p2,c4,c3,b2,a2,c2,c1,b1,a1,p1] @@ -431,6 +437,7 @@ define([ } else { // children first [c1,c2,b1,a1,c3,c4,b2,a2,p1,c6,c7,b3,a3,c8,c9,b4,a4,p2] pageDescendants = rootModel.getAllDescendantModels(false); + pageDescendants.push(rootModel); } // filter if opinionated @@ -439,6 +446,7 @@ define([ } // find current index in array + const modelId = this.get('_id'); const modelIndex = pageDescendants.findIndex(pageDescendant => { if (pageDescendant.get('_id') === modelId) { return true; @@ -447,24 +455,25 @@ define([ }); if (options.loop) { - // normalize offset position to allow for overflow looping - const typeCounts = {}; - pageDescendants.forEach(model => { - const type = model.get('_type'); - typeCounts[type] = typeCounts[type] || 0; - typeCounts[type]++; - }); - moveBy = moveBy % typeCounts[relativeDescriptor.type]; - + const totalOfType = pageDescendants.reduce((count, model) => { + if (!model.isTypeGroup(relativeDescriptor.type)) return count; + return ++count; + }, 0); + // take the remainder after removing whole units of the type count + moveBy = moveBy % totalOfType; // double up entries to allow for overflow looping pageDescendants = pageDescendants.concat(pageDescendants.slice(0)); - } for (let i = modelIndex, l = pageDescendants.length; i < l; i++) { const descendant = pageDescendants[i]; - if (descendant.get('_type') === relativeDescriptor.type) { + if (descendant.isTypeGroup(relativeDescriptor.type)) { + if (movementCount > moveBy) { + // there is no descendant which matches this relativeString + // probably looking for the descendant 0 in a parent + break; + } if (movementCount === moveBy) { return Adapt.findById(descendant.get('_id')); } @@ -472,7 +481,10 @@ define([ } } - return undefined; + } + + get hasManagedChildren() { + return true; } getChildren() { @@ -480,10 +492,11 @@ define([ let childrenCollection; - if (!this._children) { + if (!this.hasManagedChildren) { childrenCollection = new Backbone.Collection(); } else { - const children = Adapt[this._children].where({ _parentId: this.get('_id') }); + const id = this.get('_id'); + const children = Adapt.data.filter(model => model.get('_parentId') === id); childrenCollection = new Backbone.Collection(children); } @@ -510,13 +523,10 @@ define([ getParent() { if (this.get('_parent')) return this.get('_parent'); - if (this._parent === 'course') { - return Adapt.course; - } - const parent = Adapt.findById(this.get('_parentId')); + const parentId = this.get('_parentId'); + if (!parentId) return; + const parent = Adapt.findById(parentId); this.set('_parent', parent); - - // returns a parent model return parent; } @@ -535,15 +545,17 @@ define([ } getSiblings(passSiblingsAndIncludeSelf) { + const id = this.get('_id'); + const parentId = this.get('_parentId'); let siblings; if (!passSiblingsAndIncludeSelf) { // returns a collection of siblings excluding self if (this._hasSiblingsAndSelf === false) { return this.get('_siblings'); } - siblings = Adapt[this._siblings].filter(model => { - return model.get('_parentId') === this.get('_parentId') && - model.get('_id') !== this.get('_id'); + siblings = Adapt.data.filter(model => { + return model.get('_parentId') === parentId && + model.get('_id') !== id; }); this._hasSiblingsAndSelf = false; @@ -554,8 +566,8 @@ define([ return this.get('_siblings'); } - siblings = Adapt[this._siblings].where({ - _parentId: this.get('_parentId') + siblings = Adapt.data.filter(model => { + return model.get('_parentId') === parentId; }); this._hasSiblingsAndSelf = true; } @@ -574,7 +586,7 @@ define([ this.set(...args); - if (!this._children) return; + if (!this.hasManagedChildren) return; const children = this.getChildren(); children.models.forEach(child => child.setOnChildren(...args)); diff --git a/src/core/js/models/articleModel.js b/src/core/js/models/articleModel.js index 9a657a3ae..c7240d9d1 100644 --- a/src/core/js/models/articleModel.js +++ b/src/core/js/models/articleModel.js @@ -1,23 +1,37 @@ define([ + 'core/js/adapt', 'core/js/models/adaptModel' -], function (AdaptModel) { +], function (Adapt, AdaptModel) { class ArticleModel extends AdaptModel { get _parent() { + Adapt.log.deprecated('articleModel._parent, use articleModel.getParent() instead, parent models are defined by the JSON'); return 'contentObjects'; } get _siblings() { + Adapt.log.deprecated('articleModel._siblings, use articleModel.getSiblings() instead, sibling models are defined by the JSON'); return 'articles'; } get _children() { + Adapt.log.deprecated('articleModel._children, use articleModel.hasManagedChildren instead, child models are defined by the JSON'); return 'blocks'; } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'article'; + } + } + Adapt.register('article', { model: ArticleModel }); + return ArticleModel; }); diff --git a/src/core/js/models/blockModel.js b/src/core/js/models/blockModel.js index 5006f6589..fe8b5932c 100644 --- a/src/core/js/models/blockModel.js +++ b/src/core/js/models/blockModel.js @@ -1,23 +1,37 @@ define([ + 'core/js/adapt', 'core/js/models/adaptModel' -], function (AdaptModel) { +], function (Adapt, AdaptModel) { class BlockModel extends AdaptModel { get _parent() { + Adapt.log.deprecated('blockModel._parent, use blockModel.getParent() instead, parent models are defined by the JSON'); return 'articles'; } get _siblings() { + Adapt.log.deprecated('blockModel._siblings, use blockModel.getSiblings() instead, sibling models are defined by the JSON'); return 'blocks'; } get _children() { + Adapt.log.deprecated('blockModel._children, use blockModel.hasManagedChildren instead, child models are defined by the JSON'); return 'components'; } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'block'; + } + } + Adapt.register('block', { model: BlockModel }); + return BlockModel; }); diff --git a/src/core/js/models/componentModel.js b/src/core/js/models/componentModel.js index a581bbb56..452ee411f 100644 --- a/src/core/js/models/componentModel.js +++ b/src/core/js/models/componentModel.js @@ -5,13 +5,23 @@ define([ class ComponentModel extends AdaptModel { get _parent() { + Adapt.log.deprecated('componentModel._parent, use componentModel.getParent() instead, parent models are defined by the JSON'); return 'blocks'; } get _siblings() { + Adapt.log.deprecated('componentModel._siblings, use componentModel.getSiblings() instead, sibling models are defined by the JSON'); return 'components'; } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'component'; + } + defaults() { return AdaptModel.resultExtend('defaults', { _isA11yComponentDescriptionEnabled: true @@ -23,6 +33,11 @@ define([ '_userAnswer' ]); } + + get hasManagedChildren() { + return false; + } + } return ComponentModel; diff --git a/src/core/js/models/contentObjectModel.js b/src/core/js/models/contentObjectModel.js index 81f9a0125..1e9d48733 100644 --- a/src/core/js/models/contentObjectModel.js +++ b/src/core/js/models/contentObjectModel.js @@ -6,6 +6,7 @@ define([ class ContentObjectModel extends AdaptModel { get _parent() { + Adapt.log.deprecated('contentObjectModel._parent, use contentObjectModel.getParent() instead, parent models are defined by the JSON'); const isParentCourse = (this.get('_parentId') === Adapt.course.get('_id')); if (isParentCourse) { return 'course'; @@ -14,13 +15,23 @@ define([ } get _siblings() { + Adapt.log.deprecated('contentObjectModel._siblings, use contentObjectModel.getSiblings() instead, sibling models are defined by the JSON'); return 'contentObjects'; } get _children() { + Adapt.log.deprecated('contentObjectModel._children, use contentObjectModel.hasManagedChildren instead, child models are defined by the JSON'); return null; } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'contentobject'; + } + } return ContentObjectModel; diff --git a/src/core/js/models/courseModel.js b/src/core/js/models/courseModel.js index 6db0459b4..6065a40b5 100644 --- a/src/core/js/models/courseModel.js +++ b/src/core/js/models/courseModel.js @@ -6,13 +6,23 @@ define([ class CourseModel extends MenuModel { get _parent() { + Adapt.log.deprecated('courseModel._parent, use courseModel.getParent() instead, parent models are defined by the JSON'); return null; } get _siblings() { + Adapt.log.deprecated('courseModel._siblings, use courseModel.getSiblings() instead, sibling models are defined by the JSON'); return null; } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'course'; + } + initialize(attrs, options) { super.initialize(arguments); Adapt.trigger('courseModel:dataLoading'); diff --git a/src/core/js/models/itemsComponentModel.js b/src/core/js/models/itemsComponentModel.js index d18bd2c03..345411bcc 100644 --- a/src/core/js/models/itemsComponentModel.js +++ b/src/core/js/models/itemsComponentModel.js @@ -19,6 +19,14 @@ define([ }); } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'itemscomponent'; + } + setUpItems() { // see https://github.com/adaptlearning/adapt_framework/issues/2480 const items = this.get('_items') || []; diff --git a/src/core/js/models/itemsQuestionModel.js b/src/core/js/models/itemsQuestionModel.js index 789959a78..929dac6b0 100644 --- a/src/core/js/models/itemsQuestionModel.js +++ b/src/core/js/models/itemsQuestionModel.js @@ -32,6 +32,14 @@ define([ this.set('_isRadio', this.isSingleSelect()); } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'itemsquestion'; + } + restoreUserAnswers() { if (!this.get('_isSubmitted')) return; diff --git a/src/core/js/models/menuModel.js b/src/core/js/models/menuModel.js index 756b960de..2ee5edfa2 100644 --- a/src/core/js/models/menuModel.js +++ b/src/core/js/models/menuModel.js @@ -1,13 +1,23 @@ define([ + 'core/js/adapt', 'core/js/models/contentObjectModel' -], function (ContentObjectModel) { +], function (Adapt, ContentObjectModel) { class MenuModel extends ContentObjectModel { get _children() { + Adapt.log.deprecated('menuModel._children, use menuModel.hasManagedChildren instead, child models are defined by the JSON'); return 'contentObjects'; } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'menu'; + } + setCustomLocking() { const children = this.getAvailableChildModels(); children.forEach(child => { @@ -19,6 +29,8 @@ define([ } + Adapt.register('menu', { model: MenuModel }); + return MenuModel; }); diff --git a/src/core/js/models/pageModel.js b/src/core/js/models/pageModel.js index d19f7dc7a..1daa2f13c 100644 --- a/src/core/js/models/pageModel.js +++ b/src/core/js/models/pageModel.js @@ -1,15 +1,27 @@ define([ + 'core/js/adapt', 'core/js/models/contentObjectModel' -], function (ContentObjectModel) { +], function (Adapt, ContentObjectModel) { class PageModel extends ContentObjectModel { get _children() { + Adapt.log.deprecated('pageModel._children, use menuModel.hasManagedChildren instead, child models are defined by the JSON'); return 'articles'; } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'page'; + } + } + Adapt.register('page', { model: PageModel }); + return PageModel; }); diff --git a/src/core/js/models/questionModel.js b/src/core/js/models/questionModel.js index f8988a8db..ef098874e 100644 --- a/src/core/js/models/questionModel.js +++ b/src/core/js/models/questionModel.js @@ -35,6 +35,14 @@ define([ ]); } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'question'; + } + init() { this.setupDefaultSettings(); this.listenToOnce(Adapt, 'adapt:initialize', this.onAdaptInitialize); diff --git a/src/core/js/mpabc.js b/src/core/js/mpabc.js new file mode 100644 index 000000000..cadd7b0ea --- /dev/null +++ b/src/core/js/mpabc.js @@ -0,0 +1,9 @@ +define([ + 'core/js/models/menuModel', + 'core/js/models/pageModel', + 'core/js/models/articleModel', + 'core/js/models/blockModel', + 'core/js/views/pageView', + 'core/js/views/articleView', + 'core/js/views/blockView' +], function(menuModel, PageModel, ArticleModel, BlockModel, PageView, ArticleView, BlockView) {}); diff --git a/src/core/js/router.js b/src/core/js/router.js index deab47fc5..e0e61a7fe 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -4,9 +4,8 @@ define([ 'core/js/models/courseModel', 'core/js/models/contentObjectModel', 'core/js/models/menuModel', - 'core/js/views/pageView', 'core/js/startController' -], function(Adapt, RouterModel, CourseModel, ContentObjectModel, MenuModel, PageView) { +], function(Adapt, RouterModel, CourseModel, ContentObjectModel, MenuModel) { class Router extends Backbone.Router { @@ -193,11 +192,13 @@ define([ }); Adapt.trigger(`router:${type} router:contentObject`, model); + const ViewClass = Adapt.getViewClass(model); const isMenu = (model instanceof MenuModel); - if (isMenu) { + if (!ViewClass && isMenu) { + Adapt.log.deprecated(`Using event based menu view instantiation for '${Adapt.getViewName(model)}'`); return; } - this.$wrapper.append(new PageView({ model }).$el); + this.$wrapper.append(new ViewClass({ model }).$el); } async updateLocation(currentLocation, type, id, currentModel) { diff --git a/src/core/js/views/adaptView.js b/src/core/js/views/adaptView.js index 7e19b711a..0185fcf48 100644 --- a/src/core/js/views/adaptView.js +++ b/src/core/js/views/adaptView.js @@ -83,8 +83,7 @@ define([ nthChild++; model.set('_nthChild', nthChild); - const ViewModelObject = this.constructor.childView || Adapt.componentStore[model.get('_component')]; - const ChildView = ViewModelObject.view || ViewModelObject; + const ChildView = this.constructor.childView || Adapt.getViewClass(model); if (!ChildView) { throw new Error(`The component '${model.attributes._id}' ('${model.attributes._component}') has not been installed, and so is not available in your project.`); diff --git a/src/core/js/views/articleView.js b/src/core/js/views/articleView.js index 25b176d5f..d38c0a654 100644 --- a/src/core/js/views/articleView.js +++ b/src/core/js/views/articleView.js @@ -1,7 +1,7 @@ define([ - 'core/js/views/adaptView', - 'core/js/views/blockView' -], function(AdaptView, BlockView) { + 'core/js/adapt', + 'core/js/views/adaptView' +], function(Adapt, AdaptView) { class ArticleView extends AdaptView { @@ -21,11 +21,12 @@ define([ Object.assign(ArticleView, { childContainer: '.block__container', - childView: BlockView, type: 'article', template: 'article' }); + Adapt.register('article', { view: ArticleView }); + return ArticleView; }); diff --git a/src/core/js/views/blockView.js b/src/core/js/views/blockView.js index 0c367f9b4..a9bfcfa2a 100644 --- a/src/core/js/views/blockView.js +++ b/src/core/js/views/blockView.js @@ -1,6 +1,7 @@ define([ + 'core/js/adapt', 'core/js/views/adaptView' -], function(AdaptView) { +], function(Adapt, AdaptView) { class BlockView extends AdaptView { @@ -24,6 +25,8 @@ define([ template: 'block' }); + Adapt.register('block', { view: BlockView }); + return BlockView; }); diff --git a/src/core/js/views/pageView.js b/src/core/js/views/pageView.js index 74dbba011..cd4c5a12b 100644 --- a/src/core/js/views/pageView.js +++ b/src/core/js/views/pageView.js @@ -1,8 +1,7 @@ define([ 'core/js/adapt', - 'core/js/views/contentObjectView', - 'core/js/views/articleView' -], function(Adapt, ContentObjectView, ArticleView) { + 'core/js/views/contentObjectView' +], function(Adapt, ContentObjectView) { class PageView extends ContentObjectView { @@ -17,11 +16,12 @@ define([ Object.assign(PageView, { childContainer: '.article__container', - childView: ArticleView, type: 'page', template: 'page' }); + Adapt.register('page', { view: PageView }); + return PageView; }); From 64274b71863453f456f9c76f0bea228322ddf76b Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 2 Apr 2020 17:25:19 +0100 Subject: [PATCH 18/57] Typo --- src/core/js/mpabc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/js/mpabc.js b/src/core/js/mpabc.js index cadd7b0ea..cbb2aa09a 100644 --- a/src/core/js/mpabc.js +++ b/src/core/js/mpabc.js @@ -6,4 +6,4 @@ define([ 'core/js/views/pageView', 'core/js/views/articleView', 'core/js/views/blockView' -], function(menuModel, PageModel, ArticleModel, BlockModel, PageView, ArticleView, BlockView) {}); +], function(MenuModel, PageModel, ArticleModel, BlockModel, PageView, ArticleView, BlockView) {}); From 2de000fefad74aaacffa29a11bf6109a7194a537 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Sat, 4 Apr 2020 15:22:35 +0100 Subject: [PATCH 19/57] Recommendations --- src/core/js/adapt.js | 36 +++++++++++++++++++++++--------- src/core/js/data.js | 5 +++++ src/core/js/models/adaptModel.js | 22 ++++++++++++++----- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index ebc64c6f2..42f036aad 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -165,7 +165,9 @@ define([ // if an array is passed, iterate by recursive call name.forEach(name => this.register(name, object)); return object; - } else if (name.split(' ').length > 1) { + } + + if (name.split(' ').length > 1) { // if name with spaces is passed, split and pass as array this.register(name.split(' '), object); return object; @@ -181,14 +183,14 @@ define([ } const isModelSetAndInvalid = (object.model && - (!object.model.prototype instanceof Backbone.Model) && + !(object.model.prototype instanceof Backbone.Model) && !(object.model instanceof Function)); if (isModelSetAndInvalid) { throw new Error('The registered model is not a Backbone.Model or Function'); } const isViewSetAndInvalid = (object.view && - (!object.view.prototype instanceof Backbone.View) && + !(object.view.prototype instanceof Backbone.View) && !(object.view instanceof Function)); if (isViewSetAndInvalid) { throw new Error('The registered view is not a Backbone.View or Function'); @@ -211,7 +213,7 @@ define([ nameModelViewOrData = nameModelViewOrData.toJSON(); } if (nameModelViewOrData instanceof Backbone.View) { - let foundName = null; + let foundName; _.find(this.store, (entry, name) => { if (!entry || !entry.view) return; if (!(nameModelViewOrData instanceof entry.view)) return; @@ -221,9 +223,16 @@ define([ return foundName; } if (nameModelViewOrData instanceof Object) { - return typeof nameModelViewOrData._view === 'string' && nameModelViewOrData._view || - typeof nameModelViewOrData._component === 'string' && nameModelViewOrData._component || - typeof nameModelViewOrData._type === 'string' && nameModelViewOrData._type; + const names = [ + typeof nameModelViewOrData._view === 'string' && nameModelViewOrData._view, + typeof nameModelViewOrData._component === 'string' && nameModelViewOrData._component, + typeof nameModelViewOrData._type === 'string' && nameModelViewOrData._type + ].filter(Boolean); + if (names.length) { + // find first fitting view name + const name = names.find(name => this.store[name] && this.store[name].view); + return name; + } } throw new Error('Cannot derive view class name from input'); } @@ -259,9 +268,16 @@ define([ nameModelOrData = nameModelOrData.toJSON(); } if (nameModelOrData instanceof Object) { - return typeof nameModelOrData._model === 'string' && nameModelOrData._model || - typeof nameModelOrData._component === 'string' && nameModelOrData._component || - typeof nameModelOrData._type === 'string' && nameModelOrData._type; + const names = [ + typeof nameModelOrData._model === 'string' && nameModelOrData._model, + typeof nameModelOrData._component === 'string' && nameModelOrData._component, + typeof nameModelOrData._type === 'string' && nameModelOrData._type + ].filter(Boolean); + if (names.length) { + // find first fitting model name + const name = names.find(name => this.store[name] && this.store[name].model); + return name; + } } throw new Error('Cannot derive model class name from input'); } diff --git a/src/core/js/data.js b/src/core/js/data.js index d2e2db290..f87d32ffd 100644 --- a/src/core/js/data.js +++ b/src/core/js/data.js @@ -232,6 +232,11 @@ define([ return Adapt[collectionType]._byAdaptID[id][0]; } + /** + * Filter all models. + * @param {Function} filter + * @returns {Array} + */ filter(filter) { const result = []; filter(Adapt.course) && result.push(Adapt.course); diff --git a/src/core/js/models/adaptModel.js b/src/core/js/models/adaptModel.js index c70c3e48e..ffdac12ec 100644 --- a/src/core/js/models/adaptModel.js +++ b/src/core/js/models/adaptModel.js @@ -259,16 +259,28 @@ define([ /** * Returns true if this model is of the type group described. * Automatically manages pluralization typeGroup and matches lowercase only. - * Pluralized typeGroups is discouraged. + * Pluralized typeGroups and uppercase characters in typeGroups are discouraged. * @param {string} type Type group name i.e. course, contentobject, article, block, component * @returns {boolean} */ isTypeGroup(typeGroup) { + const hasUpperCase = /[A-Z]+/.test(typeGroup); + const isPluralized = typeGroup.slice(-1) === 's'; + const lowerCased = typeGroup.toLowerCase(); + const singular = isPluralized && lowerCased.slice(0, -1); // remove pluralization if ending in s + const singularLowerCased = (singular || lowerCased).toLowerCase(); + if (isPluralized || hasUpperCase) { + const message = (isPluralized && hasUpperCase) ? + `'${typeGroup}' appears pluralized and contains uppercase characters, suggest using the singular, lowercase type group '${singularLowerCased}'.` : + isPluralized ? + `'${typeGroup}' appears pluralized, suggest using the singular type group '${singularLowerCased}'.` : + `'${typeGroup}' contains uppercase characters, suggest using lowercase type group '${singularLowerCased}'.`; + Adapt.log.deprecated(message); + } const pluralizedLowerCaseTypes = [ - typeGroup, - (typeGroup.slice(-1) === 's') && typeGroup.slice(0, -1), // remove pluralization if ending in s - (typeGroup.slice(-1) !== 's') && `${typeGroup}s` // pluralize if not ending in s - ].filter(Boolean).map(s => s.toLowerCase()); + singularLowerCased, + !isPluralized && `${lowerCased}s` // pluralize if not ending in s + ].filter(Boolean); const typeGroups = this.getTypeGroups(); if (_.intersection(pluralizedLowerCaseTypes, typeGroups).length) { return true; From 1942b1a5d829690ef6d7d5ccda98c1fd132b912a Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Mon, 6 Apr 2020 09:02:20 +0100 Subject: [PATCH 20/57] issue/2714: Added Adapt.log.warnOnce --- src/core/js/adapt.js | 4 ++-- src/core/js/logging.js | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index 42f036aad..ea6169018 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -246,7 +246,7 @@ define([ const name = this.getViewName(nameModelViewOrData); const object = this.store[name]; if (!object) { - this.log.warn(`A view for '${name}' isn't registered in your project`); + this.log.warnOnce(`A view for '${name}' isn't registered in your project`); return; } const isBackboneView = (object.view && object.view.prototype instanceof Backbone.View); @@ -291,7 +291,7 @@ define([ const name = this.getModelName(nameModelOrData); const object = this.store[name]; if (!object) { - this.log.warn(`A model for '${name}' isn't registered in your project`); + this.log.warnOnce(`A model for '${name}' isn't registered in your project`); return; } const isBackboneModel = (object.model && object.model.prototype instanceof Backbone.Model); diff --git a/src/core/js/logging.js b/src/core/js/logging.js index 63afa4f86..7a27113db 100644 --- a/src/core/js/logging.js +++ b/src/core/js/logging.js @@ -72,14 +72,15 @@ define([ removed(...args) { args = ['REMOVED'].concat(args); - if (this._hasWarned(args)) { - return; - } - this._log(LOG_LEVEL.WARN, args); + this.warnOnce(...args); } deprecated(...args) { args = ['DEPRECATED'].concat(args); + this.warnOnce(...args); + } + + warnOnce(...args) { if (this._hasWarned(args)) { return; } From 347f5209852a54bcfdf8d53bc2c184122e41781e Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Mon, 6 Apr 2020 09:11:05 +0100 Subject: [PATCH 21/57] Linting fixes --- src/core/js/adapt.js | 14 +++++++------- src/core/js/models/adaptModel.js | 10 +++++----- src/core/js/models/componentModel.js | 3 ++- src/core/js/router.js | 2 +- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index ea6169018..c42468a6c 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -32,8 +32,8 @@ define([ * @deprecated since v6.0.0 - please use `Adapt.store` instead */ get componentStore() { - this.log && this.log.deprecated('Adapt.componentStore, please use Adapt.store instead'); - return this.store; + this.log && this.log.deprecated('Adapt.componentStore, please use Adapt.store instead'); + return this.store; } init() { @@ -173,7 +173,7 @@ define([ return object; } - if (!object.view && !object.model || object instanceof Backbone.View) { + if ((!object.view && !object.model) || object instanceof Backbone.View) { this.log && this.log.deprecated('View-only registrations are no longer supported'); object = { view: object }; } @@ -206,7 +206,7 @@ define([ * @param {string|Backbone.Model|Backbone.View|object} nameModelViewOrData The name of the view class you want to fetch e.g. `"hotgraphic"` or its model or its json data */ getViewName(nameModelViewOrData) { - if (typeof nameModelViewOrData === "string") { + if (typeof nameModelViewOrData === 'string') { return nameModelViewOrData; } if (nameModelViewOrData instanceof Backbone.Model) { @@ -261,7 +261,7 @@ define([ * @param {string|Backbone.Model|object} name The name of the model you want to fetch e.g. `"hotgraphic"`, the model to process or its json data */ getModelName(nameModelOrData) { - if (typeof nameModelOrData === "string") { + if (typeof nameModelOrData === 'string') { return nameModelOrData; } if (nameModelOrData instanceof Backbone.Model) { @@ -360,8 +360,8 @@ define([ * Trickle uses this function to determine where it should scrollTo after it unlocks */ parseRelativeString(relativeString) { - const splitIndex = relativeString.search(/[ \+\-\d]{1}/); - const type = relativeString.slice(0, splitIndex).replace(/^\@/, ''); + const splitIndex = relativeString.search(/[ +\-\d]{1}/); + const type = relativeString.slice(0, splitIndex).replace(/^@/, ''); const offset = parseInt(relativeString.slice(splitIndex).trim() || 0); return { type: type, diff --git a/src/core/js/models/adaptModel.js b/src/core/js/models/adaptModel.js index ffdac12ec..caff07bdf 100644 --- a/src/core/js/models/adaptModel.js +++ b/src/core/js/models/adaptModel.js @@ -273,8 +273,8 @@ define([ const message = (isPluralized && hasUpperCase) ? `'${typeGroup}' appears pluralized and contains uppercase characters, suggest using the singular, lowercase type group '${singularLowerCased}'.` : isPluralized ? - `'${typeGroup}' appears pluralized, suggest using the singular type group '${singularLowerCased}'.` : - `'${typeGroup}' contains uppercase characters, suggest using lowercase type group '${singularLowerCased}'.`; + `'${typeGroup}' appears pluralized, suggest using the singular type group '${singularLowerCased}'.` : + `'${typeGroup}' contains uppercase characters, suggest using lowercase type group '${singularLowerCased}'.`; Adapt.log.deprecated(message); } const pluralizedLowerCaseTypes = [ @@ -298,11 +298,11 @@ define([ if (this._typeGroups) return this._typeGroups; const typeGroups = [ this.get('_type') ]; let parentClass = this; - while (parentClass = Object.getPrototypeOf(parentClass)) { + while ((parentClass = Object.getPrototypeOf(parentClass))) { if (!parentClass.hasOwnProperty('getTypeGroup')) continue; - typeGroups.push( parentClass.getTypeGroup.call(this) ); + typeGroups.push(parentClass.getTypeGroup.call(this)); } - return (this._typeGroups = _.uniq(typeGroups.filter(Boolean).map(s => s.toLowerCase()))); + return (this._typeGroups = _.uniq(typeGroups.filter(Boolean).map(s => s.toLowerCase()))); } /** diff --git a/src/core/js/models/componentModel.js b/src/core/js/models/componentModel.js index 452ee411f..84c253674 100644 --- a/src/core/js/models/componentModel.js +++ b/src/core/js/models/componentModel.js @@ -1,6 +1,7 @@ define([ + 'core/js/adapt', 'core/js/models/adaptModel' -], function (AdaptModel) { +], function (Adapt, AdaptModel) { class ComponentModel extends AdaptModel { diff --git a/src/core/js/router.js b/src/core/js/router.js index e0e61a7fe..a15596827 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -236,7 +236,7 @@ define([ setGlobalClasses() { const currentModel = Adapt.location._currentModel; - + const htmlClasses = (currentModel && currentModel.get('_htmlClasses')) || ''; const classes = (Adapt.location._currentId) ? `location-${Adapt.location._contentType} location-id-${Adapt.location._currentId}` : From 96bda87a9291069cc8ec1be6620ec251f8833cd7 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Mon, 6 Apr 2020 09:33:10 +0100 Subject: [PATCH 22/57] issue/2714 Added fallback model/view name discovery --- src/core/js/adapt.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index c42468a6c..1677d2532 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -231,7 +231,7 @@ define([ if (names.length) { // find first fitting view name const name = names.find(name => this.store[name] && this.store[name].view); - return name; + return name || names.pop(); // return last available if none found } } throw new Error('Cannot derive view class name from input'); @@ -276,7 +276,7 @@ define([ if (names.length) { // find first fitting model name const name = names.find(name => this.store[name] && this.store[name].model); - return name; + return name || names.pop(); // return last available if none found } } throw new Error('Cannot derive model class name from input'); From ebdabc57a2ecd926271cc51c541c494b49d31e3d Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Mon, 6 Apr 2020 09:39:24 +0100 Subject: [PATCH 23/57] issue/2645 Added manifest creation and loading --- grunt/config/watch.js | 4 +- grunt/tasks/build.js | 1 + grunt/tasks/dev.js | 1 + grunt/tasks/diff.js | 1 + grunt/tasks/language-data-manifests.js | 7 + grunt/tasks/server-build.js | 1 + src/core/js/collections/adaptCollection.js | 6 - .../js/collections/adaptSubsetCollection.js | 23 ++ src/core/js/data.js | 256 +++++++----------- src/core/js/models/buildModel.js | 2 +- src/core/js/models/courseModel.js | 14 +- src/core/js/mpabc.js | 78 +++++- 12 files changed, 217 insertions(+), 177 deletions(-) create mode 100644 grunt/tasks/language-data-manifests.js create mode 100644 src/core/js/collections/adaptSubsetCollection.js diff --git a/grunt/config/watch.js b/grunt/config/watch.js index 46fe07361..c40ffa001 100644 --- a/grunt/config/watch.js +++ b/grunt/config/watch.js @@ -17,8 +17,8 @@ module.exports = { tasks: ['handlebars', 'javascript:dev'] }, courseJson: { - files: ['<%= sourcedir %>course/**/*.<%= jsonext %>'], - tasks: ['jsonlint', 'check-json', 'newer:copy:courseJson', 'schema-defaults'] + files: ['<%= sourcedir %>course/**/*.<%= jsonext %>', '<%= outputdir %>course/*/language_data_manifest.js'], + tasks: ['language-data-manifests', 'jsonlint', 'check-json', 'newer:copy:courseJson', 'schema-defaults'] }, courseAssets: { files: ['<%= sourcedir %>course/<%=languages%>/*', '!<%= sourcedir %>course/<%=languages%>/*.<%= jsonext %>'], diff --git a/grunt/tasks/build.js b/grunt/tasks/build.js index 76e5d3d00..042977768 100644 --- a/grunt/tasks/build.js +++ b/grunt/tasks/build.js @@ -9,6 +9,7 @@ module.exports = function(grunt) { 'build-config', 'copy', 'schema-defaults', + 'language-data-manifests', 'handlebars', 'tracking-insert', 'javascript:compile', diff --git a/grunt/tasks/dev.js b/grunt/tasks/dev.js index 9a3f0aeef..450f903bf 100644 --- a/grunt/tasks/dev.js +++ b/grunt/tasks/dev.js @@ -8,6 +8,7 @@ module.exports = function(grunt) { 'build-config', 'copy', 'schema-defaults', + 'language-data-manifests', 'handlebars', 'tracking-insert', 'javascript:dev', diff --git a/grunt/tasks/diff.js b/grunt/tasks/diff.js index 9f6f93da9..53f7694d3 100644 --- a/grunt/tasks/diff.js +++ b/grunt/tasks/diff.js @@ -8,6 +8,7 @@ module.exports = function(grunt) { 'build-config', 'copy', 'schema-defaults', + 'language-data-manifests', 'newer:handlebars:compile', 'tracking-insert', 'newer:javascript:dev', diff --git a/grunt/tasks/language-data-manifests.js b/grunt/tasks/language-data-manifests.js new file mode 100644 index 000000000..a38f232e7 --- /dev/null +++ b/grunt/tasks/language-data-manifests.js @@ -0,0 +1,7 @@ +module.exports = function(grunt) { + const Helpers = require('../helpers')(grunt); + grunt.registerTask('language-data-manifests', 'Creates a manifest for each set of language data files', function() { + const languages = Helpers.getFramework({ useOutputData: true }).getData().languages; + languages.forEach(language => language.saveManifest()); + }); +}; diff --git a/grunt/tasks/server-build.js b/grunt/tasks/server-build.js index ff82e5983..afebcf936 100644 --- a/grunt/tasks/server-build.js +++ b/grunt/tasks/server-build.js @@ -9,6 +9,7 @@ module.exports = function(grunt) { '_log-vars', 'build-config', 'copy', + 'language-data-manifests', 'less:' + requireMode, 'handlebars', 'javascript:' + requireMode, diff --git a/src/core/js/collections/adaptCollection.js b/src/core/js/collections/adaptCollection.js index 03566c559..500e65b04 100644 --- a/src/core/js/collections/adaptCollection.js +++ b/src/core/js/collections/adaptCollection.js @@ -5,13 +5,7 @@ define([ class AdaptCollection extends Backbone.Collection { initialize(models, options) { - this.url = options.url; this.once('reset', this.loadedData, this); - if (!this.url) return; - this.fetch({ - reset: true, - error: () => console.error('ERROR: unable to load file ' + this.url) - }); } loadedData() { diff --git a/src/core/js/collections/adaptSubsetCollection.js b/src/core/js/collections/adaptSubsetCollection.js new file mode 100644 index 000000000..ba6c188a1 --- /dev/null +++ b/src/core/js/collections/adaptSubsetCollection.js @@ -0,0 +1,23 @@ +define([ + 'core/js/adapt', + './adaptCollection' +], function(Adapt, AdaptCollection) { + + class AdaptSubsetCollection extends AdaptCollection { + + initialize(models, options) { + super.initialize(models, options); + this.parent = options.parent; + this.listenTo(this.parent, 'reset', this.loadSubset); + } + + loadSubset() { + this.set(this.parent.filter(model => model instanceof this.model)); + this._byAdaptID = this.groupBy('_id'); + } + + } + + return AdaptSubsetCollection; + +}); diff --git a/src/core/js/data.js b/src/core/js/data.js index f87d32ffd..258a9bcbf 100644 --- a/src/core/js/data.js +++ b/src/core/js/data.js @@ -1,38 +1,37 @@ define([ 'core/js/adapt', 'core/js/collections/adaptCollection', + 'core/js/models/buildModel', 'core/js/models/configModel', 'core/js/models/courseModel', 'core/js/models/lockingModel', - 'core/js/models/buildModel', 'core/js/startController' -], function(Adapt, AdaptCollection, ConfigModel, CourseModel) { +], function(Adapt, AdaptCollection, BuildModel, ConfigModel, CourseModel) { - class Data extends Backbone.Controller { + class Data extends AdaptCollection { - initialize() { - this.mappedIds = {}; + model(json) { + const ModelClass = Adapt.getModelClass(json); + if (!ModelClass) { + return new Backbone.Model(json); + } + return new ModelClass(json); } - init () { - Adapt.build.whenReady().then(this.onBuildDataLoaded.bind(this)); + async init () { + Adapt.build = new BuildModel(null, { url: 'adapt/js/build.min.js', reset: true }); + await Adapt.build.whenReady(); + $('html').attr('data-adapt-framework-version', Adapt.build.get('package').version); + this.loadConfigData(); } - onBuildDataLoaded() { - $('html').attr('data-adapt-framework-version', Adapt.build.get('package').version); + loadConfigData() { Adapt.config = new ConfigModel(null, { url: 'course/config.' + Adapt.build.get('jsonext'), reset: true }); + this.listenToOnce(Adapt, 'configModel:loadCourseData', this.onLoadCourseData); this.listenTo(Adapt.config, { 'change:_activeLanguage': this.onLanguageChange, 'change:_defaultDirection': this.onDirectionChange }); - - // Events that are triggered by the main Adapt content collections and models - this.listenToOnce(Adapt, 'configModel:loadCourseData', this.onLoadCourseData); - } - - onLanguageChange(model, language) { - Adapt.offlineStorage.set('lang', language); - this.loadCourseData(this.triggerDataReady.bind(this), language); } onDirectionChange(model, direction) { @@ -48,141 +47,116 @@ define([ * If it has we can go ahead and start loading; if it hasn't, apply the defaultLanguage from config.json */ onLoadCourseData() { - if (Adapt.config.get('_activeLanguage')) { - this.loadCourseData(this.triggerDataReady.bind(this)); - } else { + if (!Adapt.config.get('_activeLanguage')) { Adapt.config.set('_activeLanguage', Adapt.config.get('_defaultLanguage')); + return; } + this.loadCourseData(); } - loadCourseData(callback, newLanguage) { - this.listenTo(Adapt, 'adaptCollection:dataLoaded courseModel:dataLoaded', () => { - this.checkDataIsLoaded(callback, newLanguage); - }); + onLanguageChange(model, language) { + Adapt.offlineStorage.set('lang', language); + this.loadCourseData(language); + } + + async loadCourseData(newLanguage) { // All code that needs to run before adapt starts should go here const language = Adapt.config.get('_activeLanguage'); - const jsonext = Adapt.build.get('jsonext'); + const courseFolder = 'course/' + language + '/'; $('html').attr('lang', language); - const getContentObjectModel = json => { - const ModelClass = Adapt.getModelClass(json) || Adapt.getModelClass('menu'); - if (!ModelClass) { - throw new Error(`Cannot find model for: ${Adapt.getModelName(json)}`); - } - return new ModelClass(json); - }; - - const getPath = name => `course/${language}/${name}.${jsonext}`; - - const getModel = json => { - const ModelClass = Adapt.getModelClass(json); - if (!ModelClass) { - throw new Error(`Cannot find model for: ${Adapt.getModelName(json)}`); - } - return new ModelClass(json); - }; - - Adapt.course = new CourseModel(null, { url: getPath('course'), reset: true }); - - Adapt.contentObjects = new AdaptCollection(null, { - model: getContentObjectModel, - url: getPath('contentObjects') - }); - - Adapt.articles = new AdaptCollection(null, { - model: getModel, - url: getPath('articles') - }); + await this.loadManifestFiles(courseFolder); + await this.triggerDataLoaded(); + await this.triggerDataReady(newLanguage); + this.triggerInit(); - Adapt.blocks = new AdaptCollection(null, { - model: getModel, - url: getPath('blocks') - }); + } - Adapt.components = new AdaptCollection(null, { - model: getModel, - url: getPath('components') + getJSON(path) { + return new Promise((resolve, reject) => { + $.getJSON(path, data => { + // Add path to data incase it's necessary later + data.__path__ = path; + resolve(data); + }).fail(reject); }); } - checkDataIsLoaded(callback, newLanguage) { - if (Adapt.contentObjects.models.length > 0 && - Adapt.articles.models.length > 0 && - Adapt.blocks.models.length > 0 && - Adapt.components.models.length > 0 && - Adapt.course.get('_id')) { - - this.mapAdaptIdsToObjects(); - - Adapt.log.debug('Firing app:dataLoaded'); - - try { - Adapt.trigger('app:dataLoaded');// Triggered to setup model connections in AdaptModel.js - } catch (e) { - Adapt.log.error('Error during app:dataLoading trigger', e); + async loadManifestFiles(languagePath) { + this.trigger('loading'); + this.reset(); + const manifestPath = languagePath + 'language_data_manifest.js'; + let manifest; + try { + manifest = await this.getJSON(manifestPath); + } catch (err) { + manifest = ['course.json', 'contentObjects.json', 'articles.json', 'blocks.json', 'components.json']; + Adapt.log.warnOnce(`Manifest path '${manifestPath} not found. Using traditional files: ${manifest.join(', ')}`); + } + const allFileData = await Promise.all(manifest.map(filePath => { + return this.getJSON(`${languagePath}${filePath}`); + })); + // Flatten all file data into a single array of model data + const allModelData = allFileData.reduce((result, fileData) => { + if (fileData instanceof Array) { + result.push(...fileData); + } else if (fileData instanceof Object) { + result.push(fileData); + } else { + Adapt.log.warnOnce(`File data isn't an array or object: ${fileData.__path__}`); } - - this.setupMapping(); - - Adapt.wait.queue(() => { - callback(newLanguage); - }); - + return result; + }, []); + // Add course model first to allow other model/views to utilize its settings + const course = allModelData.find(modelData => modelData._type === 'course'); + if (!course) { + throw new Error(`Expected a model data with "_type": "course", none found.`); } + this.push(course); + // Add other models + allModelData.forEach(modelData => { + if (modelData._type === 'course') { + return; + } + this.push(modelData); + }); + // index by id + this._byAdaptID = this.indexBy('_id'); + this.trigger('reset'); + this.trigger('loaded'); + await Adapt.wait.queue(); } - mapAdaptIdsToObjects() { - Adapt.contentObjects._byAdaptID = Adapt.contentObjects.groupBy('_id'); - Adapt.articles._byAdaptID = Adapt.articles.groupBy('_id'); - Adapt.blocks._byAdaptID = Adapt.blocks.groupBy('_id'); - Adapt.components._byAdaptID = Adapt.components.groupBy('_id'); - } - - setupMapping() { - this.mappedIds = {}; - - // Setup course Id - this.mappedIds[Adapt.course.get('_id')] = 'course'; - - const collections = ['contentObjects', 'articles', 'blocks', 'components']; - - collections.forEach(collection => { - Adapt[collection].models.forEach(model => { - const id = model.get('_id'); - this.mappedIds[id] = collection; - }); - }); + async triggerDataLoaded() { + Adapt.log.debug('Firing app:dataLoaded'); + try { + Adapt.trigger('app:dataLoaded');// Triggered to setup model connections in AdaptModel.js + } catch (e) { + Adapt.log.error('Error during app:dataLoading trigger', e); + } + await Adapt.wait.queue(); } - triggerDataReady(newLanguage) { + async triggerDataReady(newLanguage) { if (newLanguage) { - Adapt.trigger('app:languageChanged', newLanguage); - _.defer(() => { Adapt.startController.loadCourseData(); - let hash = '#/'; - - if (Adapt.startController.isEnabled()) { - hash = Adapt.startController.getStartHash(true); - } - - Backbone.history.navigate(hash, { trigger: true, replace: true }); + const hash = Adapt.startController.isEnabled() ? + '#/' : + Adapt.startController.getStartHash(true); + Adapt.router.navigate(hash, { trigger: true, replace: true }); }); } - Adapt.log.debug('Firing app:dataReady'); - try { Adapt.trigger('app:dataReady'); } catch (e) { Adapt.log.error('Error during app:dataReady trigger', e); } - - Adapt.wait.queue(this.triggerInit.bind(this)); - + await Adapt.wait.queue(); } triggerInit() { @@ -192,59 +166,23 @@ define([ whenReady() { if (this.isReady) return Promise.resolve(); - return new Promise(resolve => { this.once('ready', resolve); }); } - /** - * Looks up which collection a model belongs to - * @param {string} id The id of the item you want to look up e.g. `"co-05"` - * @return {string} One of the following (or `undefined` if not found): - * - "course" - * - "contentObjects" - * - "blocks" - * - "articles" - * - "components" - */ - mapById(id) { - return this.mappedIds[id]; - } - /** * Looks up a model by its `_id` property * @param {string} id The id of the item e.g. "co-05" * @return {Backbone.Model} */ findById(id) { - if (id === Adapt.course.get('_id')) { - return Adapt.course; - } - - const collectionType = Adapt.mapById(id); - - if (!collectionType) { - console.warn('Adapt.findById() unable to find collection type for id: ' + id); + const model = this._byAdaptID[id]; + if (!model) { + console.warn(`Adapt.findById() unable to find collection type for id: ${id}`); return; } - - return Adapt[collectionType]._byAdaptID[id][0]; - } - - /** - * Filter all models. - * @param {Function} filter - * @returns {Array} - */ - filter(filter) { - const result = []; - filter(Adapt.course) && result.push(Adapt.course); - result.push(...Adapt.contentObjects.filter(filter)); - result.push(...Adapt.articles.filter(filter)); - result.push(...Adapt.blocks.filter(filter)); - result.push(...Adapt.components.filter(filter)); - return result; + return model; } } diff --git a/src/core/js/models/buildModel.js b/src/core/js/models/buildModel.js index 142186fa6..47ed6b69e 100644 --- a/src/core/js/models/buildModel.js +++ b/src/core/js/models/buildModel.js @@ -37,6 +37,6 @@ define([ } - return (Adapt.build = new BuildModel(null, { url: 'adapt/js/build.min.js', reset: true })); + return BuildModel; }); diff --git a/src/core/js/models/courseModel.js b/src/core/js/models/courseModel.js index 6065a40b5..b90ba61a1 100644 --- a/src/core/js/models/courseModel.js +++ b/src/core/js/models/courseModel.js @@ -23,23 +23,21 @@ define([ return 'course'; } - initialize(attrs, options) { - super.initialize(arguments); + initialize(...args) { Adapt.trigger('courseModel:dataLoading'); - this.url = options.url; - this.on('sync', this.loadedData, this); - if (!this.url) return; - this.fetch({ - error: () => console.error(`ERROR: unable to load file ${this.url}`) - }); + super.initialize(...args); + this.loadedData(); } loadedData() { + Adapt.course = this; Adapt.trigger('courseModel:dataLoaded'); } } + Adapt.register('course', { model: CourseModel }); + return CourseModel; }); diff --git a/src/core/js/mpabc.js b/src/core/js/mpabc.js index cbb2aa09a..c6a5157bc 100644 --- a/src/core/js/mpabc.js +++ b/src/core/js/mpabc.js @@ -1,9 +1,85 @@ define([ + 'core/js/adapt', + 'core/js/data', + 'core/js/collections/adaptSubsetCollection', + 'core/js/models/courseModel', + 'core/js/models/contentObjectModel', 'core/js/models/menuModel', 'core/js/models/pageModel', 'core/js/models/articleModel', 'core/js/models/blockModel', + 'core/js/models/componentModel', 'core/js/views/pageView', 'core/js/views/articleView', 'core/js/views/blockView' -], function(MenuModel, PageModel, ArticleModel, BlockModel, PageView, ArticleView, BlockView) {}); +], function(Adapt, Data, AdaptSubsetCollection, CourseModel, ContentObjectModel, MenuModel, PageModel, ArticleModel, BlockModel, ComponentModel, PageView, ArticleView, BlockView) { + + class MPABC extends Backbone.Controller { + + initialize() { + // Example of how to cause the data loader to wait for another module to setup + this.listenTo(Data, { + 'loading': this.waitForDataLoaded, + 'loaded': this.onDataLoaded + }); + this.setupDeprecatedSubsetCollections(); + } + + waitForDataLoaded() { + // Tell the data loader to wait + Adapt.wait.begin(); + } + + onDataLoaded() { + // Tell the data loader that we have finished + Adapt.wait.end(); + } + + setupDeprecatedSubsetCollections() { + let contentObjects = new AdaptSubsetCollection(null, { parent: Data, model: ContentObjectModel }); + let articles = new AdaptSubsetCollection(null, { parent: Data, model: ArticleModel }); + let blocks = new AdaptSubsetCollection(null, { parent: Data, model: BlockModel }); + let components = new AdaptSubsetCollection(null, { parent: Data, model: ComponentModel }); + Object.defineProperty(Adapt, 'contentObjects', { + get: function() { + Adapt.log.deprecated('Adapt.contentObjects, please use Adapt.data instead'); + return contentObjects; + }, + set: function(value) { + contentObjects = value; + } + }); + Object.defineProperty(Adapt, 'articles', { + get: function() { + Adapt.log.deprecated('Adapt.articles, please use Adapt.data instead'); + return articles; + }, + set: function(value) { + articles = value; + } + }); + Object.defineProperty(Adapt, 'blocks', { + get: function() { + Adapt.log.deprecated('Adapt.blocks, please use Adapt.data instead'); + return blocks; + }, + set: function(value) { + blocks = value; + } + }); + Object.defineProperty(Adapt, 'components', { + get: function() { + Adapt.log.deprecated('Adapt.components, please use Adapt.data instead'); + return components; + }, + set: function(value) { + components = value; + } + }); + } + + } + + return (Adapt.mpabc = new MPABC()); + +}); From dfb9224ea45d98fdf3bd7f1053e9905eb0425933 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 7 Apr 2020 13:03:01 +0100 Subject: [PATCH 24/57] Fixed issue with new compatibility layer changes --- src/core/js/views/questionView.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/js/views/questionView.js b/src/core/js/views/questionView.js index 784ee5478..76296fa3e 100644 --- a/src/core/js/views/questionView.js +++ b/src/core/js/views/questionView.js @@ -543,7 +543,9 @@ define([ if (!this.constructor.prototype[checkForFunction]) return false; // questionModel // if the function DOES exist on the view and MATCHES the compatibility function above, use the model only - if (this.constructor.prototype[checkForFunction] === ViewOnlyQuestionViewCompatibilityLayer.prototype[checkForFunction]) { + const hasCompatibleVersion = (ViewOnlyQuestionViewCompatibilityLayer.prototype.hasOwnProperty(checkForFunction)); + const usingCompatibleVersion = (this.constructor.prototype[checkForFunction] === ViewOnlyQuestionViewCompatibilityLayer.prototype[checkForFunction]); + if (hasCompatibleVersion && usingCompatibleVersion) { switch (checkForFunction) { case 'setupFeedback': case 'markQuestion': @@ -553,7 +555,6 @@ define([ } // if the function DOES exist on the view and does NOT match the compatibility function above, use the view function - Adapt.log.deprecated(`QuestionView.${name}, please use QuestionModel.${name}`); return true; // questionView } From 3b929b35d70c3a1e8a266105a097c9c21a515f01 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 17:27:29 +0100 Subject: [PATCH 25/57] issue/2712 Simplfied router --- src/core/js/a11y.js | 6 +- src/core/js/adapt.js | 50 ++- src/core/js/headings.js | 4 +- src/core/js/router.js | 481 ++++++++++++------------- src/core/js/scrolling.js | 79 ++-- src/core/js/views/adaptView.js | 25 +- src/core/js/views/contentObjectView.js | 32 +- src/core/js/wait.js | 12 +- 8 files changed, 330 insertions(+), 359 deletions(-) diff --git a/src/core/js/a11y.js b/src/core/js/a11y.js index 9f5a8a412..6a65e4ce6 100644 --- a/src/core/js/a11y.js +++ b/src/core/js/a11y.js @@ -79,7 +79,7 @@ define([ Adapt.on('device:changed', this._setupNoSelect); this.listenTo(Adapt, { 'router:location': this._onNavigationStart, - 'pageView:ready menuView:ready router:plugin': this._onNavigationEnd + 'contentObjectView:ready router:plugin': this._onNavigationEnd }); }, @@ -137,7 +137,7 @@ define([ } // Stop document reading _.defer(function() { - Adapt.a11y.toggleHidden('.page, .menu', true); + Adapt.a11y.toggleHidden('.contentobject', true); }); }, @@ -147,7 +147,7 @@ define([ return; } // Allow document to be read - Adapt.a11y.toggleHidden('.page, .menu', false); + Adapt.a11y.toggleHidden('.contentobject', false); }, isActive: function() { diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index 8d324f46a..5eefd378f 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -17,7 +17,8 @@ define([ _canScroll: true, // to stop scrollTo behaviour, _outstandingCompletionChecks: 0, _pluginWaitCount: 0, - _isStarted: false + _isStarted: false, + _shouldDestroyContentObjects: true }; } @@ -133,33 +134,18 @@ define([ /** * Allows a selector to be passed in and Adapt will navigate to this element * @param {string} selector CSS selector of the Adapt element you want to navigate to e.g. `".co-05"` - * @param {object} [settings] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). + * @param {object} [settings={}] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). * You may also include a `replace` property that you can set to `true` if you want to update the URL without creating an entry in the browser's history. */ - navigateToElement(selector, settings = {}) { - // Removes . symbol from the selector to find the model - const currentModelId = selector.replace(/\./g, ''); - const currentModel = this.data.findById(currentModelId); - // Get current page to check whether this is the current page - const currentPage = (currentModel._siblings === 'contentObjects') ? currentModel : currentModel.findAncestor('contentObjects'); - - // If current page - scrollTo element - if (currentPage.get('_id') === this.location._currentId) { - return this.scrollTo(selector, settings); - } - - // If the element is on another page navigate and wait until pageView:ready is fired - // Then scrollTo element - this.once('pageView:ready', _.debounce(() => { - this.router.set('_shouldNavigateFocus', true); - this.scrollTo(selector, settings); - }, 1)); - - const shouldReplaceRoute = settings.replace || false; + navigateToElement() {} - this.router.set('_shouldNavigateFocus', false); - Backbone.history.navigate('#/id/' + currentPage.get('_id'), { trigger: true, replace: shouldReplaceRoute }); - } + /** + * Allows a selector to be passed in and Adapt will scroll to this element + * @param {string} selector CSS selector of the Adapt element you want to navigate to e.g. `".co-05"` + * @param {object} [settings={}] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). + * You may also include a `replace` property that you can set to `true` if you want to update the URL without creating an entry in the browser's history. + */ + scrollTo() {} /** * Used to register components with the Adapt 'component store' @@ -306,10 +292,16 @@ define([ $('html').toggleClass('disable-animation', (disableAnimation === true)); } - remove() { - this.trigger('preRemove'); - this.trigger('remove'); - _.defer(this.trigger.bind(this), 'postRemove'); + async remove() { + const currentView = this.parentView; + this.trigger('preRemove', currentView); + await this.wait.queue(); + // Facilitate contentObject transitions + if (this.parentView && this.get('_shouldDestroyContentObjects')) { + this.parentView.destroy(); + } + this.trigger('remove', currentView); + _.defer(this.trigger.bind(this), 'postRemove', currentView); } } diff --git a/src/core/js/headings.js b/src/core/js/headings.js index 176e6a59e..57d9bd8cd 100644 --- a/src/core/js/headings.js +++ b/src/core/js/headings.js @@ -6,9 +6,7 @@ define([ var Headings = Backbone.Controller.extend({ initialize: function() { - var types = [ 'menu', 'menuItem', 'page', 'article', 'block', 'component' ]; - var eventNames = types.concat(['']).join('View:render '); - this.listenTo(Adapt, eventNames, this.onViewRender); + this.listenTo(Adapt, 'view:render', this.onViewRender); }, onViewRender: function(view) { diff --git a/src/core/js/router.js b/src/core/js/router.js index 0afa82bf3..4dd1c0752 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -1,9 +1,12 @@ define([ 'core/js/adapt', 'core/js/models/routerModel', + 'core/js/models/courseModel', + 'core/js/models/contentObjectModel', + 'core/js/models/menuModel', 'core/js/views/pageView', 'core/js/startController' -], function(Adapt, RouterModel, PageView) { +], function(Adapt, RouterModel, CourseModel, ContentObjectModel, MenuModel, PageView) { class Router extends Backbone.Router { @@ -16,35 +19,64 @@ define([ } initialize({ model }) { - this.model = model; - + this._navigationRoot = Adapt.course; // Flag to indicate if the router has tried to redirect to the current location. this._isCircularNavigationInProgress = false; - this.showLoading(); - // Store #wrapper element and html to cache for later use. this.$wrapper = $('#wrapper'); this.$html = $('html'); + this.listenToOnce(Adapt, 'app:dataReady', this.setDocumentTitle); + this.listenTo(Adapt, 'router:navigateTo', this.navigateToArguments); + } + + get rootModel() { + return this._navigationRoot; + } + + set rootModel(model) { + this._navigationRoot = model; + } - this.listenToOnce(Adapt, 'app:dataReady', () => { - document.title = Adapt.course.get('title'); + showLoading() { + $('.js-loading').show(); + } + + hideLoading() { + $('.js-loading').hide(); + } + + setDocumentTitle() { + const currentModel = Adapt.location._currentModel; + const hasSubTitle = (currentModel && currentModel !== Adapt.router.rootModel && currentModel.get('title')); + const title = [ + this.rootModel.get('title') || null, + hasSubTitle ? currentModel.get('title') : null + ].filter(Boolean).join(' | '); + this.listenToOnce(Adapt, 'contentObjectView:preRender', () => { + const escapedTitle = $(`
${title}
`).text(); + document.title = escapedTitle; }); - this.listenTo(Adapt, 'router:navigateTo', this.navigateToArguments); } - pruneArguments(args) { - if (args.length !== 0) { - // Remove any null arguments. - args = args.filter(v => v !== null); + navigateToArguments(args) { + args = args.filter(v => v !== null); + const options = { trigger: false, replace: false }; + if (args.length === 1 && Adapt.findById(args[0])) { + this.navigate('#/id/' + args[0], options); + return; } - - return args; + if (args.length <= 3) { + this.navigate('#/' + args.join('/'), options); + return; + } + Adapt.log.deprecated(`Use Backbone.history.navigate or window.location.href instead of Adapt.trigger('router:navigateTo')`); + this.handleRoute(...args); } handleRoute(...args) { - args = this.pruneArguments(args); + args = args.filter(v => v !== null); if (this.model.get('_canNavigate')) { // Reset _isCircularNavigationInProgress protection as code is allowed to navigate away. @@ -62,18 +94,10 @@ define([ if (this.model.get('_canNavigate')) { // Disable navigation whilst rendering. this.model.set('_canNavigate', false, { pluginName: 'adapt' }); - - switch (args.length) { - case 1: - // If only one parameter assume it's the ID. - return this.handleId(...args); - case 2: - // If there are two parameters assume it's a plugin. - return this.handlePluginRouter(...args); - default: - // Route to course home page. - return this.handleCourse(); + if (args.length <= 1) { + return this.handleId(...args); } + return this.handlePluginRouter(...args); } if (this._isCircularNavigationInProgress) { @@ -92,291 +116,238 @@ define([ this.navigateToCurrentRoute(true); } - handlePluginRouter(pluginName, location, action) { - let pluginLocation = pluginName; + async handlePluginRouter(pluginName, location, action) { + const pluginLocation = [ + pluginName, + location ? `-${location}` : null, + action ? `-${action}` : null + ].filter(Boolean).join(''); + await this.updateLocation(pluginLocation, null, null, null); + + Adapt.trigger('router:plugin:' + pluginName, pluginName, location, action); + Adapt.trigger('router:plugin', pluginName, location, action); + this.model.set('_canNavigate', true, { pluginName: 'adapt' }); + } - if (location) { - pluginLocation = pluginLocation + '-' + location; + async handleId(id) { + const rootModel = Adapt.router.rootModel; + const model = (!id) ? rootModel : Adapt.findById(id); - if (action) { - pluginLocation = pluginLocation + '-' + action; - } + if (!model) { + // Bad id + this.model.set('_canNavigate', true, { pluginName: 'adapt' }); + return; } - this.updateLocation(pluginLocation, null, null, () => { - Adapt.trigger('router:plugin:' + pluginName, pluginName, location, action); - Adapt.trigger('router:plugin', pluginName, location, action); + id = model.get('_id'); + const isContentObject = (model instanceof ContentObjectModel); + if (!isContentObject) { + // Allow navigation. this.model.set('_canNavigate', true, { pluginName: 'adapt' }); - }); - } + // Scroll to element + Adapt.navigateToElement('.' + id, { replace: true }); + return; + } - handleCourse() { - if (Adapt.course.has('_start')) { + const isRoot = (model === rootModel); + if (isRoot && Adapt.course.has('_start')) { // Do not allow access to the menu when the start controller is enabled. - const startController = Adapt.course.get('_start'); - + var startController = Adapt.course.get('_start'); if (startController._isEnabled === true && startController._isMenuDisabled === true) { return; } } - this.showLoading(); - - this.removeViews(() => { - Adapt.course.set('_isReady', false); + if (isContentObject && model.get('_isLocked') && Adapt.config.get('_forceRouteLocking')) { + // Locked id + Adapt.log.warn('Unable to navigate to locked id: ' + id); + this.model.set('_canNavigate', true, { pluginName: 'adapt' }); + if (Adapt.location._previousId === undefined) { + return this.navigate('#/', { trigger: true, replace: true }); + } + return this.navigateBack(); + } - this.setContentObjectToVisited(Adapt.course); + // Move to a content object + this.showLoading(); + await Adapt.remove(); + + /** + * TODO: + * As the course object has separate location and type rules, + * it makes it more difficult to update the Adapt.location object + * should stop doing this. + */ + const isCourse = (model instanceof CourseModel); + const type = isCourse ? 'menu' : model.get('_type'); + const location = isCourse ? 'course' : `${type}-${id}`; - this.updateLocation('course', null, null, () => { - this.listenToOnce(Adapt, 'menuView:ready', () => { - // Allow navigation. - this.model.set('_canNavigate', true, { pluginName: 'adapt' }); - this.handleNavigationFocus(); - }); + model.set('_isVisited', true); + await this.updateLocation(location, type, id, model); - Adapt.trigger('router:menu', Adapt.course); - }); + Adapt.once('contentObjectView:ready', () => { + // Allow navigation. + this.model.set('_canNavigate', true, { pluginName: 'adapt' }); + this.handleNavigationFocus(); }); - } + Adapt.trigger(`router:${type} router:contentObject`, model); - handleId(id) { - const currentModel = Adapt.findById(id); - let type = ''; - - if (!currentModel) { - this.model.set('_canNavigate', true, { pluginName: 'adapt' }); + const isMenu = (model instanceof MenuModel); + if (isMenu) { return; } + this.$wrapper.append(new PageView({ model }).$el); + } + + async updateLocation(currentLocation, type, id, currentModel) { + // Handles updating the location. + Adapt.location._previousModel = Adapt.location._currentModel; + Adapt.location._previousId = Adapt.location._currentId; + Adapt.location._previousContentType = Adapt.location._contentType; + + Adapt.location._currentModel = currentModel; + Adapt.location._currentId = id; + Adapt.location._contentType = type; + Adapt.location._currentLocation = currentLocation; - type = currentModel.get('_type'); - - switch (type) { - case 'page': - case 'menu': - if (currentModel.get('_isLocked') && Adapt.config.get('_forceRouteLocking')) { - Adapt.log.warn('Unable to navigate to locked id: ' + id); - this.model.set('_canNavigate', true, { pluginName: 'adapt' }); - if (Adapt.location._previousId === undefined) { - return this.navigate('#/', { trigger: true, replace: true }); - } else { - return Backbone.history.history.back(); - } - } else { - this.showLoading(); - this.removeViews(() => { - let location; - this.setContentObjectToVisited(currentModel); - - if (type === 'page') { - location = 'page-' + id; - this.updateLocation(location, 'page', id, () => { - this.listenToOnce(Adapt, 'pageView:ready', () => { - // Allow navigation. - this.model.set('_canNavigate', true, { pluginName: 'adapt' }); - this.handleNavigationFocus(); - }); - Adapt.trigger('router:page', currentModel); - this.$wrapper.append(new PageView({ model: currentModel }).$el); - }); - } else { - location = 'menu-' + id; - this.updateLocation(location, 'menu', id, () => { - this.listenToOnce(Adapt, 'menuView:ready', () => { - // Allow navigation. - this.model.set('_canNavigate', true, { pluginName: 'adapt' }); - this.handleNavigationFocus(); - }); - Adapt.trigger('router:menu', currentModel); - }); - } - }); - } - break; - default: - // Allow navigation. - this.model.set('_canNavigate', true, { pluginName: 'adapt' }); - Adapt.navigateToElement('.' + id, { replace: true }); + /** + * TODO: + * this if block should be removed, + * these properties are unused in the framework + */ + if (type === 'menu') { + Adapt.location._lastVisitedType = 'menu'; + Adapt.location._lastVisitedMenu = id; + } else if (type === 'page') { + Adapt.location._lastVisitedType = 'page'; + Adapt.location._lastVisitedPage = id; } - } - removeViews(onComplete) { - Adapt.remove(); + this.setDocumentTitle(); + this.setGlobalClasses(); - Adapt.wait.queue(onComplete); - } + // Trigger event when location changes. + Adapt.trigger('router:location', Adapt.location); - showLoading() { - $('.js-loading').show(); + await Adapt.wait.queue(); } - navigateToArguments(args) { - args = this.pruneArguments(args); + setGlobalClasses() { + const currentModel = Adapt.location._currentModel; + const htmlClasses = (currentModel && currentModel.get('_htmlClasses')) || ''; - const options = { trigger: false, replace: false }; + const classes = (Adapt.location._currentId) ? + `location-${Adapt.location._contentType} location-id-${Adapt.location._currentId}` : + `location-${Adapt.location._currentLocation}`; - switch (args.length) { - case 0: - this.navigate('#/', options); - break; - case 1: - if (Adapt.findById(args[0])) { - this.navigate('#/id/' + args[0], options); - } else { - this.navigate('#/' + args[0], options); - } - break; - case 2: - case 3: - this.navigate('#/' + args.join('/'), options); - break; - default: - Adapt.log.deprecated(`Use Backbone.history.navigate or window.location.href instead of Adapt.trigger('router:navigateTo')`); - this.handleRoute(...args); - } + this.$html + .removeClass(Adapt.location._previousClasses) + .addClass(classes) + .addClass(htmlClasses) + .attr('data-location', Adapt.location._currentLocation); + + this.$wrapper + .removeClass() + .addClass(classes) + .attr('data-location', Adapt.location._currentLocation); + + Adapt.location._previousClasses = `${classes} ${htmlClasses}`; } - navigateToPreviousRoute(force) { - // Sometimes a plugin might want to stop the default navigation. - // Check whether default navigation has changed. + handleNavigationFocus() { + if (!this.model.get('_shouldNavigateFocus')) return; + // Body will be forced to accept focus to start the + // screen reader reading the page. + Adapt.a11y.focus('body'); + } + + navigateBack() { + Backbone.history.history.back() + } + + navigateToCurrentRoute(force) { if (!this.model.get('_canNavigate') && !force) { return; } if (!Adapt.location._currentId) { - return Backbone.history.history.back(); - } - if (Adapt.location._previousContentType === 'page' && Adapt.location._contentType === 'menu') { - return this.navigateToParent(); - } - if (Adapt.location._previousContentType === 'page') { - return Backbone.history.history.back(); - } - if (Adapt.location._currentLocation === 'course') { return; } - this.navigateToParent(); + const currentId = Adapt.location._currentModel.get('_id'); + const isRoot = (Adapt.location._currentModel === this.rootModel); + const route = isRoot ? '#/' : '#/id/' + currentId; + this.navigate(route, { trigger: true, replace: true }); } - navigateToHomeRoute(force) { + navigateToPreviousRoute(force) { + // Sometimes a plugin might want to stop the default navigation. + // Check whether default navigation has changed. if (!this.model.get('_canNavigate') && !force) { return; } - this.navigate('#/', { trigger: true }); - } - - navigateToCurrentRoute(force) { - if (!this.model.get('_canNavigate') && !force) { - return; + const currentModel = Adapt.location._currentModel; + const previousModel = Adapt.location._previousModel; + if (!currentModel) { + return this.navigateBack(); } - if (!Adapt.location._currentId) { - return; + if (Adapt.location._currentModel instanceof MenuModel) { + return this.navigateToParent(); } - const currentId = Adapt.location._currentId; - const route = (currentId === Adapt.course.get('_id')) ? '#/' : '#/id/' + currentId; - this.navigate(route, { trigger: true, replace: true }); + if (previousModel) { + return this.navigateBack(); + } + this.navigateToParent(); } navigateToParent(force) { if (!this.model.get('_canNavigate') && !force) { return; } - const parentId = Adapt.contentObjects.findWhere({ _id: Adapt.location._currentId }).get('_parentId'); - const route = (parentId === Adapt.course.get('_id')) ? '#/' : '#/id/' + parentId; + const parentId = Adapt.location._currentModel.get('_parentId'); + const parentModel = Adapt.findById(parentId); + const isRoot = (parentModel === this.rootModel); + const route = isRoot ? '#/' : '#/id/' + parentId; this.navigate(route, { trigger: true }); } - setContentObjectToVisited(model) { - model.set('_isVisited', true); - } - - updateLocation(currentLocation, type, id, onComplete) { - // Handles updating the location. - Adapt.location._previousId = Adapt.location._currentId; - Adapt.location._previousContentType = Adapt.location._contentType; - - if (currentLocation === 'course') { - Adapt.location._currentId = Adapt.course.get('_id'); - Adapt.location._contentType = 'menu'; - Adapt.location._lastVisitedMenu = currentLocation; - } else if (!type) { - Adapt.location._currentId = null; - Adapt.location._contentType = null; - } else if (_.isString(id)) { - Adapt.location._currentId = id; - Adapt.location._contentType = type; - - if (type === 'menu') { - Adapt.location._lastVisitedType = 'menu'; - Adapt.location._lastVisitedMenu = id; - } else if (type === 'page') { - Adapt.location._lastVisitedType = 'page'; - Adapt.location._lastVisitedPage = id; - } - } - - Adapt.location._currentLocation = currentLocation; - - const locationModel = Adapt.findById(id) || Adapt.course; - const htmlClasses = (locationModel && locationModel.get('_htmlClasses')) || ''; - - const classes = (Adapt.location._currentId) ? 'location-' + - Adapt.location._contentType + - ' location-id-' + - Adapt.location._currentId - : 'location-' + Adapt.location._currentLocation; - - const previousClasses = Adapt.location._previousClasses; - if (previousClasses) { - this.$html.removeClass(previousClasses); + navigateToHomeRoute(force) { + if (!this.model.get('_canNavigate') && !force) { + return; } - - Adapt.location._previousClasses = classes + ' ' + htmlClasses; - - this.$html - .addClass(classes) - .addClass(htmlClasses) - .attr('data-location', Adapt.location._currentLocation); - - this.$wrapper - .removeClass() - .addClass(classes) - .attr('data-location', Adapt.location._currentLocation); - - this.setDocumentTitle(); - - // Trigger event when location changes. - Adapt.trigger('router:location', Adapt.location); - - Adapt.wait.queue(onComplete); + this.navigate('#/', { trigger: true }); } - setDocumentTitle() { - if (!Adapt.location._currentId) return; - - const currentModel = Adapt.findById(Adapt.location._currentId); - let pageTitle = ''; - - if (currentModel && currentModel.get('_type') !== 'course') { - const currentTitle = currentModel.get('title'); - - if (currentTitle) { - pageTitle = ' | ' + currentTitle; - } + /** + * Allows a selector to be passed in and Adapt will navigate to this element + * @param {string} selector CSS selector of the Adapt element you want to navigate to e.g. `".co-05"` + * @param {object} [settings] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). + * You may also include a `replace` property that you can set to `true` if you want to update the URL without creating an entry in the browser's history. + */ + navigateToElement(selector, settings = {}) { + // Removes . symbol from the selector to find the model + const currentModelId = selector.replace(/\./g, ''); + const currentModel = Adapt.findById(currentModelId); + if (!currentModel) return; + + // Get current page to check whether this is the current page + const currentPage = currentModel instanceof ContentObjectModel ? currentModel : currentModel.findAncestor('contentObjects'); + const pageId = currentPage.get('_id'); + // If current page - scrollTo element + if (pageId === Adapt.location._currentId) { + return Adapt.scrollTo(selector, settings); } - const courseTitle = Adapt.course.get('title'); - const documentTitle = $('
' + courseTitle + pageTitle + '
').text(); + // If the element is on another page navigate and wait until pageView:ready is fired + // Then scrollTo element + Adapt.once('contentObjectView:ready', _.debounce(() => { + this.model.set('_shouldNavigateFocus', true, { pluginName: 'adapt' }); + Adapt.scrollTo(selector, settings); + }, 1)); - this.listenToOnce(Adapt, 'pageView:ready menuView:ready', () => { - document.title = documentTitle; - }); - } + const shouldReplaceRoute = settings.replace || false; - handleNavigationFocus() { - if (!this.model.get('_shouldNavigateFocus')) return; - // Body will be forced to accept focus to start the - // screen reader reading the page. - Adapt.a11y.focus('body'); + this.model.set('_shouldNavigateFocus', false, { pluginName: 'adapt' }); + this.navigate('#/id/' + pageId, { trigger: true, replace: shouldReplaceRoute }); } get(...args) { @@ -391,8 +362,12 @@ define([ } - return (Adapt.router = new Router({ + Adapt.router = new Router({ model: new RouterModel(null, { reset: true }) - })); + }); + + Adapt.navigateToElement = Adapt.router.navigateToElement.bind(Adapt.router); + + return Adapt.router; }); diff --git a/src/core/js/scrolling.js b/src/core/js/scrolling.js index 9033ba01c..351027c9f 100644 --- a/src/core/js/scrolling.js +++ b/src/core/js/scrolling.js @@ -99,54 +99,53 @@ define([ }); } - } + scrollTo(selector, settings = {}) { + // Get the current location - this is set in the router + const location = (Adapt.location._contentType) ? + Adapt.location._contentType : Adapt.location._currentLocation; + // Trigger initial scrollTo event + Adapt.trigger(`${location}:scrollTo`, selector); + // Setup duration variable passed upon argumentsß + const disableScrollToAnimation = Adapt.config.has('_disableAnimation') ? Adapt.config.get('_disableAnimation') : false; + if (disableScrollToAnimation) { + settings.duration = 0; + } else if (!settings.duration) { + settings.duration = $.scrollTo.defaults.duration; + } - Adapt.scrolling = new Scrolling(); + let offsetTop = 0; + if (Adapt.scrolling.isLegacyScrolling) { + offsetTop = -$('.nav').outerHeight(); + // prevent scroll issue when component description aria-label coincident with top of component + if ($(selector).hasClass('component')) { + offsetTop -= $(selector).find('.aria-label').height() || 0; + } + } - Adapt.scrollTo = function(selector, settings) { - if (!settings) { - settings = {}; - } - // Get the current location - this is set in the router - const location = (Adapt.location._contentType) ? - Adapt.location._contentType : Adapt.location._currentLocation; - // Trigger initial scrollTo event - Adapt.trigger(location + ':scrollTo', selector); - // Setup duration variable passed upon argumentsß - const disableScrollToAnimation = Adapt.config.has('_disableAnimation') ? Adapt.config.get('_disableAnimation') : false; - if (disableScrollToAnimation) { - settings.duration = 0; - } else if (!settings.duration) { - settings.duration = $.scrollTo.defaults.duration; - } + if (!settings.offset) settings.offset = { top: offsetTop, left: 0 }; + if (settings.offset.top === undefined) settings.offset.top = offsetTop; + if (settings.offset.left === undefined) settings.offset.left = 0; - let offsetTop = 0; - if (Adapt.scrolling.isLegacyScrolling) { - offsetTop = -$('.nav').outerHeight(); - // prevent scroll issue when component description aria-label coincident with top of component - if ($(selector).hasClass('component')) { - offsetTop -= $(selector).find('.aria-label').height() || 0; + if (settings.offset.left === 0) settings.axis = 'y'; + + if (Adapt.get('_canScroll') !== false) { + // Trigger scrollTo plugin + $.scrollTo(selector, settings); } - } - if (!settings.offset) settings.offset = { top: offsetTop, left: 0 }; - if (settings.offset.top === undefined) settings.offset.top = offsetTop; - if (settings.offset.left === undefined) settings.offset.left = 0; + // Trigger an event after animation + // 300 milliseconds added to make sure queue has finished + _.delay(() => { + Adapt.a11y.focusNext(selector); + Adapt.trigger(`${location}:scrolledTo`, selector); + }, settings.duration + 300); + } - if (settings.offset.left === 0) settings.axis = 'y'; + } - if (Adapt.get('_canScroll') !== false) { - // Trigger scrollTo plugin - $.scrollTo(selector, settings); - } + Adapt.scrolling = new Scrolling(); - // Trigger an event after animation - // 300 milliseconds added to make sure queue has finished - _.delay(() => { - Adapt.a11y.focusNext(selector); - Adapt.trigger(location + ':scrolledTo', selector); - }, settings.duration + 300); - }; + Adapt.scrollTo = Adapt.scrolling.scrollTo.bind(Adapt.scrolling); return Adapt.scrolling; diff --git a/src/core/js/views/adaptView.js b/src/core/js/views/adaptView.js index f2624154d..9d3a098eb 100644 --- a/src/core/js/views/adaptView.js +++ b/src/core/js/views/adaptView.js @@ -11,7 +11,6 @@ define([ } initialize() { - this.listenTo(Adapt, 'remove', this.remove); this.listenTo(this.model, { 'change:_isVisible': this.toggleVisibility, 'change:_isHidden': this.toggleHidden, @@ -40,21 +39,21 @@ define([ render() { const type = this.constructor.type; - Adapt.trigger(`${type}View:preRender`, this); + Adapt.trigger(`${type}View:preRender view:preRender`, this); const data = this.model.toJSON(); data.view = this; const template = Handlebars.templates[this.constructor.template]; this.$el.html(template(data)); - Adapt.trigger(`${type}View:render`, this); + Adapt.trigger(`${type}View:render view:render`, this); _.defer(() => { // don't call postRender after remove if (this._isRemoved) return; this.postRender(); - Adapt.trigger(`${type}View:postRender`, this); + Adapt.trigger(`${type}View:postRender view:postRender`, this); }); return this; @@ -67,15 +66,10 @@ define([ this.$el.addClass(`has-animation ${onscreen._classes}-before`); this.$el.on('onscreen.adaptView', (e, m) => { - if (!m.onscreen) return; - const minVerticalInview = onscreen._percentInviewVertical || 33; - if (m.percentInviewVertical < minVerticalInview) return; - this.$el.addClass(`${onscreen._classes}-after`).off('onscreen.adaptView'); - }); } @@ -139,20 +133,23 @@ define([ } } - preRemove() {} + preRemove() { + const type = this.constructor.type; + Adapt.trigger(`${type}View:preRemove view:preRemove`, this); + } remove() { - + const type = this.constructor.type; this.preRemove(); + Adapt.trigger(`${type}View:remove view:remove`, this); this._isRemoved = true; Adapt.wait.for(end => { - this.$el.off('onscreen.adaptView'); this.model.setOnChildren('_isReady', false); this.model.set('_isReady', false); - Backbone.View.prototype.remove.call(this); - + super.remove(); + Adapt.trigger(`${type}View:postRemove view:postRemove`, this); end(); }); diff --git a/src/core/js/views/contentObjectView.js b/src/core/js/views/contentObjectView.js index 094b6ef7e..3822c1446 100644 --- a/src/core/js/views/contentObjectView.js +++ b/src/core/js/views/contentObjectView.js @@ -34,74 +34,76 @@ define([ render() { const type = this.constructor.type; - Adapt.trigger(`${type}View:preRender`, this); + Adapt.trigger(`${type}View:preRender contentObjectView:preRender view:preRender`, this); const data = this.model.toJSON(); data.view = this; const template = Handlebars.templates[this.constructor.template]; this.$el.html(template(data)); - Adapt.trigger(`${type}View:render`, this); + Adapt.trigger(`${type}View:render contentObjectView:render view:render`, this); _.defer(() => { // don't call postRender after remove if (this._isRemoved) return; this.postRender(); - Adapt.trigger(`${type}View:postRender`, this); + Adapt.trigger(`${type}View:postRender contentObjectView:postRender view:postRender`, this); }); return this; } - isReady() { + async isReady() { if (!this.model.get('_isReady')) return; + const type = this.constructor.type; const performIsReady = () => { $('.js-loading').hide(); $(window).scrollTop(0); - const type = this.constructor.type; - Adapt.trigger(`${type}View:ready`, this); + Adapt.trigger(`${type}View:ready contentObjectView:ready view:ready`, this); $.inview.unlock(`${type}View`); const styleOptions = { opacity: 1 }; if (this.disableAnimation) { this.$el.css(styleOptions); $.inview(); + _.defer(() => { + Adapt.trigger(`${type}View:postReady contentObjectView:postReady view:postReady`, this); + }); } else { this.$el.velocity(styleOptions, { duration: 'fast', complete: () => { $.inview(); + Adapt.trigger(`${type}View:postReady contentObjectView:postReady view:postReady`, this); } }); } $(window).scroll(); }; - Adapt.wait.queue(() => { - _.defer(performIsReady); - }); + Adapt.trigger(`${type}View:preReady contentObjectView:preReady view:preReady`, this); + await Adapt.wait.queue(); + _.defer(performIsReady); } preRemove() { - Adapt.trigger(`${this.constructor.type}View:preRemove`, this); + const type = this.constructor.type; + Adapt.trigger(`${type}View:preRemove contentObjectView:preRemove view:preRemove`, this); } remove() { const type = this.constructor.type; this.preRemove(); - Adapt.trigger(`${type}View:remove`, this); + Adapt.trigger(`${type}View:remove contentObjectView:preRemove view:preRemove`, this); this._isRemoved = true; Adapt.wait.for(end => { - this.$el.off('onscreen.adaptView'); this.model.setOnChildren('_isReady', false); this.model.set('_isReady', false); super.remove(); - - Adapt.trigger(`${type}View:postRemove`, this); - + Adapt.trigger(`${type}View:postRemove contentObjectView:preRemove view:preRemove`, this); end(); }); diff --git a/src/core/js/wait.js b/src/core/js/wait.js index 8131366e2..85cda30e7 100644 --- a/src/core/js/wait.js +++ b/src/core/js/wait.js @@ -112,11 +112,19 @@ define(function() { /** * Queue this function until all open waits have been ended. * - * @param {Function} callback - * @return {Object} + * @param {Function} [callback] + * @return {Object|Promise} */ queue: function(callback) { + if (!callback) { + this.begin(); + return new Promise(resolve => { + this.once('ready', resolve); + this.end(); + }); + } + this.begin(); this.once('ready', callback); this.end(); From df93b45bf1588416783ecef6a3189d89f3d8ac47 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 17:41:55 +0100 Subject: [PATCH 26/57] Fixed bugs added comments --- src/core/js/models/adaptModel.js | 13 ++++++++++++- src/core/js/router.js | 4 ++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/core/js/models/adaptModel.js b/src/core/js/models/adaptModel.js index b598db73b..9f76e6961 100644 --- a/src/core/js/models/adaptModel.js +++ b/src/core/js/models/adaptModel.js @@ -257,6 +257,10 @@ define([ const parent = this.getParent(); if (!parent) return; + /** + * TODO: + * look to remove hard coded model types + */ if (ancestorType === 'pages') { ancestorType = 'contentObjects'; } @@ -284,6 +288,10 @@ define([ descendants.slice(0, -1) ]; if (descendants === 'contentObjects') { + /** + * TODO: + * look to remove hard coded model types + */ types.push('page', 'menu'); } @@ -379,7 +387,10 @@ define([ * @return {array} */ findRelativeModel(relativeString, options) { - + /** + * TODO: + * look to remove hard coded model types + */ const types = [ 'menu', 'page', 'article', 'block', 'component' ]; options = options || {}; diff --git a/src/core/js/router.js b/src/core/js/router.js index 4dd1c0752..503d5b342 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -20,7 +20,7 @@ define([ initialize({ model }) { this.model = model; - this._navigationRoot = Adapt.course; + this._navigationRoot = null; // Flag to indicate if the router has tried to redirect to the current location. this._isCircularNavigationInProgress = false; this.showLoading(); @@ -32,7 +32,7 @@ define([ } get rootModel() { - return this._navigationRoot; + return this._navigationRoot || Adapt.course; } set rootModel(model) { From 763ccfce15785cc86f98e3395f09e55a266680dc Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 17:50:21 +0100 Subject: [PATCH 27/57] Added defer to postRemove events as expected --- src/core/js/router.js | 2 +- src/core/js/views/adaptView.js | 4 +++- src/core/js/views/contentObjectView.js | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/js/router.js b/src/core/js/router.js index 503d5b342..cf788ae1e 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -263,7 +263,7 @@ define([ } navigateBack() { - Backbone.history.history.back() + Backbone.history.history.back(); } navigateToCurrentRoute(force) { diff --git a/src/core/js/views/adaptView.js b/src/core/js/views/adaptView.js index 9d3a098eb..7e19b711a 100644 --- a/src/core/js/views/adaptView.js +++ b/src/core/js/views/adaptView.js @@ -149,7 +149,9 @@ define([ this.model.setOnChildren('_isReady', false); this.model.set('_isReady', false); super.remove(); - Adapt.trigger(`${type}View:postRemove view:postRemove`, this); + _.defer(() => { + Adapt.trigger(`${type}View:postRemove view:postRemove`, this); + }); end(); }); diff --git a/src/core/js/views/contentObjectView.js b/src/core/js/views/contentObjectView.js index 3822c1446..d86ede621 100644 --- a/src/core/js/views/contentObjectView.js +++ b/src/core/js/views/contentObjectView.js @@ -103,7 +103,9 @@ define([ this.model.setOnChildren('_isReady', false); this.model.set('_isReady', false); super.remove(); - Adapt.trigger(`${type}View:postRemove contentObjectView:preRemove view:preRemove`, this); + _.defer(() => { + Adapt.trigger(`${type}View:postRemove contentObjectView:preRemove view:preRemove`, this); + }); end(); }); From eb6597a36f3b77838b0031f206461b8f61e00d61 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 17:57:50 +0100 Subject: [PATCH 28/57] Removed bad condition --- src/core/js/router.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/js/router.js b/src/core/js/router.js index cf788ae1e..d429d0ac8 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -159,7 +159,7 @@ define([ } } - if (isContentObject && model.get('_isLocked') && Adapt.config.get('_forceRouteLocking')) { + if (model.get('_isLocked') && Adapt.config.get('_forceRouteLocking')) { // Locked id Adapt.log.warn('Unable to navigate to locked id: ' + id); this.model.set('_canNavigate', true, { pluginName: 'adapt' }); From eca527b68286628c769d3737dfaf0667929f0a7d Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 1 Apr 2020 18:02:10 +0100 Subject: [PATCH 29/57] Switched to cached value --- src/core/js/adapt.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index 5eefd378f..80a414e82 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -297,8 +297,8 @@ define([ this.trigger('preRemove', currentView); await this.wait.queue(); // Facilitate contentObject transitions - if (this.parentView && this.get('_shouldDestroyContentObjects')) { - this.parentView.destroy(); + if (currentView && this.get('_shouldDestroyContentObjects')) { + currentView.destroy(); } this.trigger('remove', currentView); _.defer(this.trigger.bind(this), 'postRemove', currentView); From 5a1cae893c8827757ee8ab1eafd1a5cc9121e060 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 2 Apr 2020 16:27:08 +0100 Subject: [PATCH 30/57] Recommendations Co-Authored-By: tomgreenfield --- src/core/js/router.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/js/router.js b/src/core/js/router.js index d429d0ac8..547035e06 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -51,8 +51,8 @@ define([ const currentModel = Adapt.location._currentModel; const hasSubTitle = (currentModel && currentModel !== Adapt.router.rootModel && currentModel.get('title')); const title = [ - this.rootModel.get('title') || null, - hasSubTitle ? currentModel.get('title') : null + this.rootModel.get('title'), + hasSubTitle && currentModel.get('title') ].filter(Boolean).join(' | '); this.listenToOnce(Adapt, 'contentObjectView:preRender', () => { const escapedTitle = $(`
${title}
`).text(); From c5b5bc468eaf60312e793744e7cb95ed00f4b602 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 2 Apr 2020 16:27:27 +0100 Subject: [PATCH 31/57] Recommendations Co-Authored-By: tomgreenfield --- src/core/js/router.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/js/router.js b/src/core/js/router.js index 547035e06..0da8ea325 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -119,8 +119,8 @@ define([ async handlePluginRouter(pluginName, location, action) { const pluginLocation = [ pluginName, - location ? `-${location}` : null, - action ? `-${action}` : null + location && `-${location}`, + action && `-${action}` ].filter(Boolean).join(''); await this.updateLocation(pluginLocation, null, null, null); From a555e944d04011c180b59cac9b9388ed2b8b80f8 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 2 Apr 2020 16:30:56 +0100 Subject: [PATCH 32/57] Recommendations --- src/core/js/router.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/js/router.js b/src/core/js/router.js index 0da8ea325..deab47fc5 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -235,16 +235,16 @@ define([ setGlobalClasses() { const currentModel = Adapt.location._currentModel; + const htmlClasses = (currentModel && currentModel.get('_htmlClasses')) || ''; - const classes = (Adapt.location._currentId) ? `location-${Adapt.location._contentType} location-id-${Adapt.location._currentId}` : `location-${Adapt.location._currentLocation}`; + const currentClasses = `${classes} ${htmlClasses}`; this.$html .removeClass(Adapt.location._previousClasses) - .addClass(classes) - .addClass(htmlClasses) + .addClass(currentClasses) .attr('data-location', Adapt.location._currentLocation); this.$wrapper @@ -252,7 +252,7 @@ define([ .addClass(classes) .attr('data-location', Adapt.location._currentLocation); - Adapt.location._previousClasses = `${classes} ${htmlClasses}`; + Adapt.location._previousClasses = currentClasses; } handleNavigationFocus() { From 9a20e9baec21cdcfcd0af446cb9b10839ecce28e Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 2 Apr 2020 16:56:25 +0100 Subject: [PATCH 33/57] issue/2714 Decoupled menu, page, article, block and component --- src/core/js/adapt.js | 161 +++++++++++++++----- src/core/js/app.js | 1 + src/core/js/data.js | 85 +++++------ src/core/js/models/adaptModel.js | 174 ++++++++++++---------- src/core/js/models/articleModel.js | 16 +- src/core/js/models/blockModel.js | 16 +- src/core/js/models/componentModel.js | 15 ++ src/core/js/models/contentObjectModel.js | 11 ++ src/core/js/models/courseModel.js | 10 ++ src/core/js/models/itemsComponentModel.js | 8 + src/core/js/models/itemsQuestionModel.js | 8 + src/core/js/models/menuModel.js | 14 +- src/core/js/models/pageModel.js | 14 +- src/core/js/models/questionModel.js | 8 + src/core/js/mpabc.js | 9 ++ src/core/js/router.js | 9 +- src/core/js/views/adaptView.js | 3 +- src/core/js/views/articleView.js | 9 +- src/core/js/views/blockView.js | 5 +- src/core/js/views/pageView.js | 8 +- 20 files changed, 400 insertions(+), 184 deletions(-) create mode 100644 src/core/js/mpabc.js diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index 80a414e82..ebc64c6f2 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -8,7 +8,7 @@ define([ initialize() { this.loadScript = window.__loadScript; this.location = {}; - this.componentStore = {}; + this.store = {}; this.setupWait(); } @@ -28,6 +28,14 @@ define([ }; } + /** + * @deprecated since v6.0.0 - please use `Adapt.store` instead + */ + get componentStore() { + this.log && this.log.deprecated('Adapt.componentStore, please use Adapt.store instead'); + return this.store; + } + init() { this.addDirection(); this.disableAnimation(); @@ -148,39 +156,133 @@ define([ scrollTo() {} /** - * Used to register components with the Adapt 'component store' - * @param {string} name The name of the component to be registered + * Used to register models and views with `Adapt.store` + * @param {string|Array} name The name(s) of the model/view to be registered * @param {object} object Object containing properties `model` and `view` or (legacy) an object representing the view */ register(name, object) { - if (this.componentStore[name]) { - throw Error('The component "' + name + '" already exists in your project'); + if (name instanceof Array) { + // if an array is passed, iterate by recursive call + name.forEach(name => this.register(name, object)); + return object; + } else if (name.split(' ').length > 1) { + // if name with spaces is passed, split and pass as array + this.register(name.split(' '), object); + return object; } - if (object.view) { - // use view+model object - if (!object.view.template) object.view.template = name; - } else { - // use view object - if (!object.template) object.template = name; + if (!object.view && !object.model || object instanceof Backbone.View) { + this.log && this.log.deprecated('View-only registrations are no longer supported'); + object = { view: object }; } - this.componentStore[name] = object; + if (object.view && !object.view.template) { + object.view.template = name; + } + + const isModelSetAndInvalid = (object.model && + (!object.model.prototype instanceof Backbone.Model) && + !(object.model instanceof Function)); + if (isModelSetAndInvalid) { + throw new Error('The registered model is not a Backbone.Model or Function'); + } + + const isViewSetAndInvalid = (object.view && + (!object.view.prototype instanceof Backbone.View) && + !(object.view instanceof Function)); + if (isViewSetAndInvalid) { + throw new Error('The registered view is not a Backbone.View or Function'); + } + + this.store[name] = Object.assign({}, this.store[name], object); return object; } /** - * Fetches a component view class from the componentStore. For a usage example, see either HotGraphic or Narrative - * @param {string} name The name of the componentView you want to fetch e.g. `"hotgraphic"` - * @returns {ComponentView} Reference to the view class + * Parses a view class name. + * @param {string|Backbone.Model|Backbone.View|object} nameModelViewOrData The name of the view class you want to fetch e.g. `"hotgraphic"` or its model or its json data + */ + getViewName(nameModelViewOrData) { + if (typeof nameModelViewOrData === "string") { + return nameModelViewOrData; + } + if (nameModelViewOrData instanceof Backbone.Model) { + nameModelViewOrData = nameModelViewOrData.toJSON(); + } + if (nameModelViewOrData instanceof Backbone.View) { + let foundName = null; + _.find(this.store, (entry, name) => { + if (!entry || !entry.view) return; + if (!(nameModelViewOrData instanceof entry.view)) return; + foundName = name; + return true; + }); + return foundName; + } + if (nameModelViewOrData instanceof Object) { + return typeof nameModelViewOrData._view === 'string' && nameModelViewOrData._view || + typeof nameModelViewOrData._component === 'string' && nameModelViewOrData._component || + typeof nameModelViewOrData._type === 'string' && nameModelViewOrData._type; + } + throw new Error('Cannot derive view class name from input'); + } + + /** + * Fetches a view class from the store. For a usage example, see either HotGraphic or Narrative + * @param {string|Backbone.Model|Backbone.View|object} nameModelViewOrData The name of the view class you want to fetch e.g. `"hotgraphic"` or its model or its json data + * @returns {Backbone.View} Reference to the view class + */ + getViewClass(nameModelViewOrData) { + const name = this.getViewName(nameModelViewOrData); + const object = this.store[name]; + if (!object) { + this.log.warn(`A view for '${name}' isn't registered in your project`); + return; + } + const isBackboneView = (object.view && object.view.prototype instanceof Backbone.View); + if (!isBackboneView && object.view instanceof Function) { + return object.view(); + } + return object.view; + } + + /** + * Parses a model class name. + * @param {string|Backbone.Model|object} name The name of the model you want to fetch e.g. `"hotgraphic"`, the model to process or its json data + */ + getModelName(nameModelOrData) { + if (typeof nameModelOrData === "string") { + return nameModelOrData; + } + if (nameModelOrData instanceof Backbone.Model) { + nameModelOrData = nameModelOrData.toJSON(); + } + if (nameModelOrData instanceof Object) { + return typeof nameModelOrData._model === 'string' && nameModelOrData._model || + typeof nameModelOrData._component === 'string' && nameModelOrData._component || + typeof nameModelOrData._type === 'string' && nameModelOrData._type; + } + throw new Error('Cannot derive model class name from input'); + } + + /** + * Fetches a model class from the store. For a usage example, see either HotGraphic or Narrative + * @param {string|Backbone.Model|object} name The name of the model you want to fetch e.g. `"hotgraphic"` or its json data + * @returns {Backbone.Model} Reference to the view class */ - getViewClass(name) { - const object = this.componentStore[name]; + getModelClass(nameModelOrData) { + const name = this.getModelName(nameModelOrData); + const object = this.store[name]; if (!object) { - throw Error('The component "' + name + '" doesn\'t exist in your project'); + this.log.warn(`A model for '${name}' isn't registered in your project`); + return; + } + const isBackboneModel = (object.model && object.model.prototype instanceof Backbone.Model); + if (!isBackboneModel && object.model instanceof Function) { + return object.model(); } - return object.view || object; + return object.model; } /** @@ -242,28 +344,13 @@ define([ * Trickle uses this function to determine where it should scrollTo after it unlocks */ parseRelativeString(relativeString) { - if (relativeString[0] === '@') { - relativeString = relativeString.substr(1); - } - - let type = relativeString.match(/(component|block|article|page|menu)/); - if (!type) { - this.log.error('Adapt.parseRelativeString() could not match relative type', relativeString); - return; - } - type = type[0]; - - const offset = parseInt(relativeString.substr(type.length).trim() || 0); - if (isNaN(offset)) { - this.log.error('Adapt.parseRelativeString() could not parse relative offset', relativeString); - return; - } - + const splitIndex = relativeString.search(/[ \+\-\d]{1}/); + const type = relativeString.slice(0, splitIndex).replace(/^\@/, ''); + const offset = parseInt(relativeString.slice(splitIndex).trim() || 0); return { type: type, offset: offset }; - } addDirection() { diff --git a/src/core/js/app.js b/src/core/js/app.js index 88496ac2b..c918ec963 100644 --- a/src/core/js/app.js +++ b/src/core/js/app.js @@ -10,6 +10,7 @@ require([ 'core/js/notify', 'core/js/router', 'core/js/models/lockingModel', + 'core/js/mpabc', 'core/js/helpers', 'core/js/scrolling', 'core/js/headings', diff --git a/src/core/js/data.js b/src/core/js/data.js index 818ce110f..d2e2db290 100644 --- a/src/core/js/data.js +++ b/src/core/js/data.js @@ -1,18 +1,12 @@ define([ 'core/js/adapt', 'core/js/collections/adaptCollection', - 'core/js/models/articleModel', - 'core/js/models/blockModel', 'core/js/models/configModel', - 'core/js/models/menuModel', - 'core/js/models/pageModel', - 'core/js/models/componentModel', 'core/js/models/courseModel', - 'core/js/models/questionModel', 'core/js/models/lockingModel', 'core/js/models/buildModel', 'core/js/startController' -], function(Adapt, AdaptCollection, ArticleModel, BlockModel, ConfigModel, MenuModel, PageModel, ComponentModel, CourseModel, QuestionModel) { +], function(Adapt, AdaptCollection, ConfigModel, CourseModel) { class Data extends Backbone.Controller { @@ -69,60 +63,47 @@ define([ // All code that needs to run before adapt starts should go here const language = Adapt.config.get('_activeLanguage'); const jsonext = Adapt.build.get('jsonext'); - const courseFolder = 'course/' + language + '/'; $('html').attr('lang', language); - Adapt.course = new CourseModel(null, { url: courseFolder + 'course.' + jsonext, reset: true }); + const getContentObjectModel = json => { + const ModelClass = Adapt.getModelClass(json) || Adapt.getModelClass('menu'); + if (!ModelClass) { + throw new Error(`Cannot find model for: ${Adapt.getModelName(json)}`); + } + return new ModelClass(json); + }; + + const getPath = name => `course/${language}/${name}.${jsonext}`; + + const getModel = json => { + const ModelClass = Adapt.getModelClass(json); + if (!ModelClass) { + throw new Error(`Cannot find model for: ${Adapt.getModelName(json)}`); + } + return new ModelClass(json); + }; + + Adapt.course = new CourseModel(null, { url: getPath('course'), reset: true }); Adapt.contentObjects = new AdaptCollection(null, { - model: json => { - switch (json._type) { - case 'page': - return new PageModel(json); - case 'menu': - return new MenuModel(json); - } - }, - url: courseFolder + 'contentObjects.' + jsonext + model: getContentObjectModel, + url: getPath('contentObjects') }); Adapt.articles = new AdaptCollection(null, { - model: ArticleModel, - url: courseFolder + 'articles.' + jsonext + model: getModel, + url: getPath('articles') }); Adapt.blocks = new AdaptCollection(null, { - model: BlockModel, - url: courseFolder + 'blocks.' + jsonext + model: getModel, + url: getPath('blocks') }); Adapt.components = new AdaptCollection(null, { - model: json => { - - // use view+model object - const ViewModelObject = Adapt.componentStore[json._component]; - - if (!ViewModelObject) { - throw new Error('One or more components of type "' + json._component + '" were included in the course - but no component of that type is installed...'); - } - - // if model defined for component use component model - if (ViewModelObject.model) { - // eslint-disable-next-line new-cap - return new ViewModelObject.model(json); - } - - const View = ViewModelObject.view || ViewModelObject; - // if question type use question model - if (View._isQuestionType) { - return new QuestionModel(json); - } - - // otherwise use component model - return new ComponentModel(json); - }, - url: courseFolder + 'components.' + jsonext + model: getModel, + url: getPath('components') }); } @@ -251,6 +232,16 @@ define([ return Adapt[collectionType]._byAdaptID[id][0]; } + filter(filter) { + const result = []; + filter(Adapt.course) && result.push(Adapt.course); + result.push(...Adapt.contentObjects.filter(filter)); + result.push(...Adapt.articles.filter(filter)); + result.push(...Adapt.blocks.filter(filter)); + result.push(...Adapt.components.filter(filter)); + return result; + } + } return (Adapt.data = new Data()); diff --git a/src/core/js/models/adaptModel.js b/src/core/js/models/adaptModel.js index 9f76e6961..c70c3e48e 100644 --- a/src/core/js/models/adaptModel.js +++ b/src/core/js/models/adaptModel.js @@ -49,14 +49,14 @@ define([ } setupModel() { - if (this._children) { + if (this.hasManagedChildren) { this.setupChildListeners(); } this.init(); _.defer(() => { - if (this._children) { + if (this.hasManagedChildren) { this.checkCompletionStatus(); this.checkInteractionCompletionStatus(); @@ -247,6 +247,52 @@ define([ Adapt.checkedCompletion(); } + /** + * Returns a string describing the type group of this model. + * Strings should be lowercase and not plurlaized. + * i.e. 'page', 'menu', 'contentobject', 'component', 'article', 'block' + * Override in inheritance chain. + * @returns {string} + */ + getTypeGroup() {} + + /** + * Returns true if this model is of the type group described. + * Automatically manages pluralization typeGroup and matches lowercase only. + * Pluralized typeGroups is discouraged. + * @param {string} type Type group name i.e. course, contentobject, article, block, component + * @returns {boolean} + */ + isTypeGroup(typeGroup) { + const pluralizedLowerCaseTypes = [ + typeGroup, + (typeGroup.slice(-1) === 's') && typeGroup.slice(0, -1), // remove pluralization if ending in s + (typeGroup.slice(-1) !== 's') && `${typeGroup}s` // pluralize if not ending in s + ].filter(Boolean).map(s => s.toLowerCase()); + const typeGroups = this.getTypeGroups(); + if (_.intersection(pluralizedLowerCaseTypes, typeGroups).length) { + return true; + } + return false; + } + + /** + * Returns an array of strings describing the model type groups. + * All strings are lowercase and should not be pluralized. + * i.e. ['course', 'menu', 'contentobject'], ['page', 'contentobject'], ['component'] + * @returns {[string]} + */ + getTypeGroups() { + if (this._typeGroups) return this._typeGroups; + const typeGroups = [ this.get('_type') ]; + let parentClass = this; + while (parentClass = Object.getPrototypeOf(parentClass)) { + if (!parentClass.hasOwnProperty('getTypeGroup')) continue; + typeGroups.push( parentClass.getTypeGroup.call(this) ); + } + return (this._typeGroups = _.uniq(typeGroups.filter(Boolean).map(s => s.toLowerCase()))); + } + /** * Searches the model's ancestors to find the first instance of the specified ancestor type * @param {string} [ancestorType] Valid values are 'course', 'pages', 'contentObjects', 'articles' or 'blocks'. @@ -256,19 +302,9 @@ define([ findAncestor(ancestorType) { const parent = this.getParent(); if (!parent) return; - - /** - * TODO: - * look to remove hard coded model types - */ - if (ancestorType === 'pages') { - ancestorType = 'contentObjects'; - } - - if (!ancestorType || this._parent === ancestorType) { + if (!ancestorType || parent.isTypeGroup(ancestorType)) { return parent; } - return parent.findAncestor(ancestorType); } @@ -283,21 +319,9 @@ define([ * this.findDescendantModels('components', { where: { _isAvailable: true, _isOptional: false }}); */ findDescendantModels(descendants, options) { - - const types = [ - descendants.slice(0, -1) - ]; - if (descendants === 'contentObjects') { - /** - * TODO: - * look to remove hard coded model types - */ - types.push('page', 'menu'); - } - const allDescendantsModels = this.getAllDescendantModels(); const returnedDescendants = allDescendantsModels.filter(model => { - return types.includes(model.get('_type')); + return model.isTypeGroup(descendants); }); if (!options) { @@ -337,8 +361,7 @@ define([ const descendants = []; - if (this.get('_type') === 'component') { - descendants.push(this); + if (!this.hasManagedChildren) { return descendants; } @@ -346,11 +369,9 @@ define([ children.models.forEach(child => { - if (child.get('_type') === 'component') { - + if (!child.hasManagedChildren) { descendants.push(child); return; - } const subDescendants = child.getAllDescendantModels(isParentFirst); @@ -386,44 +407,29 @@ define([ * @param {boolean} options.loop * @return {array} */ - findRelativeModel(relativeString, options) { - /** - * TODO: - * look to remove hard coded model types - */ - const types = [ 'menu', 'page', 'article', 'block', 'component' ]; - - options = options || {}; - - const modelId = this.get('_id'); - const modelType = this.get('_type'); - + findRelativeModel(relativeString, options = {}) { // return a model relative to the specified one if opinionated - let rootModel = Adapt.course; - if (options.limitParentId) { - rootModel = Adapt.findById(options.limitParentId); - } + const rootModel = options.limitParentId ? + Adapt.findById(options.limitParentId) : + Adapt.course; const relativeDescriptor = Adapt.parseRelativeString(relativeString); - - const findAncestorType = (types.indexOf(modelType) > types.indexOf(relativeDescriptor.type)); - const findSiblingType = (modelType === relativeDescriptor.type); - const searchBackwards = (relativeDescriptor.offset < 0); let moveBy = Math.abs(relativeDescriptor.offset); let movementCount = 0; - const findDescendantType = (!findSiblingType && !findAncestorType); - - if (findDescendantType) { + const hasDescendantsOfType = Boolean(this.findDescendantModels(relativeDescriptor.type).length); + if (hasDescendantsOfType) { // move by one less as first found is considered next + // will find descendants on either side but not inside moveBy--; } let pageDescendants; if (searchBackwards) { // parents first [p1,a1,b1,c1,c2,a2,b2,c3,c4,p2,a3,b3,c6,c7,a4,b4,c8,c9] - pageDescendants = rootModel.getAllDescendantModels(true); + pageDescendants = [rootModel]; + pageDescendants.push(...rootModel.getAllDescendantModels(true)); // reverse so that we don't need a forward and a backward iterating loop // reversed [c9,c8,b4,a4,c7,c6,b3,a3,p2,c4,c3,b2,a2,c2,c1,b1,a1,p1] @@ -431,6 +437,7 @@ define([ } else { // children first [c1,c2,b1,a1,c3,c4,b2,a2,p1,c6,c7,b3,a3,c8,c9,b4,a4,p2] pageDescendants = rootModel.getAllDescendantModels(false); + pageDescendants.push(rootModel); } // filter if opinionated @@ -439,6 +446,7 @@ define([ } // find current index in array + const modelId = this.get('_id'); const modelIndex = pageDescendants.findIndex(pageDescendant => { if (pageDescendant.get('_id') === modelId) { return true; @@ -447,24 +455,25 @@ define([ }); if (options.loop) { - // normalize offset position to allow for overflow looping - const typeCounts = {}; - pageDescendants.forEach(model => { - const type = model.get('_type'); - typeCounts[type] = typeCounts[type] || 0; - typeCounts[type]++; - }); - moveBy = moveBy % typeCounts[relativeDescriptor.type]; - + const totalOfType = pageDescendants.reduce((count, model) => { + if (!model.isTypeGroup(relativeDescriptor.type)) return count; + return ++count; + }, 0); + // take the remainder after removing whole units of the type count + moveBy = moveBy % totalOfType; // double up entries to allow for overflow looping pageDescendants = pageDescendants.concat(pageDescendants.slice(0)); - } for (let i = modelIndex, l = pageDescendants.length; i < l; i++) { const descendant = pageDescendants[i]; - if (descendant.get('_type') === relativeDescriptor.type) { + if (descendant.isTypeGroup(relativeDescriptor.type)) { + if (movementCount > moveBy) { + // there is no descendant which matches this relativeString + // probably looking for the descendant 0 in a parent + break; + } if (movementCount === moveBy) { return Adapt.findById(descendant.get('_id')); } @@ -472,7 +481,10 @@ define([ } } - return undefined; + } + + get hasManagedChildren() { + return true; } getChildren() { @@ -480,10 +492,11 @@ define([ let childrenCollection; - if (!this._children) { + if (!this.hasManagedChildren) { childrenCollection = new Backbone.Collection(); } else { - const children = Adapt[this._children].where({ _parentId: this.get('_id') }); + const id = this.get('_id'); + const children = Adapt.data.filter(model => model.get('_parentId') === id); childrenCollection = new Backbone.Collection(children); } @@ -510,13 +523,10 @@ define([ getParent() { if (this.get('_parent')) return this.get('_parent'); - if (this._parent === 'course') { - return Adapt.course; - } - const parent = Adapt.findById(this.get('_parentId')); + const parentId = this.get('_parentId'); + if (!parentId) return; + const parent = Adapt.findById(parentId); this.set('_parent', parent); - - // returns a parent model return parent; } @@ -535,15 +545,17 @@ define([ } getSiblings(passSiblingsAndIncludeSelf) { + const id = this.get('_id'); + const parentId = this.get('_parentId'); let siblings; if (!passSiblingsAndIncludeSelf) { // returns a collection of siblings excluding self if (this._hasSiblingsAndSelf === false) { return this.get('_siblings'); } - siblings = Adapt[this._siblings].filter(model => { - return model.get('_parentId') === this.get('_parentId') && - model.get('_id') !== this.get('_id'); + siblings = Adapt.data.filter(model => { + return model.get('_parentId') === parentId && + model.get('_id') !== id; }); this._hasSiblingsAndSelf = false; @@ -554,8 +566,8 @@ define([ return this.get('_siblings'); } - siblings = Adapt[this._siblings].where({ - _parentId: this.get('_parentId') + siblings = Adapt.data.filter(model => { + return model.get('_parentId') === parentId; }); this._hasSiblingsAndSelf = true; } @@ -574,7 +586,7 @@ define([ this.set(...args); - if (!this._children) return; + if (!this.hasManagedChildren) return; const children = this.getChildren(); children.models.forEach(child => child.setOnChildren(...args)); diff --git a/src/core/js/models/articleModel.js b/src/core/js/models/articleModel.js index 9a657a3ae..c7240d9d1 100644 --- a/src/core/js/models/articleModel.js +++ b/src/core/js/models/articleModel.js @@ -1,23 +1,37 @@ define([ + 'core/js/adapt', 'core/js/models/adaptModel' -], function (AdaptModel) { +], function (Adapt, AdaptModel) { class ArticleModel extends AdaptModel { get _parent() { + Adapt.log.deprecated('articleModel._parent, use articleModel.getParent() instead, parent models are defined by the JSON'); return 'contentObjects'; } get _siblings() { + Adapt.log.deprecated('articleModel._siblings, use articleModel.getSiblings() instead, sibling models are defined by the JSON'); return 'articles'; } get _children() { + Adapt.log.deprecated('articleModel._children, use articleModel.hasManagedChildren instead, child models are defined by the JSON'); return 'blocks'; } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'article'; + } + } + Adapt.register('article', { model: ArticleModel }); + return ArticleModel; }); diff --git a/src/core/js/models/blockModel.js b/src/core/js/models/blockModel.js index 5006f6589..fe8b5932c 100644 --- a/src/core/js/models/blockModel.js +++ b/src/core/js/models/blockModel.js @@ -1,23 +1,37 @@ define([ + 'core/js/adapt', 'core/js/models/adaptModel' -], function (AdaptModel) { +], function (Adapt, AdaptModel) { class BlockModel extends AdaptModel { get _parent() { + Adapt.log.deprecated('blockModel._parent, use blockModel.getParent() instead, parent models are defined by the JSON'); return 'articles'; } get _siblings() { + Adapt.log.deprecated('blockModel._siblings, use blockModel.getSiblings() instead, sibling models are defined by the JSON'); return 'blocks'; } get _children() { + Adapt.log.deprecated('blockModel._children, use blockModel.hasManagedChildren instead, child models are defined by the JSON'); return 'components'; } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'block'; + } + } + Adapt.register('block', { model: BlockModel }); + return BlockModel; }); diff --git a/src/core/js/models/componentModel.js b/src/core/js/models/componentModel.js index a581bbb56..452ee411f 100644 --- a/src/core/js/models/componentModel.js +++ b/src/core/js/models/componentModel.js @@ -5,13 +5,23 @@ define([ class ComponentModel extends AdaptModel { get _parent() { + Adapt.log.deprecated('componentModel._parent, use componentModel.getParent() instead, parent models are defined by the JSON'); return 'blocks'; } get _siblings() { + Adapt.log.deprecated('componentModel._siblings, use componentModel.getSiblings() instead, sibling models are defined by the JSON'); return 'components'; } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'component'; + } + defaults() { return AdaptModel.resultExtend('defaults', { _isA11yComponentDescriptionEnabled: true @@ -23,6 +33,11 @@ define([ '_userAnswer' ]); } + + get hasManagedChildren() { + return false; + } + } return ComponentModel; diff --git a/src/core/js/models/contentObjectModel.js b/src/core/js/models/contentObjectModel.js index 81f9a0125..1e9d48733 100644 --- a/src/core/js/models/contentObjectModel.js +++ b/src/core/js/models/contentObjectModel.js @@ -6,6 +6,7 @@ define([ class ContentObjectModel extends AdaptModel { get _parent() { + Adapt.log.deprecated('contentObjectModel._parent, use contentObjectModel.getParent() instead, parent models are defined by the JSON'); const isParentCourse = (this.get('_parentId') === Adapt.course.get('_id')); if (isParentCourse) { return 'course'; @@ -14,13 +15,23 @@ define([ } get _siblings() { + Adapt.log.deprecated('contentObjectModel._siblings, use contentObjectModel.getSiblings() instead, sibling models are defined by the JSON'); return 'contentObjects'; } get _children() { + Adapt.log.deprecated('contentObjectModel._children, use contentObjectModel.hasManagedChildren instead, child models are defined by the JSON'); return null; } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'contentobject'; + } + } return ContentObjectModel; diff --git a/src/core/js/models/courseModel.js b/src/core/js/models/courseModel.js index 6db0459b4..6065a40b5 100644 --- a/src/core/js/models/courseModel.js +++ b/src/core/js/models/courseModel.js @@ -6,13 +6,23 @@ define([ class CourseModel extends MenuModel { get _parent() { + Adapt.log.deprecated('courseModel._parent, use courseModel.getParent() instead, parent models are defined by the JSON'); return null; } get _siblings() { + Adapt.log.deprecated('courseModel._siblings, use courseModel.getSiblings() instead, sibling models are defined by the JSON'); return null; } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'course'; + } + initialize(attrs, options) { super.initialize(arguments); Adapt.trigger('courseModel:dataLoading'); diff --git a/src/core/js/models/itemsComponentModel.js b/src/core/js/models/itemsComponentModel.js index d18bd2c03..345411bcc 100644 --- a/src/core/js/models/itemsComponentModel.js +++ b/src/core/js/models/itemsComponentModel.js @@ -19,6 +19,14 @@ define([ }); } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'itemscomponent'; + } + setUpItems() { // see https://github.com/adaptlearning/adapt_framework/issues/2480 const items = this.get('_items') || []; diff --git a/src/core/js/models/itemsQuestionModel.js b/src/core/js/models/itemsQuestionModel.js index 789959a78..929dac6b0 100644 --- a/src/core/js/models/itemsQuestionModel.js +++ b/src/core/js/models/itemsQuestionModel.js @@ -32,6 +32,14 @@ define([ this.set('_isRadio', this.isSingleSelect()); } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'itemsquestion'; + } + restoreUserAnswers() { if (!this.get('_isSubmitted')) return; diff --git a/src/core/js/models/menuModel.js b/src/core/js/models/menuModel.js index 756b960de..2ee5edfa2 100644 --- a/src/core/js/models/menuModel.js +++ b/src/core/js/models/menuModel.js @@ -1,13 +1,23 @@ define([ + 'core/js/adapt', 'core/js/models/contentObjectModel' -], function (ContentObjectModel) { +], function (Adapt, ContentObjectModel) { class MenuModel extends ContentObjectModel { get _children() { + Adapt.log.deprecated('menuModel._children, use menuModel.hasManagedChildren instead, child models are defined by the JSON'); return 'contentObjects'; } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'menu'; + } + setCustomLocking() { const children = this.getAvailableChildModels(); children.forEach(child => { @@ -19,6 +29,8 @@ define([ } + Adapt.register('menu', { model: MenuModel }); + return MenuModel; }); diff --git a/src/core/js/models/pageModel.js b/src/core/js/models/pageModel.js index d19f7dc7a..1daa2f13c 100644 --- a/src/core/js/models/pageModel.js +++ b/src/core/js/models/pageModel.js @@ -1,15 +1,27 @@ define([ + 'core/js/adapt', 'core/js/models/contentObjectModel' -], function (ContentObjectModel) { +], function (Adapt, ContentObjectModel) { class PageModel extends ContentObjectModel { get _children() { + Adapt.log.deprecated('pageModel._children, use menuModel.hasManagedChildren instead, child models are defined by the JSON'); return 'articles'; } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'page'; + } + } + Adapt.register('page', { model: PageModel }); + return PageModel; }); diff --git a/src/core/js/models/questionModel.js b/src/core/js/models/questionModel.js index f8988a8db..ef098874e 100644 --- a/src/core/js/models/questionModel.js +++ b/src/core/js/models/questionModel.js @@ -35,6 +35,14 @@ define([ ]); } + /** + * Returns a string of the model type group. + * @returns {string} + */ + getTypeGroup() { + return 'question'; + } + init() { this.setupDefaultSettings(); this.listenToOnce(Adapt, 'adapt:initialize', this.onAdaptInitialize); diff --git a/src/core/js/mpabc.js b/src/core/js/mpabc.js new file mode 100644 index 000000000..cadd7b0ea --- /dev/null +++ b/src/core/js/mpabc.js @@ -0,0 +1,9 @@ +define([ + 'core/js/models/menuModel', + 'core/js/models/pageModel', + 'core/js/models/articleModel', + 'core/js/models/blockModel', + 'core/js/views/pageView', + 'core/js/views/articleView', + 'core/js/views/blockView' +], function(menuModel, PageModel, ArticleModel, BlockModel, PageView, ArticleView, BlockView) {}); diff --git a/src/core/js/router.js b/src/core/js/router.js index deab47fc5..e0e61a7fe 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -4,9 +4,8 @@ define([ 'core/js/models/courseModel', 'core/js/models/contentObjectModel', 'core/js/models/menuModel', - 'core/js/views/pageView', 'core/js/startController' -], function(Adapt, RouterModel, CourseModel, ContentObjectModel, MenuModel, PageView) { +], function(Adapt, RouterModel, CourseModel, ContentObjectModel, MenuModel) { class Router extends Backbone.Router { @@ -193,11 +192,13 @@ define([ }); Adapt.trigger(`router:${type} router:contentObject`, model); + const ViewClass = Adapt.getViewClass(model); const isMenu = (model instanceof MenuModel); - if (isMenu) { + if (!ViewClass && isMenu) { + Adapt.log.deprecated(`Using event based menu view instantiation for '${Adapt.getViewName(model)}'`); return; } - this.$wrapper.append(new PageView({ model }).$el); + this.$wrapper.append(new ViewClass({ model }).$el); } async updateLocation(currentLocation, type, id, currentModel) { diff --git a/src/core/js/views/adaptView.js b/src/core/js/views/adaptView.js index 7e19b711a..0185fcf48 100644 --- a/src/core/js/views/adaptView.js +++ b/src/core/js/views/adaptView.js @@ -83,8 +83,7 @@ define([ nthChild++; model.set('_nthChild', nthChild); - const ViewModelObject = this.constructor.childView || Adapt.componentStore[model.get('_component')]; - const ChildView = ViewModelObject.view || ViewModelObject; + const ChildView = this.constructor.childView || Adapt.getViewClass(model); if (!ChildView) { throw new Error(`The component '${model.attributes._id}' ('${model.attributes._component}') has not been installed, and so is not available in your project.`); diff --git a/src/core/js/views/articleView.js b/src/core/js/views/articleView.js index 25b176d5f..d38c0a654 100644 --- a/src/core/js/views/articleView.js +++ b/src/core/js/views/articleView.js @@ -1,7 +1,7 @@ define([ - 'core/js/views/adaptView', - 'core/js/views/blockView' -], function(AdaptView, BlockView) { + 'core/js/adapt', + 'core/js/views/adaptView' +], function(Adapt, AdaptView) { class ArticleView extends AdaptView { @@ -21,11 +21,12 @@ define([ Object.assign(ArticleView, { childContainer: '.block__container', - childView: BlockView, type: 'article', template: 'article' }); + Adapt.register('article', { view: ArticleView }); + return ArticleView; }); diff --git a/src/core/js/views/blockView.js b/src/core/js/views/blockView.js index 0c367f9b4..a9bfcfa2a 100644 --- a/src/core/js/views/blockView.js +++ b/src/core/js/views/blockView.js @@ -1,6 +1,7 @@ define([ + 'core/js/adapt', 'core/js/views/adaptView' -], function(AdaptView) { +], function(Adapt, AdaptView) { class BlockView extends AdaptView { @@ -24,6 +25,8 @@ define([ template: 'block' }); + Adapt.register('block', { view: BlockView }); + return BlockView; }); diff --git a/src/core/js/views/pageView.js b/src/core/js/views/pageView.js index 74dbba011..cd4c5a12b 100644 --- a/src/core/js/views/pageView.js +++ b/src/core/js/views/pageView.js @@ -1,8 +1,7 @@ define([ 'core/js/adapt', - 'core/js/views/contentObjectView', - 'core/js/views/articleView' -], function(Adapt, ContentObjectView, ArticleView) { + 'core/js/views/contentObjectView' +], function(Adapt, ContentObjectView) { class PageView extends ContentObjectView { @@ -17,11 +16,12 @@ define([ Object.assign(PageView, { childContainer: '.article__container', - childView: ArticleView, type: 'page', template: 'page' }); + Adapt.register('page', { view: PageView }); + return PageView; }); From 53264e547025860e4d10d9e8fff899936dffd3da Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 2 Apr 2020 17:25:19 +0100 Subject: [PATCH 34/57] Typo --- src/core/js/mpabc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/js/mpabc.js b/src/core/js/mpabc.js index cadd7b0ea..cbb2aa09a 100644 --- a/src/core/js/mpabc.js +++ b/src/core/js/mpabc.js @@ -6,4 +6,4 @@ define([ 'core/js/views/pageView', 'core/js/views/articleView', 'core/js/views/blockView' -], function(menuModel, PageModel, ArticleModel, BlockModel, PageView, ArticleView, BlockView) {}); +], function(MenuModel, PageModel, ArticleModel, BlockModel, PageView, ArticleView, BlockView) {}); From 76cdf72c69d4eba7e498be6f2e69bf9fd45099bb Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Sat, 4 Apr 2020 15:22:35 +0100 Subject: [PATCH 35/57] Recommendations --- src/core/js/adapt.js | 36 +++++++++++++++++++++++--------- src/core/js/data.js | 5 +++++ src/core/js/models/adaptModel.js | 22 ++++++++++++++----- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index ebc64c6f2..42f036aad 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -165,7 +165,9 @@ define([ // if an array is passed, iterate by recursive call name.forEach(name => this.register(name, object)); return object; - } else if (name.split(' ').length > 1) { + } + + if (name.split(' ').length > 1) { // if name with spaces is passed, split and pass as array this.register(name.split(' '), object); return object; @@ -181,14 +183,14 @@ define([ } const isModelSetAndInvalid = (object.model && - (!object.model.prototype instanceof Backbone.Model) && + !(object.model.prototype instanceof Backbone.Model) && !(object.model instanceof Function)); if (isModelSetAndInvalid) { throw new Error('The registered model is not a Backbone.Model or Function'); } const isViewSetAndInvalid = (object.view && - (!object.view.prototype instanceof Backbone.View) && + !(object.view.prototype instanceof Backbone.View) && !(object.view instanceof Function)); if (isViewSetAndInvalid) { throw new Error('The registered view is not a Backbone.View or Function'); @@ -211,7 +213,7 @@ define([ nameModelViewOrData = nameModelViewOrData.toJSON(); } if (nameModelViewOrData instanceof Backbone.View) { - let foundName = null; + let foundName; _.find(this.store, (entry, name) => { if (!entry || !entry.view) return; if (!(nameModelViewOrData instanceof entry.view)) return; @@ -221,9 +223,16 @@ define([ return foundName; } if (nameModelViewOrData instanceof Object) { - return typeof nameModelViewOrData._view === 'string' && nameModelViewOrData._view || - typeof nameModelViewOrData._component === 'string' && nameModelViewOrData._component || - typeof nameModelViewOrData._type === 'string' && nameModelViewOrData._type; + const names = [ + typeof nameModelViewOrData._view === 'string' && nameModelViewOrData._view, + typeof nameModelViewOrData._component === 'string' && nameModelViewOrData._component, + typeof nameModelViewOrData._type === 'string' && nameModelViewOrData._type + ].filter(Boolean); + if (names.length) { + // find first fitting view name + const name = names.find(name => this.store[name] && this.store[name].view); + return name; + } } throw new Error('Cannot derive view class name from input'); } @@ -259,9 +268,16 @@ define([ nameModelOrData = nameModelOrData.toJSON(); } if (nameModelOrData instanceof Object) { - return typeof nameModelOrData._model === 'string' && nameModelOrData._model || - typeof nameModelOrData._component === 'string' && nameModelOrData._component || - typeof nameModelOrData._type === 'string' && nameModelOrData._type; + const names = [ + typeof nameModelOrData._model === 'string' && nameModelOrData._model, + typeof nameModelOrData._component === 'string' && nameModelOrData._component, + typeof nameModelOrData._type === 'string' && nameModelOrData._type + ].filter(Boolean); + if (names.length) { + // find first fitting model name + const name = names.find(name => this.store[name] && this.store[name].model); + return name; + } } throw new Error('Cannot derive model class name from input'); } diff --git a/src/core/js/data.js b/src/core/js/data.js index d2e2db290..f87d32ffd 100644 --- a/src/core/js/data.js +++ b/src/core/js/data.js @@ -232,6 +232,11 @@ define([ return Adapt[collectionType]._byAdaptID[id][0]; } + /** + * Filter all models. + * @param {Function} filter + * @returns {Array} + */ filter(filter) { const result = []; filter(Adapt.course) && result.push(Adapt.course); diff --git a/src/core/js/models/adaptModel.js b/src/core/js/models/adaptModel.js index c70c3e48e..ffdac12ec 100644 --- a/src/core/js/models/adaptModel.js +++ b/src/core/js/models/adaptModel.js @@ -259,16 +259,28 @@ define([ /** * Returns true if this model is of the type group described. * Automatically manages pluralization typeGroup and matches lowercase only. - * Pluralized typeGroups is discouraged. + * Pluralized typeGroups and uppercase characters in typeGroups are discouraged. * @param {string} type Type group name i.e. course, contentobject, article, block, component * @returns {boolean} */ isTypeGroup(typeGroup) { + const hasUpperCase = /[A-Z]+/.test(typeGroup); + const isPluralized = typeGroup.slice(-1) === 's'; + const lowerCased = typeGroup.toLowerCase(); + const singular = isPluralized && lowerCased.slice(0, -1); // remove pluralization if ending in s + const singularLowerCased = (singular || lowerCased).toLowerCase(); + if (isPluralized || hasUpperCase) { + const message = (isPluralized && hasUpperCase) ? + `'${typeGroup}' appears pluralized and contains uppercase characters, suggest using the singular, lowercase type group '${singularLowerCased}'.` : + isPluralized ? + `'${typeGroup}' appears pluralized, suggest using the singular type group '${singularLowerCased}'.` : + `'${typeGroup}' contains uppercase characters, suggest using lowercase type group '${singularLowerCased}'.`; + Adapt.log.deprecated(message); + } const pluralizedLowerCaseTypes = [ - typeGroup, - (typeGroup.slice(-1) === 's') && typeGroup.slice(0, -1), // remove pluralization if ending in s - (typeGroup.slice(-1) !== 's') && `${typeGroup}s` // pluralize if not ending in s - ].filter(Boolean).map(s => s.toLowerCase()); + singularLowerCased, + !isPluralized && `${lowerCased}s` // pluralize if not ending in s + ].filter(Boolean); const typeGroups = this.getTypeGroups(); if (_.intersection(pluralizedLowerCaseTypes, typeGroups).length) { return true; From 818dd05b3c89ea35029a6f26fc04dd3f1d9f7fdb Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Mon, 6 Apr 2020 09:02:20 +0100 Subject: [PATCH 36/57] issue/2714: Added Adapt.log.warnOnce --- src/core/js/adapt.js | 4 ++-- src/core/js/logging.js | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index 42f036aad..ea6169018 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -246,7 +246,7 @@ define([ const name = this.getViewName(nameModelViewOrData); const object = this.store[name]; if (!object) { - this.log.warn(`A view for '${name}' isn't registered in your project`); + this.log.warnOnce(`A view for '${name}' isn't registered in your project`); return; } const isBackboneView = (object.view && object.view.prototype instanceof Backbone.View); @@ -291,7 +291,7 @@ define([ const name = this.getModelName(nameModelOrData); const object = this.store[name]; if (!object) { - this.log.warn(`A model for '${name}' isn't registered in your project`); + this.log.warnOnce(`A model for '${name}' isn't registered in your project`); return; } const isBackboneModel = (object.model && object.model.prototype instanceof Backbone.Model); diff --git a/src/core/js/logging.js b/src/core/js/logging.js index 63afa4f86..7a27113db 100644 --- a/src/core/js/logging.js +++ b/src/core/js/logging.js @@ -72,14 +72,15 @@ define([ removed(...args) { args = ['REMOVED'].concat(args); - if (this._hasWarned(args)) { - return; - } - this._log(LOG_LEVEL.WARN, args); + this.warnOnce(...args); } deprecated(...args) { args = ['DEPRECATED'].concat(args); + this.warnOnce(...args); + } + + warnOnce(...args) { if (this._hasWarned(args)) { return; } From 2bb469b6da233e8806c20aeb76728c6536950fb8 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Mon, 6 Apr 2020 09:11:05 +0100 Subject: [PATCH 37/57] Linting fixes --- src/core/js/adapt.js | 14 +++++++------- src/core/js/models/adaptModel.js | 10 +++++----- src/core/js/models/componentModel.js | 3 ++- src/core/js/router.js | 2 +- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index ea6169018..c42468a6c 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -32,8 +32,8 @@ define([ * @deprecated since v6.0.0 - please use `Adapt.store` instead */ get componentStore() { - this.log && this.log.deprecated('Adapt.componentStore, please use Adapt.store instead'); - return this.store; + this.log && this.log.deprecated('Adapt.componentStore, please use Adapt.store instead'); + return this.store; } init() { @@ -173,7 +173,7 @@ define([ return object; } - if (!object.view && !object.model || object instanceof Backbone.View) { + if ((!object.view && !object.model) || object instanceof Backbone.View) { this.log && this.log.deprecated('View-only registrations are no longer supported'); object = { view: object }; } @@ -206,7 +206,7 @@ define([ * @param {string|Backbone.Model|Backbone.View|object} nameModelViewOrData The name of the view class you want to fetch e.g. `"hotgraphic"` or its model or its json data */ getViewName(nameModelViewOrData) { - if (typeof nameModelViewOrData === "string") { + if (typeof nameModelViewOrData === 'string') { return nameModelViewOrData; } if (nameModelViewOrData instanceof Backbone.Model) { @@ -261,7 +261,7 @@ define([ * @param {string|Backbone.Model|object} name The name of the model you want to fetch e.g. `"hotgraphic"`, the model to process or its json data */ getModelName(nameModelOrData) { - if (typeof nameModelOrData === "string") { + if (typeof nameModelOrData === 'string') { return nameModelOrData; } if (nameModelOrData instanceof Backbone.Model) { @@ -360,8 +360,8 @@ define([ * Trickle uses this function to determine where it should scrollTo after it unlocks */ parseRelativeString(relativeString) { - const splitIndex = relativeString.search(/[ \+\-\d]{1}/); - const type = relativeString.slice(0, splitIndex).replace(/^\@/, ''); + const splitIndex = relativeString.search(/[ +\-\d]{1}/); + const type = relativeString.slice(0, splitIndex).replace(/^@/, ''); const offset = parseInt(relativeString.slice(splitIndex).trim() || 0); return { type: type, diff --git a/src/core/js/models/adaptModel.js b/src/core/js/models/adaptModel.js index ffdac12ec..caff07bdf 100644 --- a/src/core/js/models/adaptModel.js +++ b/src/core/js/models/adaptModel.js @@ -273,8 +273,8 @@ define([ const message = (isPluralized && hasUpperCase) ? `'${typeGroup}' appears pluralized and contains uppercase characters, suggest using the singular, lowercase type group '${singularLowerCased}'.` : isPluralized ? - `'${typeGroup}' appears pluralized, suggest using the singular type group '${singularLowerCased}'.` : - `'${typeGroup}' contains uppercase characters, suggest using lowercase type group '${singularLowerCased}'.`; + `'${typeGroup}' appears pluralized, suggest using the singular type group '${singularLowerCased}'.` : + `'${typeGroup}' contains uppercase characters, suggest using lowercase type group '${singularLowerCased}'.`; Adapt.log.deprecated(message); } const pluralizedLowerCaseTypes = [ @@ -298,11 +298,11 @@ define([ if (this._typeGroups) return this._typeGroups; const typeGroups = [ this.get('_type') ]; let parentClass = this; - while (parentClass = Object.getPrototypeOf(parentClass)) { + while ((parentClass = Object.getPrototypeOf(parentClass))) { if (!parentClass.hasOwnProperty('getTypeGroup')) continue; - typeGroups.push( parentClass.getTypeGroup.call(this) ); + typeGroups.push(parentClass.getTypeGroup.call(this)); } - return (this._typeGroups = _.uniq(typeGroups.filter(Boolean).map(s => s.toLowerCase()))); + return (this._typeGroups = _.uniq(typeGroups.filter(Boolean).map(s => s.toLowerCase()))); } /** diff --git a/src/core/js/models/componentModel.js b/src/core/js/models/componentModel.js index 452ee411f..84c253674 100644 --- a/src/core/js/models/componentModel.js +++ b/src/core/js/models/componentModel.js @@ -1,6 +1,7 @@ define([ + 'core/js/adapt', 'core/js/models/adaptModel' -], function (AdaptModel) { +], function (Adapt, AdaptModel) { class ComponentModel extends AdaptModel { diff --git a/src/core/js/router.js b/src/core/js/router.js index e0e61a7fe..a15596827 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -236,7 +236,7 @@ define([ setGlobalClasses() { const currentModel = Adapt.location._currentModel; - + const htmlClasses = (currentModel && currentModel.get('_htmlClasses')) || ''; const classes = (Adapt.location._currentId) ? `location-${Adapt.location._contentType} location-id-${Adapt.location._currentId}` : From b4abd4be8d9b3caf220eb0a2790d4fd390aad18b Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Mon, 6 Apr 2020 09:33:10 +0100 Subject: [PATCH 38/57] issue/2714 Added fallback model/view name discovery --- src/core/js/adapt.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index c42468a6c..1677d2532 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -231,7 +231,7 @@ define([ if (names.length) { // find first fitting view name const name = names.find(name => this.store[name] && this.store[name].view); - return name; + return name || names.pop(); // return last available if none found } } throw new Error('Cannot derive view class name from input'); @@ -276,7 +276,7 @@ define([ if (names.length) { // find first fitting model name const name = names.find(name => this.store[name] && this.store[name].model); - return name; + return name || names.pop(); // return last available if none found } } throw new Error('Cannot derive model class name from input'); From a861913c9a037caccb736043be729b0329d83500 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Mon, 6 Apr 2020 09:39:24 +0100 Subject: [PATCH 39/57] issue/2645 Added manifest creation and loading --- grunt/config/watch.js | 4 +- grunt/tasks/build.js | 1 + grunt/tasks/dev.js | 1 + grunt/tasks/diff.js | 1 + grunt/tasks/language-data-manifests.js | 7 + grunt/tasks/server-build.js | 1 + src/core/js/collections/adaptCollection.js | 6 - .../js/collections/adaptSubsetCollection.js | 23 ++ src/core/js/data.js | 256 +++++++----------- src/core/js/models/buildModel.js | 2 +- src/core/js/models/courseModel.js | 14 +- src/core/js/mpabc.js | 78 +++++- 12 files changed, 217 insertions(+), 177 deletions(-) create mode 100644 grunt/tasks/language-data-manifests.js create mode 100644 src/core/js/collections/adaptSubsetCollection.js diff --git a/grunt/config/watch.js b/grunt/config/watch.js index 46fe07361..c40ffa001 100644 --- a/grunt/config/watch.js +++ b/grunt/config/watch.js @@ -17,8 +17,8 @@ module.exports = { tasks: ['handlebars', 'javascript:dev'] }, courseJson: { - files: ['<%= sourcedir %>course/**/*.<%= jsonext %>'], - tasks: ['jsonlint', 'check-json', 'newer:copy:courseJson', 'schema-defaults'] + files: ['<%= sourcedir %>course/**/*.<%= jsonext %>', '<%= outputdir %>course/*/language_data_manifest.js'], + tasks: ['language-data-manifests', 'jsonlint', 'check-json', 'newer:copy:courseJson', 'schema-defaults'] }, courseAssets: { files: ['<%= sourcedir %>course/<%=languages%>/*', '!<%= sourcedir %>course/<%=languages%>/*.<%= jsonext %>'], diff --git a/grunt/tasks/build.js b/grunt/tasks/build.js index 76e5d3d00..042977768 100644 --- a/grunt/tasks/build.js +++ b/grunt/tasks/build.js @@ -9,6 +9,7 @@ module.exports = function(grunt) { 'build-config', 'copy', 'schema-defaults', + 'language-data-manifests', 'handlebars', 'tracking-insert', 'javascript:compile', diff --git a/grunt/tasks/dev.js b/grunt/tasks/dev.js index 9a3f0aeef..450f903bf 100644 --- a/grunt/tasks/dev.js +++ b/grunt/tasks/dev.js @@ -8,6 +8,7 @@ module.exports = function(grunt) { 'build-config', 'copy', 'schema-defaults', + 'language-data-manifests', 'handlebars', 'tracking-insert', 'javascript:dev', diff --git a/grunt/tasks/diff.js b/grunt/tasks/diff.js index 9f6f93da9..53f7694d3 100644 --- a/grunt/tasks/diff.js +++ b/grunt/tasks/diff.js @@ -8,6 +8,7 @@ module.exports = function(grunt) { 'build-config', 'copy', 'schema-defaults', + 'language-data-manifests', 'newer:handlebars:compile', 'tracking-insert', 'newer:javascript:dev', diff --git a/grunt/tasks/language-data-manifests.js b/grunt/tasks/language-data-manifests.js new file mode 100644 index 000000000..a38f232e7 --- /dev/null +++ b/grunt/tasks/language-data-manifests.js @@ -0,0 +1,7 @@ +module.exports = function(grunt) { + const Helpers = require('../helpers')(grunt); + grunt.registerTask('language-data-manifests', 'Creates a manifest for each set of language data files', function() { + const languages = Helpers.getFramework({ useOutputData: true }).getData().languages; + languages.forEach(language => language.saveManifest()); + }); +}; diff --git a/grunt/tasks/server-build.js b/grunt/tasks/server-build.js index ff82e5983..afebcf936 100644 --- a/grunt/tasks/server-build.js +++ b/grunt/tasks/server-build.js @@ -9,6 +9,7 @@ module.exports = function(grunt) { '_log-vars', 'build-config', 'copy', + 'language-data-manifests', 'less:' + requireMode, 'handlebars', 'javascript:' + requireMode, diff --git a/src/core/js/collections/adaptCollection.js b/src/core/js/collections/adaptCollection.js index 03566c559..500e65b04 100644 --- a/src/core/js/collections/adaptCollection.js +++ b/src/core/js/collections/adaptCollection.js @@ -5,13 +5,7 @@ define([ class AdaptCollection extends Backbone.Collection { initialize(models, options) { - this.url = options.url; this.once('reset', this.loadedData, this); - if (!this.url) return; - this.fetch({ - reset: true, - error: () => console.error('ERROR: unable to load file ' + this.url) - }); } loadedData() { diff --git a/src/core/js/collections/adaptSubsetCollection.js b/src/core/js/collections/adaptSubsetCollection.js new file mode 100644 index 000000000..ba6c188a1 --- /dev/null +++ b/src/core/js/collections/adaptSubsetCollection.js @@ -0,0 +1,23 @@ +define([ + 'core/js/adapt', + './adaptCollection' +], function(Adapt, AdaptCollection) { + + class AdaptSubsetCollection extends AdaptCollection { + + initialize(models, options) { + super.initialize(models, options); + this.parent = options.parent; + this.listenTo(this.parent, 'reset', this.loadSubset); + } + + loadSubset() { + this.set(this.parent.filter(model => model instanceof this.model)); + this._byAdaptID = this.groupBy('_id'); + } + + } + + return AdaptSubsetCollection; + +}); diff --git a/src/core/js/data.js b/src/core/js/data.js index f87d32ffd..258a9bcbf 100644 --- a/src/core/js/data.js +++ b/src/core/js/data.js @@ -1,38 +1,37 @@ define([ 'core/js/adapt', 'core/js/collections/adaptCollection', + 'core/js/models/buildModel', 'core/js/models/configModel', 'core/js/models/courseModel', 'core/js/models/lockingModel', - 'core/js/models/buildModel', 'core/js/startController' -], function(Adapt, AdaptCollection, ConfigModel, CourseModel) { +], function(Adapt, AdaptCollection, BuildModel, ConfigModel, CourseModel) { - class Data extends Backbone.Controller { + class Data extends AdaptCollection { - initialize() { - this.mappedIds = {}; + model(json) { + const ModelClass = Adapt.getModelClass(json); + if (!ModelClass) { + return new Backbone.Model(json); + } + return new ModelClass(json); } - init () { - Adapt.build.whenReady().then(this.onBuildDataLoaded.bind(this)); + async init () { + Adapt.build = new BuildModel(null, { url: 'adapt/js/build.min.js', reset: true }); + await Adapt.build.whenReady(); + $('html').attr('data-adapt-framework-version', Adapt.build.get('package').version); + this.loadConfigData(); } - onBuildDataLoaded() { - $('html').attr('data-adapt-framework-version', Adapt.build.get('package').version); + loadConfigData() { Adapt.config = new ConfigModel(null, { url: 'course/config.' + Adapt.build.get('jsonext'), reset: true }); + this.listenToOnce(Adapt, 'configModel:loadCourseData', this.onLoadCourseData); this.listenTo(Adapt.config, { 'change:_activeLanguage': this.onLanguageChange, 'change:_defaultDirection': this.onDirectionChange }); - - // Events that are triggered by the main Adapt content collections and models - this.listenToOnce(Adapt, 'configModel:loadCourseData', this.onLoadCourseData); - } - - onLanguageChange(model, language) { - Adapt.offlineStorage.set('lang', language); - this.loadCourseData(this.triggerDataReady.bind(this), language); } onDirectionChange(model, direction) { @@ -48,141 +47,116 @@ define([ * If it has we can go ahead and start loading; if it hasn't, apply the defaultLanguage from config.json */ onLoadCourseData() { - if (Adapt.config.get('_activeLanguage')) { - this.loadCourseData(this.triggerDataReady.bind(this)); - } else { + if (!Adapt.config.get('_activeLanguage')) { Adapt.config.set('_activeLanguage', Adapt.config.get('_defaultLanguage')); + return; } + this.loadCourseData(); } - loadCourseData(callback, newLanguage) { - this.listenTo(Adapt, 'adaptCollection:dataLoaded courseModel:dataLoaded', () => { - this.checkDataIsLoaded(callback, newLanguage); - }); + onLanguageChange(model, language) { + Adapt.offlineStorage.set('lang', language); + this.loadCourseData(language); + } + + async loadCourseData(newLanguage) { // All code that needs to run before adapt starts should go here const language = Adapt.config.get('_activeLanguage'); - const jsonext = Adapt.build.get('jsonext'); + const courseFolder = 'course/' + language + '/'; $('html').attr('lang', language); - const getContentObjectModel = json => { - const ModelClass = Adapt.getModelClass(json) || Adapt.getModelClass('menu'); - if (!ModelClass) { - throw new Error(`Cannot find model for: ${Adapt.getModelName(json)}`); - } - return new ModelClass(json); - }; - - const getPath = name => `course/${language}/${name}.${jsonext}`; - - const getModel = json => { - const ModelClass = Adapt.getModelClass(json); - if (!ModelClass) { - throw new Error(`Cannot find model for: ${Adapt.getModelName(json)}`); - } - return new ModelClass(json); - }; - - Adapt.course = new CourseModel(null, { url: getPath('course'), reset: true }); - - Adapt.contentObjects = new AdaptCollection(null, { - model: getContentObjectModel, - url: getPath('contentObjects') - }); - - Adapt.articles = new AdaptCollection(null, { - model: getModel, - url: getPath('articles') - }); + await this.loadManifestFiles(courseFolder); + await this.triggerDataLoaded(); + await this.triggerDataReady(newLanguage); + this.triggerInit(); - Adapt.blocks = new AdaptCollection(null, { - model: getModel, - url: getPath('blocks') - }); + } - Adapt.components = new AdaptCollection(null, { - model: getModel, - url: getPath('components') + getJSON(path) { + return new Promise((resolve, reject) => { + $.getJSON(path, data => { + // Add path to data incase it's necessary later + data.__path__ = path; + resolve(data); + }).fail(reject); }); } - checkDataIsLoaded(callback, newLanguage) { - if (Adapt.contentObjects.models.length > 0 && - Adapt.articles.models.length > 0 && - Adapt.blocks.models.length > 0 && - Adapt.components.models.length > 0 && - Adapt.course.get('_id')) { - - this.mapAdaptIdsToObjects(); - - Adapt.log.debug('Firing app:dataLoaded'); - - try { - Adapt.trigger('app:dataLoaded');// Triggered to setup model connections in AdaptModel.js - } catch (e) { - Adapt.log.error('Error during app:dataLoading trigger', e); + async loadManifestFiles(languagePath) { + this.trigger('loading'); + this.reset(); + const manifestPath = languagePath + 'language_data_manifest.js'; + let manifest; + try { + manifest = await this.getJSON(manifestPath); + } catch (err) { + manifest = ['course.json', 'contentObjects.json', 'articles.json', 'blocks.json', 'components.json']; + Adapt.log.warnOnce(`Manifest path '${manifestPath} not found. Using traditional files: ${manifest.join(', ')}`); + } + const allFileData = await Promise.all(manifest.map(filePath => { + return this.getJSON(`${languagePath}${filePath}`); + })); + // Flatten all file data into a single array of model data + const allModelData = allFileData.reduce((result, fileData) => { + if (fileData instanceof Array) { + result.push(...fileData); + } else if (fileData instanceof Object) { + result.push(fileData); + } else { + Adapt.log.warnOnce(`File data isn't an array or object: ${fileData.__path__}`); } - - this.setupMapping(); - - Adapt.wait.queue(() => { - callback(newLanguage); - }); - + return result; + }, []); + // Add course model first to allow other model/views to utilize its settings + const course = allModelData.find(modelData => modelData._type === 'course'); + if (!course) { + throw new Error(`Expected a model data with "_type": "course", none found.`); } + this.push(course); + // Add other models + allModelData.forEach(modelData => { + if (modelData._type === 'course') { + return; + } + this.push(modelData); + }); + // index by id + this._byAdaptID = this.indexBy('_id'); + this.trigger('reset'); + this.trigger('loaded'); + await Adapt.wait.queue(); } - mapAdaptIdsToObjects() { - Adapt.contentObjects._byAdaptID = Adapt.contentObjects.groupBy('_id'); - Adapt.articles._byAdaptID = Adapt.articles.groupBy('_id'); - Adapt.blocks._byAdaptID = Adapt.blocks.groupBy('_id'); - Adapt.components._byAdaptID = Adapt.components.groupBy('_id'); - } - - setupMapping() { - this.mappedIds = {}; - - // Setup course Id - this.mappedIds[Adapt.course.get('_id')] = 'course'; - - const collections = ['contentObjects', 'articles', 'blocks', 'components']; - - collections.forEach(collection => { - Adapt[collection].models.forEach(model => { - const id = model.get('_id'); - this.mappedIds[id] = collection; - }); - }); + async triggerDataLoaded() { + Adapt.log.debug('Firing app:dataLoaded'); + try { + Adapt.trigger('app:dataLoaded');// Triggered to setup model connections in AdaptModel.js + } catch (e) { + Adapt.log.error('Error during app:dataLoading trigger', e); + } + await Adapt.wait.queue(); } - triggerDataReady(newLanguage) { + async triggerDataReady(newLanguage) { if (newLanguage) { - Adapt.trigger('app:languageChanged', newLanguage); - _.defer(() => { Adapt.startController.loadCourseData(); - let hash = '#/'; - - if (Adapt.startController.isEnabled()) { - hash = Adapt.startController.getStartHash(true); - } - - Backbone.history.navigate(hash, { trigger: true, replace: true }); + const hash = Adapt.startController.isEnabled() ? + '#/' : + Adapt.startController.getStartHash(true); + Adapt.router.navigate(hash, { trigger: true, replace: true }); }); } - Adapt.log.debug('Firing app:dataReady'); - try { Adapt.trigger('app:dataReady'); } catch (e) { Adapt.log.error('Error during app:dataReady trigger', e); } - - Adapt.wait.queue(this.triggerInit.bind(this)); - + await Adapt.wait.queue(); } triggerInit() { @@ -192,59 +166,23 @@ define([ whenReady() { if (this.isReady) return Promise.resolve(); - return new Promise(resolve => { this.once('ready', resolve); }); } - /** - * Looks up which collection a model belongs to - * @param {string} id The id of the item you want to look up e.g. `"co-05"` - * @return {string} One of the following (or `undefined` if not found): - * - "course" - * - "contentObjects" - * - "blocks" - * - "articles" - * - "components" - */ - mapById(id) { - return this.mappedIds[id]; - } - /** * Looks up a model by its `_id` property * @param {string} id The id of the item e.g. "co-05" * @return {Backbone.Model} */ findById(id) { - if (id === Adapt.course.get('_id')) { - return Adapt.course; - } - - const collectionType = Adapt.mapById(id); - - if (!collectionType) { - console.warn('Adapt.findById() unable to find collection type for id: ' + id); + const model = this._byAdaptID[id]; + if (!model) { + console.warn(`Adapt.findById() unable to find collection type for id: ${id}`); return; } - - return Adapt[collectionType]._byAdaptID[id][0]; - } - - /** - * Filter all models. - * @param {Function} filter - * @returns {Array} - */ - filter(filter) { - const result = []; - filter(Adapt.course) && result.push(Adapt.course); - result.push(...Adapt.contentObjects.filter(filter)); - result.push(...Adapt.articles.filter(filter)); - result.push(...Adapt.blocks.filter(filter)); - result.push(...Adapt.components.filter(filter)); - return result; + return model; } } diff --git a/src/core/js/models/buildModel.js b/src/core/js/models/buildModel.js index 142186fa6..47ed6b69e 100644 --- a/src/core/js/models/buildModel.js +++ b/src/core/js/models/buildModel.js @@ -37,6 +37,6 @@ define([ } - return (Adapt.build = new BuildModel(null, { url: 'adapt/js/build.min.js', reset: true })); + return BuildModel; }); diff --git a/src/core/js/models/courseModel.js b/src/core/js/models/courseModel.js index 6065a40b5..b90ba61a1 100644 --- a/src/core/js/models/courseModel.js +++ b/src/core/js/models/courseModel.js @@ -23,23 +23,21 @@ define([ return 'course'; } - initialize(attrs, options) { - super.initialize(arguments); + initialize(...args) { Adapt.trigger('courseModel:dataLoading'); - this.url = options.url; - this.on('sync', this.loadedData, this); - if (!this.url) return; - this.fetch({ - error: () => console.error(`ERROR: unable to load file ${this.url}`) - }); + super.initialize(...args); + this.loadedData(); } loadedData() { + Adapt.course = this; Adapt.trigger('courseModel:dataLoaded'); } } + Adapt.register('course', { model: CourseModel }); + return CourseModel; }); diff --git a/src/core/js/mpabc.js b/src/core/js/mpabc.js index cbb2aa09a..c6a5157bc 100644 --- a/src/core/js/mpabc.js +++ b/src/core/js/mpabc.js @@ -1,9 +1,85 @@ define([ + 'core/js/adapt', + 'core/js/data', + 'core/js/collections/adaptSubsetCollection', + 'core/js/models/courseModel', + 'core/js/models/contentObjectModel', 'core/js/models/menuModel', 'core/js/models/pageModel', 'core/js/models/articleModel', 'core/js/models/blockModel', + 'core/js/models/componentModel', 'core/js/views/pageView', 'core/js/views/articleView', 'core/js/views/blockView' -], function(MenuModel, PageModel, ArticleModel, BlockModel, PageView, ArticleView, BlockView) {}); +], function(Adapt, Data, AdaptSubsetCollection, CourseModel, ContentObjectModel, MenuModel, PageModel, ArticleModel, BlockModel, ComponentModel, PageView, ArticleView, BlockView) { + + class MPABC extends Backbone.Controller { + + initialize() { + // Example of how to cause the data loader to wait for another module to setup + this.listenTo(Data, { + 'loading': this.waitForDataLoaded, + 'loaded': this.onDataLoaded + }); + this.setupDeprecatedSubsetCollections(); + } + + waitForDataLoaded() { + // Tell the data loader to wait + Adapt.wait.begin(); + } + + onDataLoaded() { + // Tell the data loader that we have finished + Adapt.wait.end(); + } + + setupDeprecatedSubsetCollections() { + let contentObjects = new AdaptSubsetCollection(null, { parent: Data, model: ContentObjectModel }); + let articles = new AdaptSubsetCollection(null, { parent: Data, model: ArticleModel }); + let blocks = new AdaptSubsetCollection(null, { parent: Data, model: BlockModel }); + let components = new AdaptSubsetCollection(null, { parent: Data, model: ComponentModel }); + Object.defineProperty(Adapt, 'contentObjects', { + get: function() { + Adapt.log.deprecated('Adapt.contentObjects, please use Adapt.data instead'); + return contentObjects; + }, + set: function(value) { + contentObjects = value; + } + }); + Object.defineProperty(Adapt, 'articles', { + get: function() { + Adapt.log.deprecated('Adapt.articles, please use Adapt.data instead'); + return articles; + }, + set: function(value) { + articles = value; + } + }); + Object.defineProperty(Adapt, 'blocks', { + get: function() { + Adapt.log.deprecated('Adapt.blocks, please use Adapt.data instead'); + return blocks; + }, + set: function(value) { + blocks = value; + } + }); + Object.defineProperty(Adapt, 'components', { + get: function() { + Adapt.log.deprecated('Adapt.components, please use Adapt.data instead'); + return components; + }, + set: function(value) { + components = value; + } + }); + } + + } + + return (Adapt.mpabc = new MPABC()); + +}); From d6dfe8d3f2a558125dcd79e5872fe85f96290b6b Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 14 Apr 2020 10:43:55 +0100 Subject: [PATCH 40/57] Recommendations --- src/core/js/mpabc.js | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src/core/js/mpabc.js b/src/core/js/mpabc.js index c6a5157bc..0fa77a9dd 100644 --- a/src/core/js/mpabc.js +++ b/src/core/js/mpabc.js @@ -19,8 +19,8 @@ define([ initialize() { // Example of how to cause the data loader to wait for another module to setup this.listenTo(Data, { - 'loading': this.waitForDataLoaded, - 'loaded': this.onDataLoaded + loading: this.waitForDataLoaded, + loaded: this.onDataLoaded }); this.setupDeprecatedSubsetCollections(); } @@ -41,40 +41,32 @@ define([ let blocks = new AdaptSubsetCollection(null, { parent: Data, model: BlockModel }); let components = new AdaptSubsetCollection(null, { parent: Data, model: ComponentModel }); Object.defineProperty(Adapt, 'contentObjects', { - get: function() { + get: () => { Adapt.log.deprecated('Adapt.contentObjects, please use Adapt.data instead'); return contentObjects; }, - set: function(value) { - contentObjects = value; - } + set: value => contentObjects = value }); Object.defineProperty(Adapt, 'articles', { - get: function() { + get: () => { Adapt.log.deprecated('Adapt.articles, please use Adapt.data instead'); return articles; }, - set: function(value) { - articles = value; - } + set: value => articles = value }); Object.defineProperty(Adapt, 'blocks', { - get: function() { + get: () => { Adapt.log.deprecated('Adapt.blocks, please use Adapt.data instead'); return blocks; }, - set: function(value) { - blocks = value; - } + set: value => blocks = value }); Object.defineProperty(Adapt, 'components', { - get: function() { + get: () => { Adapt.log.deprecated('Adapt.components, please use Adapt.data instead'); return components; }, - set: function(value) { - components = value; - } + set: value => components = value }); } From 4dd0ba80611117e3fc1423ee7180710807cc7b0e Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 14 Apr 2020 11:34:00 +0100 Subject: [PATCH 41/57] Improved babel source mapping --- grunt/config/babel.js | 60 +++++++++++++++++++++++++++---------- grunt/tasks/build.js | 2 +- grunt/tasks/dev.js | 2 +- grunt/tasks/diff.js | 2 +- grunt/tasks/server-build.js | 2 +- 5 files changed, 48 insertions(+), 20 deletions(-) diff --git a/grunt/config/babel.js b/grunt/config/babel.js index dd9178c5e..3e6415f60 100644 --- a/grunt/config/babel.js +++ b/grunt/config/babel.js @@ -1,22 +1,50 @@ module.exports = { - options: { - sourceMap: true, - inputSourceMap: true, - sourceType: 'script', - minified: true, - comments: false, - presets: [ - [ - '@babel/preset-env', - { - "targets": { - "ie": "11" + compile: { + options: { + inputSourceMap: false, + minified: true, + comments: false, + presets: [ + [ + '@babel/preset-env', + { + "targets": { + "ie": "11" + } } - } + ] ] - ] + }, + files: [{ + "expand": true, + "cwd": "<%= tempdir %>", + "src": [ + "adapt.min.js" + ], + "dest": "<%= outputdir %>adapt/js/", + "ext": ".min.js" + }] }, - dist: { + dev: { + options: { + sourceMap: true, + inputSourceMap: true, + sourceType: 'script', + retainLines: true, + minified: false, + compact: false, + comments: true, + presets: [ + [ + '@babel/preset-env', + { + "targets": { + "ie": "11" + } + } + ] + ] + }, files: [{ "expand": true, "cwd": "<%= tempdir %>", @@ -27,4 +55,4 @@ module.exports = { "ext": ".min.js" }] } -}; \ No newline at end of file +}; diff --git a/grunt/tasks/build.js b/grunt/tasks/build.js index 042977768..7c2803138 100644 --- a/grunt/tasks/build.js +++ b/grunt/tasks/build.js @@ -13,7 +13,7 @@ module.exports = function(grunt) { 'handlebars', 'tracking-insert', 'javascript:compile', - 'babel', + 'babel:compile', 'clean:dist', 'less:compile', 'replace', diff --git a/grunt/tasks/dev.js b/grunt/tasks/dev.js index 450f903bf..29c3f8d34 100644 --- a/grunt/tasks/dev.js +++ b/grunt/tasks/dev.js @@ -12,7 +12,7 @@ module.exports = function(grunt) { 'handlebars', 'tracking-insert', 'javascript:dev', - 'babel', + 'babel:dev', 'less:dev', 'replace', 'scripts:adaptpostbuild', diff --git a/grunt/tasks/diff.js b/grunt/tasks/diff.js index 53f7694d3..3939933aa 100644 --- a/grunt/tasks/diff.js +++ b/grunt/tasks/diff.js @@ -12,7 +12,7 @@ module.exports = function(grunt) { 'newer:handlebars:compile', 'tracking-insert', 'newer:javascript:dev', - 'babel', + 'babel:dev', 'newer:less:dev', 'replace', 'scripts:adaptpostbuild', diff --git a/grunt/tasks/server-build.js b/grunt/tasks/server-build.js index afebcf936..5397490aa 100644 --- a/grunt/tasks/server-build.js +++ b/grunt/tasks/server-build.js @@ -13,7 +13,7 @@ module.exports = function(grunt) { 'less:' + requireMode, 'handlebars', 'javascript:' + requireMode, - 'babel', + 'babel:' + requireMode, 'replace', 'scripts:adaptpostbuild', 'clean:temp' From 3c7b65644793c85810150e9a916c04fbeff095ab Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 14 Apr 2020 12:23:57 +0100 Subject: [PATCH 42/57] Readded sourceType to babel config --- grunt/config/babel.js | 1 + 1 file changed, 1 insertion(+) diff --git a/grunt/config/babel.js b/grunt/config/babel.js index 3e6415f60..e794ef21c 100644 --- a/grunt/config/babel.js +++ b/grunt/config/babel.js @@ -2,6 +2,7 @@ module.exports = { compile: { options: { inputSourceMap: false, + sourceType: 'script', minified: true, comments: false, presets: [ From cf924ca3513f6406600fd62724e6a1a3ec0e16d3 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 14 Apr 2020 15:11:39 +0100 Subject: [PATCH 43/57] isTypeGroup warning simplified --- src/core/js/models/adaptModel.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/core/js/models/adaptModel.js b/src/core/js/models/adaptModel.js index caff07bdf..f9cf774cb 100644 --- a/src/core/js/models/adaptModel.js +++ b/src/core/js/models/adaptModel.js @@ -270,12 +270,7 @@ define([ const singular = isPluralized && lowerCased.slice(0, -1); // remove pluralization if ending in s const singularLowerCased = (singular || lowerCased).toLowerCase(); if (isPluralized || hasUpperCase) { - const message = (isPluralized && hasUpperCase) ? - `'${typeGroup}' appears pluralized and contains uppercase characters, suggest using the singular, lowercase type group '${singularLowerCased}'.` : - isPluralized ? - `'${typeGroup}' appears pluralized, suggest using the singular type group '${singularLowerCased}'.` : - `'${typeGroup}' contains uppercase characters, suggest using lowercase type group '${singularLowerCased}'.`; - Adapt.log.deprecated(message); + Adapt.log.deprecated(`'${typeGroup}' appears pluralized or contains uppercase characters, suggest using the singular, lowercase type group '${singularLowerCased}'.`); } const pluralizedLowerCaseTypes = [ singularLowerCased, From 74e2b6cf261f497916d81e32fb133a9214723ed1 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 21 Apr 2020 12:46:16 +0100 Subject: [PATCH 44/57] Fix for authoring tool assets.json --- grunt/helpers/data/Language.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/grunt/helpers/data/Language.js b/grunt/helpers/data/Language.js index 51e74ec13..a20215294 100644 --- a/grunt/helpers/data/Language.js +++ b/grunt/helpers/data/Language.js @@ -80,6 +80,10 @@ class Language { hasChanged: false }); file.load(); + if (jsonFileName === 'assets.json'&& typeof file.data === 'object' && !(file.data instanceof Array) && !file.data._type && !file.data._component && !file.data._model ) { + // Skipping as file is the Authoring Tool import/export asset manifest + return; + } this.files.push(file); }); From 977679305ed55e38db84aee46f44833bcb165797 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 21 Apr 2020 12:49:35 +0100 Subject: [PATCH 45/57] Fixed linting issues --- grunt/config/babel.js | 32 ++++++++++++++++---------------- grunt/helpers/data/Language.js | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/grunt/config/babel.js b/grunt/config/babel.js index e794ef21c..750603003 100644 --- a/grunt/config/babel.js +++ b/grunt/config/babel.js @@ -9,21 +9,21 @@ module.exports = { [ '@babel/preset-env', { - "targets": { - "ie": "11" + targets: { + ie: '11' } } ] ] }, files: [{ - "expand": true, - "cwd": "<%= tempdir %>", - "src": [ - "adapt.min.js" + expand: true, + cwd: '<%= tempdir %>', + src: [ + 'adapt.min.js' ], - "dest": "<%= outputdir %>adapt/js/", - "ext": ".min.js" + dest: '<%= outputdir %>adapt/js/', + ext: '.min.js' }] }, dev: { @@ -39,21 +39,21 @@ module.exports = { [ '@babel/preset-env', { - "targets": { - "ie": "11" + targets: { + ie: '11' } } ] ] }, files: [{ - "expand": true, - "cwd": "<%= tempdir %>", - "src": [ - "adapt.min.js" + expand: true, + cwd: '<%= tempdir %>', + src: [ + 'adapt.min.js' ], - "dest": "<%= outputdir %>adapt/js/", - "ext": ".min.js" + dest: '<%= outputdir %>adapt/js/', + ext: '.min.js' }] } }; diff --git a/grunt/helpers/data/Language.js b/grunt/helpers/data/Language.js index a20215294..c12d2383e 100644 --- a/grunt/helpers/data/Language.js +++ b/grunt/helpers/data/Language.js @@ -80,7 +80,7 @@ class Language { hasChanged: false }); file.load(); - if (jsonFileName === 'assets.json'&& typeof file.data === 'object' && !(file.data instanceof Array) && !file.data._type && !file.data._component && !file.data._model ) { + if (jsonFileName === 'assets.json' && typeof file.data === 'object' && !(file.data instanceof Array) && !file.data._type && !file.data._component && !file.data._model) { // Skipping as file is the Authoring Tool import/export asset manifest return; } From 2af544887c5665fb0bafbd7232f9cd00018b7058 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 21 Apr 2020 14:06:11 +0100 Subject: [PATCH 46/57] Reworked AAT assets.json fix --- grunt/helpers/data/Language.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/grunt/helpers/data/Language.js b/grunt/helpers/data/Language.js index c12d2383e..11bcd23f6 100644 --- a/grunt/helpers/data/Language.js +++ b/grunt/helpers/data/Language.js @@ -66,8 +66,10 @@ class Language { const relativePath = dataFilePath.slice(this.path.length); return relativePath; }).filter((dataFilePath) => { - const isNotManifest = (dataFilePath !== this.manifestPath); - return isNotManifest; + const isManifest = (dataFilePath === this.manifestPath); + // Skip file if it is the Authoring Tool import/export asset manifest + const isAATAssetJSON = (dataFilePath === 'assets.json'); + return !isManifest && !isAATAssetJSON; }); dataFiles.forEach(jsonFileName => { @@ -80,10 +82,6 @@ class Language { hasChanged: false }); file.load(); - if (jsonFileName === 'assets.json' && typeof file.data === 'object' && !(file.data instanceof Array) && !file.data._type && !file.data._component && !file.data._model) { - // Skipping as file is the Authoring Tool import/export asset manifest - return; - } this.files.push(file); }); @@ -125,8 +123,10 @@ class Language { const relativePath = dataFilePath.slice(this.path.length); return relativePath; }).filter((dataFilePath) => { - const isNotManifest = (dataFilePath !== this.manifestPath); - return isNotManifest; + const isManifest = (dataFilePath === this.manifestPath); + // Skip file if it is the Authoring Tool import/export asset manifest + const isAATAssetJSON = (dataFilePath === 'assets.json'); + return !isManifest && !isAATAssetJSON; }); const hasNoDataFiles = !dataFiles.length; if (hasNoDataFiles) { From e5e51578aaf85237763026babf14ca42c1ff20ca Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 21 Apr 2020 15:45:20 +0100 Subject: [PATCH 47/57] Switched to native filter --- src/core/js/views/contentObjectView.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/js/views/contentObjectView.js b/src/core/js/views/contentObjectView.js index d86ede621..ceadbb63d 100644 --- a/src/core/js/views/contentObjectView.js +++ b/src/core/js/views/contentObjectView.js @@ -13,7 +13,7 @@ define([ } className() { - return _.filter([ + return [ this.constructor.type, 'contentobject', this.constructor.className, @@ -22,7 +22,7 @@ define([ this.setVisibility(), (this.model.get('_isComplete') ? 'is-complete' : ''), (this.model.get('_isOptional') ? 'is-optional' : '') - ], Boolean).join(' '); + ].filter(Boolean).join(' '); } preRender() { From 014c62ae5c68ae1367635f978b3e786cdbe6e7fe Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 21 Apr 2020 16:00:59 +0100 Subject: [PATCH 48/57] Switched from instanceof Array to Array.isArray --- src/core/js/adapt.js | 2 +- src/core/js/data.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index 1677d2532..ef08b9cfe 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -161,7 +161,7 @@ define([ * @param {object} object Object containing properties `model` and `view` or (legacy) an object representing the view */ register(name, object) { - if (name instanceof Array) { + if (Array.isArray(name)) { // if an array is passed, iterate by recursive call name.forEach(name => this.register(name, object)); return object; diff --git a/src/core/js/data.js b/src/core/js/data.js index 258a9bcbf..8fb59c6cf 100644 --- a/src/core/js/data.js +++ b/src/core/js/data.js @@ -100,7 +100,7 @@ define([ })); // Flatten all file data into a single array of model data const allModelData = allFileData.reduce((result, fileData) => { - if (fileData instanceof Array) { + if (Array.isArray(fileData)) { result.push(...fileData); } else if (fileData instanceof Object) { result.push(fileData); From 95298332dcbee0522167a66e97d97d98a85e3c67 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 21 Apr 2020 21:49:46 +0100 Subject: [PATCH 49/57] Fixed babel watch command --- grunt/config/watch.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grunt/config/watch.js b/grunt/config/watch.js index c40ffa001..0a18abce8 100644 --- a/grunt/config/watch.js +++ b/grunt/config/watch.js @@ -26,7 +26,7 @@ module.exports = { }, js: { files: ['<%= sourcedir %>**/*.js'], - tasks: ['javascript:dev', 'babel', 'clean:temp'] + tasks: ['javascript:dev', 'babel:dev', 'clean:temp'] }, componentsAssets: { files: ['<%= sourcedir %>components/**/assets/**'], From 4d9a5c8ee470429e66e8edb38d04c145110cbcd6 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 21 Apr 2020 21:51:05 +0100 Subject: [PATCH 50/57] Added contentObject postRemove instance event, added global preReady and wait queue --- src/core/js/views/contentObjectView.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/js/views/contentObjectView.js b/src/core/js/views/contentObjectView.js index ceadbb63d..1627fb5ab 100644 --- a/src/core/js/views/contentObjectView.js +++ b/src/core/js/views/contentObjectView.js @@ -56,9 +56,12 @@ define([ async isReady() { if (!this.model.get('_isReady')) return; + if (this.model !== Adapt.location._currentModel) return; const type = this.constructor.type; - const performIsReady = () => { + const performIsReady = async () => { + Adapt.trigger(`${type}View:preReady contentObjectView:preReady view:preReady`, this); + await Adapt.wait.queue(); $('.js-loading').hide(); $(window).scrollTop(0); Adapt.trigger(`${type}View:ready contentObjectView:ready view:ready`, this); @@ -104,7 +107,8 @@ define([ this.model.set('_isReady', false); super.remove(); _.defer(() => { - Adapt.trigger(`${type}View:postRemove contentObjectView:preRemove view:preRemove`, this); + Adapt.trigger(`${type}View:postRemove contentObjectView:postRemove view:postRemove`, this); + this.trigger('postRemove'); }); end(); }); From 3b0683c6d32ece12bd147c8ffe9ba8964753f36c Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 21 Apr 2020 21:51:36 +0100 Subject: [PATCH 51/57] Switched buttons and heading views to use contentObject postRemove instance event --- src/core/js/views/buttonsView.js | 3 +-- src/core/js/views/headingView.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/core/js/views/buttonsView.js b/src/core/js/views/buttonsView.js index fcf4d1ff2..06e79298b 100644 --- a/src/core/js/views/buttonsView.js +++ b/src/core/js/views/buttonsView.js @@ -18,8 +18,7 @@ define([ initialize: function(options) { this.parent = options.parent; - - this.listenTo(Adapt, 'remove', this.remove); + this.listenTo(Adapt.parentView, 'postRemove', this.remove); this.listenTo(this.model, 'change:_buttonState', this.onButtonStateChanged); this.listenTo(this.model, 'change:feedbackMessage', this.onFeedbackMessageChanged); this.listenTo(this.model, 'change:_attemptsLeft', this.onAttemptsChanged); diff --git a/src/core/js/views/headingView.js b/src/core/js/views/headingView.js index 5693cfa36..cd2d10db2 100644 --- a/src/core/js/views/headingView.js +++ b/src/core/js/views/headingView.js @@ -5,7 +5,7 @@ define([ var HeadingView = Backbone.View.extend({ initialize: function() { - this.listenTo(Adapt, 'remove', this.remove); + this.listenTo(Adapt.parentView, 'postRemove', this.remove); this.listenTo(this.model, 'change:_isComplete', this.render); this.render(); }, From e756447f7d31f76c0263f60924ada07dd131df5f Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 22 Apr 2020 10:04:59 +0100 Subject: [PATCH 52/57] Added ready guard and removed duplicate preReady event --- src/core/js/views/contentObjectView.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/core/js/views/contentObjectView.js b/src/core/js/views/contentObjectView.js index 1627fb5ab..2f47bbb96 100644 --- a/src/core/js/views/contentObjectView.js +++ b/src/core/js/views/contentObjectView.js @@ -55,8 +55,8 @@ define([ } async isReady() { - if (!this.model.get('_isReady')) return; - if (this.model !== Adapt.location._currentModel) return; + if (!this.model.get('_isReady') || this._isTriggeredReady) return; + this._isTriggeredReady = true; const type = this.constructor.type; const performIsReady = async () => { @@ -85,8 +85,6 @@ define([ $(window).scroll(); }; - Adapt.trigger(`${type}View:preReady contentObjectView:preReady view:preReady`, this); - await Adapt.wait.queue(); _.defer(performIsReady); } From f1e632def8b9ac4bd0e4df9521fbbd0d1d14bf86 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Mon, 27 Apr 2020 11:52:40 +0100 Subject: [PATCH 53/57] Moved _isReady reset back into Adapt.remove to allow multiple page renders --- src/core/js/adapt.js | 4 ++++ src/core/js/views/adaptView.js | 2 -- src/core/js/views/contentObjectView.js | 14 +++++++------- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index ef08b9cfe..614c3c756 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -397,6 +397,10 @@ define([ async remove() { const currentView = this.parentView; + if (currentView) { + currentView.model.setOnChildren('_isReady', false); + currentView.model.set('_isReady', false); + } this.trigger('preRemove', currentView); await this.wait.queue(); // Facilitate contentObject transitions diff --git a/src/core/js/views/adaptView.js b/src/core/js/views/adaptView.js index 0185fcf48..c6a085214 100644 --- a/src/core/js/views/adaptView.js +++ b/src/core/js/views/adaptView.js @@ -145,8 +145,6 @@ define([ Adapt.wait.for(end => { this.$el.off('onscreen.adaptView'); - this.model.setOnChildren('_isReady', false); - this.model.set('_isReady', false); super.remove(); _.defer(() => { Adapt.trigger(`${type}View:postRemove view:postRemove`, this); diff --git a/src/core/js/views/contentObjectView.js b/src/core/js/views/contentObjectView.js index 2f47bbb96..41f3687e0 100644 --- a/src/core/js/views/contentObjectView.js +++ b/src/core/js/views/contentObjectView.js @@ -63,7 +63,9 @@ define([ Adapt.trigger(`${type}View:preReady contentObjectView:preReady view:preReady`, this); await Adapt.wait.queue(); $('.js-loading').hide(); - $(window).scrollTop(0); + if (Adapt.get('_shouldContentObjectScrollTop') !== false) { + $(window).scrollTop(0); + } Adapt.trigger(`${type}View:ready contentObjectView:ready view:ready`, this); $.inview.unlock(`${type}View`); const styleOptions = { opacity: 1 }; @@ -101,8 +103,10 @@ define([ Adapt.wait.for(end => { this.$el.off('onscreen.adaptView'); - this.model.setOnChildren('_isReady', false); - this.model.set('_isReady', false); + this.findDescendantViews().reverse().forEach(view => { + view.remove(); + }); + this.childViews = []; super.remove(); _.defer(() => { Adapt.trigger(`${type}View:postRemove contentObjectView:postRemove view:postRemove`, this); @@ -115,10 +119,6 @@ define([ } destroy() { - this.findDescendantViews().reverse().forEach(view => { - view.remove(); - }); - this.childViews = []; this.remove(); if (Adapt.parentView === this) { Adapt.parentView = null; From 1c8ca18179ec7bde3054be38eee585e9bfa6e1c0 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Fri, 1 May 2020 14:55:39 +0100 Subject: [PATCH 54/57] ItemsQuestionModel not restoring question answers correctly, missing init --- src/core/js/models/itemsQuestionModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/js/models/itemsQuestionModel.js b/src/core/js/models/itemsQuestionModel.js index 929dac6b0..2f14d977f 100644 --- a/src/core/js/models/itemsQuestionModel.js +++ b/src/core/js/models/itemsQuestionModel.js @@ -19,7 +19,7 @@ define([ } // extend BlendedItemsComponentQuestionModel with ItemsComponentModel Object.getOwnPropertyNames(ItemsComponentModel.prototype).forEach(name => { - if (name === 'constructor') return; + if (name === 'constructor' || name === 'init' || name === 'reset') return; Object.defineProperty(BlendedItemsComponentQuestionModel.prototype, name, { value: ItemsComponentModel.prototype[name] }); From 5cf0fa29165065f57f27e5f29afe6acc955856f3 Mon Sep 17 00:00:00 2001 From: moloko Date: Thu, 7 May 2020 14:51:16 +0100 Subject: [PATCH 55/57] prevent invalid value from being processed by setActiveItem see #2749 --- src/core/js/models/itemsComponentModel.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/js/models/itemsComponentModel.js b/src/core/js/models/itemsComponentModel.js index 345411bcc..851d43a07 100644 --- a/src/core/js/models/itemsComponentModel.js +++ b/src/core/js/models/itemsComponentModel.js @@ -68,7 +68,9 @@ define([ this.get('_children').each(item => item.toggleActive(false)); } - setActiveItem(index) { + setActiveItem(index = 1) { + if (index < 0 || index >= this.get('_children').length) return; + const activeItem = this.getActiveItem(); if (activeItem) activeItem.toggleActive(false); this.getItem(index).toggleActive(true); From 9f39b3109d552c60aef922854179086a53523cb7 Mon Sep 17 00:00:00 2001 From: moloko Date: Thu, 7 May 2020 15:25:45 +0100 Subject: [PATCH 56/57] amend following code review --- src/core/js/models/itemsComponentModel.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/core/js/models/itemsComponentModel.js b/src/core/js/models/itemsComponentModel.js index 851d43a07..57d35ad78 100644 --- a/src/core/js/models/itemsComponentModel.js +++ b/src/core/js/models/itemsComponentModel.js @@ -68,12 +68,13 @@ define([ this.get('_children').each(item => item.toggleActive(false)); } - setActiveItem(index = 1) { - if (index < 0 || index >= this.get('_children').length) return; + setActiveItem() { + const item = this.getItem(index); + if (!item) return; const activeItem = this.getActiveItem(); if (activeItem) activeItem.toggleActive(false); - this.getItem(index).toggleActive(true); + item.toggleActive(true); } } From bfea0cbebbe8ef9c4cc80d01f521979a3809eecd Mon Sep 17 00:00:00 2001 From: Matt Leathes Date: Thu, 7 May 2020 20:23:36 +0100 Subject: [PATCH 57/57] restored parameter --- src/core/js/models/itemsComponentModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/js/models/itemsComponentModel.js b/src/core/js/models/itemsComponentModel.js index 57d35ad78..93ff23dfc 100644 --- a/src/core/js/models/itemsComponentModel.js +++ b/src/core/js/models/itemsComponentModel.js @@ -68,7 +68,7 @@ define([ this.get('_children').each(item => item.toggleActive(false)); } - setActiveItem() { + setActiveItem(index) { const item = this.getItem(index); if (!item) return;