diff --git a/js/models/itemModel.js b/js/models/itemModel.js index 035facd7..5c17b0c7 100644 --- a/js/models/itemModel.js +++ b/js/models/itemModel.js @@ -1,6 +1,22 @@ +/** + * @file ItemModel - State model for a single interactive item within a component + * @module core/js/models/itemModel + * @description Represents one item (e.g. a tab, accordion panel, or answer option) within + * an items-based component. Tracks active and visited state, and supports per-item class toggling. + * Typically managed by {@link module:core/js/models/itemsComponentModel}. + * + * **Known Issues & Improvements:** + * - `_score` is only used when item scoring is enabled (`_hasItemScoring`); could be clearer in defaults. + */ import LockingModel from 'core/js/models/lockingModel'; import { toggleModelClass } from '../modelHelpers'; +/** + * @class ItemModel + * @classdesc State model for a single selectable or interactive item within an items-based component. + * Stores `_isActive`, `_isVisited`, `_score`, and `_classes`. + * @extends LockingModel + */ export default class ItemModel extends LockingModel { defaults() { @@ -13,9 +29,10 @@ export default class ItemModel extends LockingModel { } /** - * Toggle a className in the _classes attribute - * @param className {string} Name or names of class to add/remove to _classes attribute, space separated list - * @param hasClass {boolean|null|undefined} true to add a class, false to remove, null or undefined to toggle + * Toggle a CSS class name on the `_classes` attribute. + * @param {string} className - Name or space-separated names to add/remove from `_classes` + * @param {boolean|null} [hasClass] - `true` to add, `false` to remove, `null`/`undefined` to toggle + * @returns {ItemModel} This model, for chaining */ toggleClass(className, hasClass) { toggleModelClass(this, className, hasClass); @@ -26,10 +43,18 @@ export default class ItemModel extends LockingModel { this.set({ _isActive: false, _isVisited: false }); } + /** + * Set or toggle the `_isActive` state of this item. + * @param {boolean} [isActive] - `true` to activate, `false` to deactivate. Defaults to the inverse of the current state. + */ toggleActive(isActive = !this.get('_isActive')) { this.set('_isActive', Boolean(isActive)); } + /** + * Set or toggle the `_isVisited` state of this item. + * @param {boolean} [isVisited] - `true` to mark visited, `false` to unmark. Defaults to the inverse of the current state. + */ toggleVisited(isVisited = !this.get('_isVisited')) { this.set('_isVisited', Boolean(isVisited)); } diff --git a/js/models/itemsComponentModel.js b/js/models/itemsComponentModel.js index eae87a20..7c89adc4 100644 --- a/js/models/itemsComponentModel.js +++ b/js/models/itemsComponentModel.js @@ -1,6 +1,26 @@ +/** + * @file ItemsComponentModel - Base model for components backed by an item collection + * @module core/js/models/itemsComponentModel + * @description Extends ComponentModel to manage a Backbone.Collection of + * {@link module:core/js/models/itemModel|ItemModel} children. Provides item lookup, + * active/visited state management, user-answer persistence, and completion tracking. + * Used as a base class by tab, accordion, and carousel-style components. + * + * **Known Issues & Improvements:** + * - `Backbone` is referenced as a global rather than imported, which may cause issues in strict module environments. + * - Items are initialised from `_items` JSON but collection changes are not written back automatically (only via `toJSON`). + */ import ComponentModel from 'core/js/models/componentModel'; import ItemModel from 'core/js/models/itemModel'; +/** + * @class ItemsComponentModel + * @classdesc Base model for Adapt components that manage a collection of interactive items. + * Sets up a `Backbone.Collection` of {@link module:core/js/models/itemModel|ItemModel} instances + * accessible via `getChildren()`. Handles user-answer storage, visited-state tracking, and + * completion detection. + * @extends ComponentModel + */ export default class ItemsComponentModel extends ComponentModel { toJSON() { @@ -21,12 +41,20 @@ export default class ItemsComponentModel extends ComponentModel { super.init(); } + /** + * Restore `_isVisited` flags on child items from the stored `_userAnswer` boolean array. + * Called during revisit to reinstate the learner's previous interaction state. + */ restoreUserAnswers() { const booleanArray = this.get('_userAnswer'); if (!booleanArray) return; this.getChildren().forEach(child => child.set('_isVisited', booleanArray[child.get('_index')])); } + /** + * Persist the current visited state of all items as a sorted boolean array in `_userAnswer`. + * Items are sorted by `_index` before serialisation to ensure consistent order. + */ storeUserAnswer() { const items = this.getChildren().slice(0); items.sort((a, b) => a.get('_index') - b.get('_index')); @@ -41,6 +69,11 @@ export default class ItemsComponentModel extends ComponentModel { this.setChildren(new Backbone.Collection(items, { model: ItemModel })); } + /** + * Return the child ItemModel at the given index. + * @param {number} index - Zero-based item index + * @returns {ItemModel|undefined} + */ getItem(index) { return this.getChildren().findWhere({ _index: index }); } @@ -83,6 +116,11 @@ export default class ItemsComponentModel extends ComponentModel { this.getChildren().each(item => item.toggleActive(false)); } + /** + * Deactivate the current active item and activate the item at the given index. + * Does nothing if no item exists at `index`. + * @param {number} index - Zero-based index of the item to activate + */ setActiveItem(index) { const item = this.getItem(index); if (!item) return; diff --git a/js/models/itemsQuestionModel.js b/js/models/itemsQuestionModel.js index 1511a8a5..85a47a76 100644 --- a/js/models/itemsQuestionModel.js +++ b/js/models/itemsQuestionModel.js @@ -1,3 +1,17 @@ +/** + * @file ItemsQuestionModel - Question model for item-selection question types + * @module core/js/models/itemsQuestionModel + * @description Combines {@link module:core/js/models/questionModel|QuestionModel} and + * {@link module:core/js/models/itemsComponentModel|ItemsComponentModel} to support + * item-selection question types (e.g. MCQ, matching). Handles single- and multi-select modes, + * optional item-level scoring, randomisation, and individual item feedback. + * + * **Known Issues & Improvements:** + * - `BlendedItemsComponentQuestionModel` uses `Object.getOwnPropertyNames` to mix in + * `ItemsComponentModel` methods; a proper mixin utility would be cleaner. + * - `storeUserAnswer` overrides the `ItemsComponentModel` version to track `_isActive` + * instead of `_isVisited`; this asymmetry can be confusing. + */ import Adapt from 'core/js/adapt'; import QuestionModel from 'core/js/models/questionModel'; import ItemsComponentModel from 'core/js/models/itemsComponentModel'; @@ -28,6 +42,13 @@ Object.getOwnPropertyNames(ItemsComponentModel.prototype).forEach(name => { }); }); +/** + * @class ItemsQuestionModel + * @classdesc Question model for item-selection components such as MCQ and matching. + * Extends the blended QuestionModel + ItemsComponentModel base to support selectable items, + * single/multi-select modes, item-level scoring, and per-item feedback. + * @extends BlendedItemsComponentQuestionModel + */ export default class ItemsQuestionModel extends BlendedItemsComponentQuestionModel { init() { @@ -37,6 +58,10 @@ export default class ItemsQuestionModel extends BlendedItemsComponentQuestionMod this.checkCanSubmit(); } + /** + * Restore the learner's previous active selections from the stored `_userAnswer` array, + * then mark the question as submitted and recalculate score and feedback. + */ restoreUserAnswers() { if (!this.get('_isSubmitted')) return; @@ -53,20 +78,24 @@ export default class ItemsQuestionModel extends BlendedItemsComponentQuestionMod this.setupFeedback(); } + /** + * Shuffle the child item collection if `_isRandom` is enabled and the question is still enabled. + */ setupRandomisation() { if (!this.get('_isRandom') || !this.get('_isEnabled')) return; const children = this.getChildren(); children.set(children.shuffle()); } - // check if the user is allowed to submit the question 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 + /** + * Persist the active state of all items as a sorted boolean array in `_userAnswer`. + * Overrides `ItemsComponentModel#storeUserAnswer` to track `_isActive` rather than `_isVisited`. + */ storeUserAnswer() { const items = this.getChildren().slice(0); items.sort((a, b) => a.get('_index') - b.get('_index')); @@ -74,6 +103,11 @@ export default class ItemsQuestionModel extends BlendedItemsComponentQuestionMod this.set('_userAnswer', userAnswer); } + /** + * Evaluate whether the learner's active selections match the correct answer. + * Sets `_numberOfCorrectAnswers`, `_numberOfIncorrectAnswers`, and related props on the model. + * @returns {boolean} `true` if all required items are selected with no incorrect selections + */ isCorrect() { const allChildren = this.getChildren(); const activeChildren = allChildren.filter(itemModel => itemModel.get('_isActive')); @@ -116,12 +150,22 @@ export default class ItemsQuestionModel extends BlendedItemsComponentQuestionMod this.set('_score', score); } + /** + * When `_hasItemScoring` is enabled, returns the sum of `_score` values for active items. + * Otherwise falls back to the standard `QuestionModel` score logic. + * @type {number} + */ get score() { if (!this.get('_hasItemScoring')) return super.score; const children = this.getChildren()?.toArray() || []; return children.reduce((score, child) => (score += child.get('_isActive') ? child.get('_score') || 0 : 0), 0); } + /** + * When `_hasItemScoring` is enabled, returns the sum of the top `_selectable` positive item scores. + * Otherwise falls back to `QuestionModel#maxScore`. + * @type {number} + */ get maxScore() { if (!this.get('_hasItemScoring')) return super.maxScore; const children = this.getChildren()?.toArray() || []; @@ -130,6 +174,11 @@ export default class ItemsQuestionModel extends BlendedItemsComponentQuestionMod return scores.reverse().slice(0, this.get('_selectable')).filter(score => score > 0).reduce((maxScore, score) => (maxScore += score), 0); } + /** + * When `_hasItemScoring` is enabled, returns the sum of the lowest `_selectable` negative item scores. + * Otherwise falls back to `QuestionModel#minScore`. + * @type {number} + */ get minScore() { if (!this.get('_hasItemScoring')) return super.minScore; const children = this.getChildren()?.toArray() || []; @@ -138,6 +187,12 @@ export default class ItemsQuestionModel extends BlendedItemsComponentQuestionMod return scores.slice(0, this.get('_selectable')).filter(score => score < 0).reduce((minScore, score) => (minScore += score), 0); } + /** + * Return feedback config for the current state, merging individual item feedback when + * the question is incorrect, single-select, and the active item provides its own `feedback` data. + * @param {Object} [_feedback] - Feedback config; defaults to `this.get('_feedback')` + * @returns {{ title: string, body: string, _classes: string, _graphic?: Object, _imageAlignment?: string }} + */ getFeedback (_feedback = this.get('_feedback')) { if (!_feedback) return {}; const activeItem = this.getActiveItem(); @@ -185,9 +240,6 @@ export default class ItemsQuestionModel extends BlendedItemsComponentQuestionMod return selectedItems[selectedItems.length - 1]; } - /** - * Reset the question items for another attempt - */ resetQuestion() { this.resetItems(); } @@ -210,6 +262,10 @@ export default class ItemsQuestionModel extends BlendedItemsComponentQuestionMod this.set('_isAtLeastOneCorrectSelection', Boolean(this.getLastActiveItem())); } + /** + * Return a SCORM interactions object describing the correct responses and available choices. + * @returns {{ correctResponsesPattern: string[], choices: Array<{id: string, description: string}> }} + */ getInteractionObject() { const interactions = { correctResponsesPattern: [], @@ -238,9 +294,10 @@ export default class ItemsQuestionModel extends BlendedItemsComponentQuestionMod } /** - * 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' - */ + * Return the learner's active item indexes (1-based) as a comma-separated string + * for the `cmi.interactions.n.student_response` SCORM field. + * @returns {string} e.g. `'1,5,2'` + */ getResponse() { const activeItems = this.getActiveItems(); const activeIndexes = activeItems.map(itemModel => { @@ -251,8 +308,9 @@ export default class ItemsQuestionModel extends BlendedItemsComponentQuestionMod } /** - * used by adapt-contrib-spoor to get the type of this question in the format required by the cmi.interactions.n.type data field - */ + * Return `'choice'` as the SCORM interaction type for the `cmi.interactions.n.type` field. + * @returns {string} + */ getResponseType() { return 'choice'; } diff --git a/js/models/questionModel.js b/js/models/questionModel.js index 6b988989..18ac2378 100644 --- a/js/models/questionModel.js +++ b/js/models/questionModel.js @@ -1,3 +1,16 @@ +/** + * @file QuestionModel - Abstract base model for all question components + * @module core/js/models/questionModel + * @description Abstract base model providing the full question lifecycle: setup, submission, + * scoring, feedback, marking, and reset. Subclasses must implement {@link QuestionModel#canSubmit}, + * {@link QuestionModel#isCorrect}, {@link QuestionModel#isPartlyCorrect}, + * {@link QuestionModel#resetQuestion}, {@link QuestionModel#getResponse}, and + * {@link QuestionModel#getResponseType}. + * + * **Known Issues & Improvements:** + * - `setScore` is deprecated but still called internally; callers should migrate to the `score`, `maxScore`, and `minScore` getters. + * - `getFeedback` handles both legacy and current config shapes, adding long-term maintenance complexity. + */ import Adapt from 'core/js/adapt'; import components from 'core/js/components'; import ComponentModel from 'core/js/models/componentModel'; @@ -9,13 +22,15 @@ import BUTTON_STATE from 'core/js/enums/buttonStateEnum'; * @property {string} title */ +/** + * @class QuestionModel + * @classdesc Abstract base model for Adapt question components. Manages attempts, scoring, + * button state, feedback, and context activities. Register concrete question types via + * `components.register()` with a subclass of this model. + * @extends ComponentModel + */ class QuestionModel extends ComponentModel { - /// /// - // Setup question types - /// / - - // Used to set model defaults defaults() { // Extend from the ComponentModel defaults return ComponentModel.resultExtend('defaults', { @@ -35,7 +50,6 @@ class QuestionModel extends ComponentModel { }); } - // Extend from the ComponentModel trackable trackable() { return ComponentModel.resultExtend('trackable', [ '_isSubmitted', @@ -78,7 +92,6 @@ class QuestionModel extends ComponentModel { super.init(); } - // Calls default methods to setup on questions setupDefaultSettings() { // Not sure this is needed anymore, keeping to maintain API this.setupWeightSettings(); @@ -86,7 +99,6 @@ class QuestionModel extends ComponentModel { this.set('_shouldShowMarking', this.shouldShowMarking); } - // Used to setup either global or local button text setupButtonSettings() { const globalButtons = Adapt.course.get('_buttons'); @@ -118,31 +130,26 @@ class QuestionModel extends ComponentModel { } } - // Used to setup either global or local question weight/score setupWeightSettings() { // Not needed as handled by model defaults, keeping to maintain API } - /// /// - // Submit process - /// / - - // Use to check if the user is allowed to submit the question - // Maybe the user has to select an item? + /** + * Override in subclasses to determine whether the learner may submit the question. + * @returns {boolean} `true` if submission is allowed + */ canSubmit() {} checkCanSubmit() { this.set('_canSubmit', this.canSubmit(), { pluginName: 'adapt' }); } - // Used to update the amount of attempts the user has left updateAttempts() { const attemptsLeft = this.get('_attemptsLeft') || this.get('_attempts'); this.set('_attemptsLeft', attemptsLeft - 1); } - // Used to set _isEnabled and _isSubmitted on the model setQuestionAsSubmitted() { this.set({ _isEnabled: false, @@ -151,7 +158,6 @@ class QuestionModel extends ComponentModel { }); } - // Sets _isCorrect:true/false based upon isCorrect method below markQuestion() { this.set({ _isCorrect: this.isCorrect(), @@ -160,11 +166,16 @@ class QuestionModel extends ComponentModel { this.updateRawScore(); } - // Should return a boolean based upon whether to question is correct or not + /** + * Override in subclasses to evaluate whether the learner's answer is fully correct. + * @returns {boolean} `true` if the answer is correct + */ isCorrect() {} - // Used by the question to determine if the question is incorrect or partly correct - // Should return a boolean + /** + * Override in subclasses to evaluate whether the learner's answer is partly correct. + * @returns {boolean} `true` if the answer is partly correct + */ isPartlyCorrect() {} /** @@ -208,8 +219,6 @@ class QuestionModel extends ComponentModel { return 0; } - // Checks if the question should be set to complete - // Calls setCompletionStatus and adds complete classes checkQuestionCompletion() { const isComplete = (this.get('_isCorrect') || this.get('_attemptsLeft') === 0); @@ -220,8 +229,6 @@ class QuestionModel extends ComponentModel { return isComplete; } - // Updates buttons based upon question state by setting - // _buttonState on the model which buttonsView listens to updateButtons() { const isInteractionComplete = this.get('_isInteractionComplete'); const isCorrect = this.get('_isCorrect'); @@ -260,6 +267,13 @@ class QuestionModel extends ComponentModel { } + /** + * Build a feedback configuration object for the current question state. + * Handles both legacy config shapes (`feedback.correct` string / `_partlyCorrect` / `_incorrect`) + * and the current shape (`_correct`, `_partlyCorrectFinal`, `_incorrectFinal`, etc.). + * @param {Object} [feedback] - Feedback config; defaults to `this.get('_feedback')` + * @returns {{ title: string, body: string, _classes: string, isAltTitle: boolean, _graphic?: Object, _imageAlignment?: string }} + */ getFeedback(feedback = this.get('_feedback')) { if (!feedback) return {}; @@ -321,7 +335,6 @@ class QuestionModel extends ComponentModel { return feedbackConfig; } - // Used to setup the correct, incorrect and partly correct feedback setupFeedback() { if (!this.has('_feedback')) return; const { title = '', body = '' } = this.getFeedback(); @@ -372,7 +385,6 @@ class QuestionModel extends ComponentModel { return true; } - // Reset question for subsequent attempts setQuestionAsReset() { this.set({ _isEnabled: true, @@ -388,10 +400,17 @@ class QuestionModel extends ComponentModel { */ resetQuestion() {} + /** + * Trigger a `question:refresh` event to prompt the view to re-render. + */ refresh() { this.trigger('question:refresh'); } + /** + * Return the appropriate `BUTTON_STATE` value for the current question state. + * @returns {string} A value from {@link module:core/js/enums/buttonStateEnum|BUTTON_STATE} + */ getButtonState() { if (this.get('_isCorrect')) { return BUTTON_STATE.CORRECT; @@ -408,16 +427,16 @@ class QuestionModel extends ComponentModel { 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[] + /** + * Override in subclasses to return SCORM interaction data for `cmi.interactions`. + * @returns {{ correctResponsesPattern: string[], choices?: Array<{id: string, description: string}> }} + */ getInteractionObject() { return {}; } /** - * Add a `ContextActivity` for the content object ancestors assocaited with the question + * Add a `ContextActivity` for the content object ancestors associated with the question */ addContentObjectContextActivities() { // SCORM doesn't necessarily need course context as implied in reports (exclude via spoor) @@ -434,9 +453,9 @@ class QuestionModel extends ComponentModel { /** * Add a `ContextActivity` to the collection - * @param {string} id - * @param {string} type - * @param {string} title + * @param {string} id - The content object's `_id` + * @param {string} type - The content object's `_type` (e.g. `'page'`, `'menu'`) + * @param {string} title - The content object's display title */ addContextActivity(id, type, title) { const entry = { @@ -461,10 +480,16 @@ class QuestionModel extends ComponentModel { return this._contextActivities; } - // Returns a string detailing how the user answered the question. + /** + * Override in subclasses to return the learner's answer as a string for SCORM reporting. + * @returns {string} + */ getResponse() {} - // Returns a string describing the type of interaction: "choice" and "matching" supported (see scorm wrapper) + /** + * Override in subclasses to return the SCORM interaction type string (e.g. `'choice'`, `'matching'`). + * @returns {string} + */ getResponseType() {} /**