From ebee86bfb1a02b1eb84522a4116798f8fbd79ec9 Mon Sep 17 00:00:00 2001 From: Joseph Replin Date: Tue, 12 May 2026 14:51:33 -0500 Subject: [PATCH] Add JSDoc and docs to item/question models Add comprehensive JSDoc headers and inline documentation across itemModel, itemsComponentModel, itemsQuestionModel, and questionModel. Document class responsibilities, public methods and return values (e.g. toggleClass, toggleActive/toggleVisited, restoreUserAnswers, storeUserAnswer, getItem, setActiveItem, setupRandomisation, getInteractionObject, getResponse/getResponseType), and note a few known issues/improvements. Enhance QuestionModel with clearer abstract method docs (canSubmit, isCorrect, isPartlyCorrect, getFeedback, getResponse, getResponseType), button state helper and small API clarifications. No functional behavioral changes beyond added return annotations and documentation, and a few minor API clarifications (toggleClass now documents chaining return). --- js/models/itemModel.js | 31 +++++++++- js/models/itemsComponentModel.js | 38 ++++++++++++ js/models/itemsQuestionModel.js | 80 ++++++++++++++++++++++---- js/models/questionModel.js | 99 ++++++++++++++++++++------------ 4 files changed, 197 insertions(+), 51 deletions(-) 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() {} /**