Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions js/models/itemModel.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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);
Expand All @@ -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));
}
Expand Down
38 changes: 38 additions & 0 deletions js/models/itemsComponentModel.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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'));
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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;
Expand Down
80 changes: 69 additions & 11 deletions js/models/itemsQuestionModel.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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() {
Expand All @@ -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;

Expand All @@ -53,27 +78,36 @@ 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'));
const userAnswer = items.map(itemModel => itemModel.get('_isActive'));
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'));
Expand Down Expand Up @@ -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() || [];
Expand All @@ -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() || [];
Expand All @@ -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();
Expand Down Expand Up @@ -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();
}
Expand All @@ -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: [],
Expand Down Expand Up @@ -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 => {
Expand All @@ -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';
}
Expand Down
Loading
Loading