diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index 614c3c756..9d065bbd6 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -140,20 +140,22 @@ define([ } /** - * Allows a selector to be passed in and Adapt will navigate to this element + * Allows a selector to be passed in and Adapt will navigate to this element. Resolves + * asynchronously when the element has been navigated to. * @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. + * @param {Object} [settings] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). + * @param {Object} [settings.replace=false] Set to `true` if you want to update the URL without creating an entry in the browser's history. */ - navigateToElement() {} + async navigateToElement() {} /** - * Allows a selector to be passed in and Adapt will scroll to this element + * Allows a selector to be passed in and Adapt will scroll to this element. Resolves + * asynchronously when the element has been navigated/scrolled to. * @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. + * @param {Object} [settings={}] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). + * @param {Object} [settings.replace=false] Set to `true` if you want to update the URL without creating an entry in the browser's history. */ - scrollTo() {} + async scrollTo() {} /** * Used to register models and views with `Adapt.store` @@ -343,7 +345,9 @@ define([ } const foundView = idPathToView.reduce((view, currentId) => { - return view && view.childViews && view.childViews[currentId]; + if (!view) return; + const childViews = view.getChildViews(); + return childViews && childViews.find(view => view.model.get('_id') === currentId); }, this.parentView); return foundView; @@ -398,8 +402,10 @@ define([ async remove() { const currentView = this.parentView; if (currentView) { - currentView.model.setOnChildren('_isReady', false); - currentView.model.set('_isReady', false); + currentView.model.setOnChildren({ + '_isReady': false, + '_isRendered': false + }); } this.trigger('preRemove', currentView); await this.wait.queue(); diff --git a/src/core/js/childEvent.js b/src/core/js/childEvent.js new file mode 100644 index 000000000..ba55766a4 --- /dev/null +++ b/src/core/js/childEvent.js @@ -0,0 +1,104 @@ +define([ + 'core/js/adapt' +], function(Adapt) { + + /** + * Event object triggered for controlling child view rendering. + * Sent with 'view:addChild' and 'view:requestChild' events. + * All plugins receive the same object reference in their event handler and + * as such, this object becomes a place of consensus for plugins to decide how + * to handle rendering for this child. + */ + class ChildEvent extends Backbone.Controller { + + /** + * @param {string} type Event type + * @param {AdaptView} target Parent view + * @param {AdaptModel} model Child model + */ + initialize(type, target, model) { + /** @type {string} */ + this.type = type; + /** @type {AdaptView} */ + this.target = target; + /** @type {boolean} Force the child model to render */ + this.isForced = false; + /** @type {boolean} Stop rendering before the child model */ + this.isStoppedImmediate = false; + /** @type {boolean} Stop rendering after the child model */ + this.isStoppedNext = false; + /** @type {boolean} Contains a model to render in response to a requestChild event */ + this.hasRequestChild = false; + this._model = model; + } + + /** + * Get the model to be rendered. + * @returns {AdaptModel} + */ + get model() { + return this._model; + } + + /** + * Set the model to render in response to a 'view:requestChild' event. + * @param {AdaptModel} + */ + set model(model) { + if (this.type !== 'requestChild') { + Adapt.log.warn(`Cannot change model in ${this.type} event.`); + return; + } + if (this._model) { + Adapt.log.warn(`Cannot inject two models in one sitting. ${model.get('_id')} attempts to overwrite ${this._model.get('_id')}`); + return; + } + this._model = model; + this.hasRequestChild = true; + } + + /** + * Reset all render stops. + */ + reset() { + this.isStoppedImmediate = false; + this.isStoppedNext = false; + } + + /** + * Force model to render. + */ + force() { + this.isForced = true; + } + + /** + * General stop. Stop immediately or stop next with flag to false. + * @param {boolean} [immediate=true] Flag to stop immediate or next. + */ + stop(immediate = true) { + if (!immediate) { + return this.stopNext(); + } + this.isStoppedImmediate = true; + } + + /** + * Shortcut to stop(false). Stop the render after the contained model is rendered. + */ + stopNext() { + this.isStoppedNext = true; + } + + /** + * Trigger an event to signify that a final decision has been reached. + */ + close() { + this.trigger('closed'); + } + + } + + return ChildEvent; + +}); diff --git a/src/core/js/models/adaptModel.js b/src/core/js/models/adaptModel.js index ca7834e40..52db770cc 100644 --- a/src/core/js/models/adaptModel.js +++ b/src/core/js/models/adaptModel.js @@ -30,6 +30,7 @@ define([ _canShowFeedback: true, _classes: '', _canReset: false, + _canRequestChild: false, _isComplete: false, _isInteractionComplete: false, _isA11yRegionEnabled: false, @@ -39,6 +40,7 @@ define([ _isResetOnRevisit: false, _isAvailable: true, _isOptional: false, + _isRendered: false, _isReady: false, _isVisible: true, _isLocked: false, @@ -208,16 +210,30 @@ define([ } } - checkReadyStatus() { + /** + * Checks if any child models which have been _isRendered are not _isReady. + * If all rendered child models are marked ready then this model will be + * marked _isReady: true as well. + * @param {AdaptModel} [model] + * @param {boolean} [value] + * @returns {boolean} + */ + checkReadyStatus(model, value) { + if (value === false) { + // Do not respond to _isReady: false as _isReady is unset throughout + // the rendering process + return false; + } // Filter children based upon whether they are available - // Check if any return _isReady:false + // Check if any _isRendered: true children return _isReady: false // If not - set this model to _isReady: true const children = this.getAvailableChildModels(); - if (children.find(child => child.get('_isReady') === false)) { - return; + if (children.find(child => child.get('_isReady') === false && child.get('_isRendered'))) { + return false; } this.set('_isReady', true); + return true; } setCompletionStatus() { @@ -734,6 +750,19 @@ define([ this.checkLocking(); } + /** + * Used before a model is rendered to determine if it should be reset to its + * default values. + */ + checkIfResetOnRevisit() { + var isResetOnRevisit = this.get('_isResetOnRevisit'); + if (!isResetOnRevisit) { + return; + } + // If reset is enabled set defaults + this.reset(isResetOnRevisit); + } + /** * Clones this model and all managed children returning a new branch. * Assign new unique ids to each cloned model. diff --git a/src/core/js/router.js b/src/core/js/router.js index a15596827..dc50cf2d4 100644 --- a/src/core/js/router.js +++ b/src/core/js/router.js @@ -145,7 +145,7 @@ define([ // Allow navigation. this.model.set('_canNavigate', true, { pluginName: 'adapt' }); // Scroll to element - Adapt.navigateToElement('.' + id, { replace: true }); + Adapt.navigateToElement('.' + id, { replace: true, duration: 400 }); return; } @@ -319,12 +319,14 @@ define([ } /** - * Allows a selector to be passed in and Adapt will navigate to this element + * Allows a selector to be passed in and Adapt will navigate to this element. Resolves + * asynchronously when the element has been navigated to. + * Backend for Adapt.navigateToElement * @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. + * @param {Object} [settings] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). + * @param {Object} [settings.replace=false] Set to `true` if you want to update the URL without creating an entry in the browser's history. */ - navigateToElement(selector, settings = {}) { + async navigateToElement(selector, settings = {}) { // Removes . symbol from the selector to find the model const currentModelId = selector.replace(/\./g, ''); const currentModel = Adapt.findById(currentModelId); @@ -338,17 +340,20 @@ define([ return Adapt.scrollTo(selector, settings); } - // 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)); - const shouldReplaceRoute = settings.replace || false; - this.model.set('_shouldNavigateFocus', false, { pluginName: 'adapt' }); - this.navigate('#/id/' + pageId, { trigger: true, replace: shouldReplaceRoute }); + await new Promise(resolve => { + // If the element is on another page navigate and wait until pageView:ready is fired + // Then scrollTo element + Adapt.once('contentObjectView:ready', _.debounce(async () => { + this.model.set('_shouldNavigateFocus', true, { pluginName: 'adapt' }); + await Adapt.scrollTo(selector, settings); + resolve(); + }, 1)); + + this.model.set('_shouldNavigateFocus', false, { pluginName: 'adapt' }); + this.navigate('#/id/' + pageId, { trigger: true, replace: shouldReplaceRoute }); + }); } get(...args) { diff --git a/src/core/js/scrolling.js b/src/core/js/scrolling.js index 5452e7bef..212a10db5 100644 --- a/src/core/js/scrolling.js +++ b/src/core/js/scrolling.js @@ -100,7 +100,24 @@ define([ }); } - scrollTo(selector, settings = {}) { + /** + * Allows a selector to be passed in and Adapt will scroll to this element. Resolves + * asynchronously when the element has been navigated/scrolled to. + * Backend for Adapt.scrollTo + * @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.replace=false] Set to `true` if you want to update the URL without creating an entry in the browser's history. + */ + async scrollTo(selector, settings = {}) { + + const currentModelId = selector.replace(/\./g, ''); + const currentModel = Adapt.findById(currentModelId); + if (!currentModel) return; + + if (!currentModel.get('_isRendered') || !currentModel.get('_isRendered')) { + await Adapt.parentView.renderTo(currentModelId); + } + // Get the current location - this is set in the router const location = (Adapt.location._contentType) ? Adapt.location._contentType : Adapt.location._currentLocation; @@ -136,10 +153,13 @@ define([ // 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); + await new Promise(resolve => { + _.delay(() => { + Adapt.a11y.focusNext(selector); + Adapt.trigger(`${location}:scrolledTo`, selector); + resolve(); + }, settings.duration + 300); + }); } } diff --git a/src/core/js/views/adaptView.js b/src/core/js/views/adaptView.js index c6a085214..5c6cd103a 100644 --- a/src/core/js/views/adaptView.js +++ b/src/core/js/views/adaptView.js @@ -1,6 +1,7 @@ define([ - 'core/js/adapt' -], function(Adapt) { + 'core/js/adapt', + '../childEvent' +], function(Adapt, ChildEvent) { class AdaptView extends Backbone.View { @@ -73,34 +74,177 @@ define([ }); } - addChildren() { - let nthChild = 0; - const { models } = this.model.getChildren(); - this.childViews = {}; - models.forEach(model => { - if (!model.get('_isAvailable')) return; - - nthChild++; - model.set('_nthChild', nthChild); - + /** + * Add children and descendant views, first-child-first. Wait until all possible + * views are added before resolving asynchronously. + * Will trigger 'view:addChild'(ChildEvent), 'view:requestChild'(ChildEvent) + * and 'view:childAdded'(ParentView, ChildView) accordingly. + * @returns {number} Count of views added + */ + async addChildren() { + this.nthChild = this.nthChild || 0; + // Check descendants first + let addedCount = await this.addDescendants(false); + // Iterate through existing available children and/or request new children + // if required and allowed + while (true) { + const models = this.model.getAvailableChildModels(); + const event = this._getAddChildEvent(models[this.nthChild]); + if (!event) { + break; + } + if (event.isForced) { + event.reset(); + } + if (event.isStoppedImmediate || !event.model) { + // If addChild has been stopped before it is added then + // set all subsequent models and their children as not rendered + const subsequentModels = models.slice(this.nthChild); + subsequentModels.forEach(model => model.setOnChildren('_isRendered', false)); + break; + } + // Set model state + const model = event.model; + model.set({ + '_isRendered': true, + '_nthChild': ++this.nthChild + }); + // Create child view 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.`); } - - const $parentContainer = this.$(this.constructor.childContainer); const childView = new ChildView({ model }); + this.addChildView(childView); + addedCount++; + if (event.isStoppedNext) { + break; + } + } + + if (!addedCount) { + return addedCount; + } - this.childViews[model.get('_id')] = childView; + // Children were added, unset _isReady + this.model.set('_isReady', false); + return addedCount; + } + + /** + * Child views can be added with '_renderPosition': 'outer-append' or + * 'inner-append' (default). Each child view will trigger a + * 'view:childAdded'(ParentView, ChildView) event and be added to the + * this.getChildViews() array on this parent. + * @param {AdaptView} childView + * @returns {AdaptView} Returns this childView + */ + addChildView(childView) { + const childViews = this.getChildViews() || []; + childViews.push(childView); + this.setChildViews(childViews); + const $parentContainer = this.$(this.constructor.childContainer); + switch (childView.model.get('_renderPosition')) { + case 'outer-append': + // Useful for trickle buttons, inline feedback etc + this.$el.append(childView.$el); + break; + case 'inner-append': + default: + $parentContainer.append(childView.$el); + break; + } + // Signify that a child has been added to the view to enable updates to status bars + Adapt.trigger('view:childAdded', this, childView); + return childView; + } - $parentContainer.append(childView.$el); + /** + * Iterates through existing childViews and runs addChildren on them, resolving + * the total count of views added asynchronously. + * @returns {number} Count of views added + */ + async addDescendants() { + let addedDescendantCount = 0; + const childViews = this.getChildViews(); + if (!childViews) { + return addedDescendantCount; + } + for (let i = 0, l = childViews.length; i < l; i++) { + const view = childViews[i]; + addedDescendantCount = view.addChildren ? await view.addChildren() : 0; + if (addedDescendantCount) { + break; + } + } + if (!addedDescendantCount) { + this.model.checkReadyStatus(); + return addedDescendantCount; + } + // Descendants were added, unset _isReady + this.model.set('_isReady', false); + return addedDescendantCount; + } + + /** + * Resolves after outstanding asynchronous view additions are finished + * and ready. + */ + async whenReady() { + if (this.model.get('_isReady')) return; + return new Promise(resolve => { + const onReadyChange = (model, value) => { + if (!value) return; + this.stopListening(this.model, 'change:_isReady', onReadyChange); + resolve(); + }; + this.listenTo(this.model, 'change:_isReady', onReadyChange); + this.model.checkReadyStatus(); }); } + /** + * Triggers and returns a new ChildEvent object for render control. + * This function is used by addChildren to manage event triggering. + * @param {AdaptModel} model + * @returns {ChildEvent} + */ + _getAddChildEvent(model) { + const isRequestChild = (!model); + let event = new ChildEvent(null, this, model); + if (isRequestChild) { + // No model has been supplied, we are at the end of the available child list + const canRequestChild = this.model.get('_canRequestChild'); + if (!canRequestChild) { + // This model cannot request children + return; + } + event.type = 'requestChild'; + // Send a request asking for a new model + Adapt.trigger('view:requestChild', event); + if (!event.hasRequestChild) { + // No new model was supplied + return; + } + // A new model has been supplied for the end of the list. + } + // Trigger an event to signify that a new model is to be added + event.type = 'addChild'; + Adapt.trigger('view:addChild', event); + // Close the event so that the final state can be scrutinized + event.close(); + return event; + } + + /** + * Return an array of all child and descendant views. + * @param {boolean} [isParentFirst=false] Array returns with parents before children + * @returns {[AdaptView]} + */ findDescendantViews(isParentFirst) { const descendants = []; - this.childViews && _.each(this.childViews, view => { + const childViews = this.getChildViews(); + childViews && childViews.forEach(view => { if (isParentFirst) descendants.push(view); const children = view.findDescendantViews && view.findDescendantViews(isParentFirst); if (children) descendants.push(...children); @@ -175,8 +319,28 @@ define([ this.$el.toggleClass('is-complete', isComplete); } + /** + * @returns {[AdaptViews]} + */ getChildViews() { - return this.childViews; + return this._childViews; + } + + /** + * @param {[AdaptView]} value + */ + setChildViews(value) { + this._childViews = value; + } + + /** + * Returns an indexed by id list of child views. + * @deprecated since 0.5.5 + * @returns {{ view.model.get('_id')); } } diff --git a/src/core/js/views/contentObjectView.js b/src/core/js/views/contentObjectView.js index 554f07dea..851bd256b 100644 --- a/src/core/js/views/contentObjectView.js +++ b/src/core/js/views/contentObjectView.js @@ -90,6 +90,41 @@ define([ _.defer(performIsReady); } + /** + * Force render up to specified id. Resolves when views are ready. + * @param {string} id + */ + async renderTo(id) { + let models = this.model.getAllDescendantModels(true).filter(model => model.get('_isAvailable')); + const index = models.findIndex(model => model.get('_id') === id); + if (index === -1) { + throw new Error(`Cannot renderTo "${id}" as it isn't a descendant.`); + } + // Return early if the model is already rendered and ready + const model = models[index]; + if (model.get('_isRendered') && model.get('_isReady')) { + return; + } + // Force all models up until the id to render + models = models.slice(0, index + 1); + const ids = _.indexBy(models, (model) => model.get('_id')); + const forceUntilId = (event) => { + const addingId = event.model.get('_id'); + if (!ids[addingId]) return; + event.force(); + if (addingId !== id) return; + Adapt.off('view:addChild', forceUntilId); + }; + Adapt.on('view:addChild', forceUntilId); + // Trigger addChildren cascade + await this.addChildren(); + await this.whenReady(); + // Error if model isn't rendered and ready + if (!model.get('_isRendered') || !model.get('_isReady')) { + throw new Error(`Cannot renderTo "${id}".`); + } + } + preRemove() { const type = this.constructor.type; Adapt.trigger(`${type}View:preRemove contentObjectView:preRemove view:preRemove`, this); @@ -106,7 +141,7 @@ define([ this.findDescendantViews().reverse().forEach(view => { view.remove(); }); - this.childViews = []; + this.setChildViews(null); super.remove(); _.defer(() => { Adapt.trigger(`${type}View:postRemove contentObjectView:postRemove view:postRemove`, this); diff --git a/src/core/js/views/pageView.js b/src/core/js/views/pageView.js index cd4c5a12b..c742d27a1 100644 --- a/src/core/js/views/pageView.js +++ b/src/core/js/views/pageView.js @@ -5,6 +5,15 @@ define([ class PageView extends ContentObjectView { + preRender() { + // checkIfResetOnRevisit on descendant models before render + this.model.getAllDescendantModels().forEach(model => { + if (!model.checkIfResetOnRevisit) return; + model.checkIfResetOnRevisit(); + }); + super.preRender(); + } + remove() { if (this.$pageLabel) { this.$pageLabel.remove();