UNPKG

@caspingus/lt

Version:

A utility library of helpers and tools for working with Learnosity APIs.

607 lines (554 loc) 20.6 kB
import * as app from '../../core/app'; import logger from '../../../utils/logger'; import * as activity from '../../core/activity'; import * as player from '../../core/player'; import * as items from '../../core/items'; import * as questions from '../../core/questions'; import * as entities from 'entities'; /** * Extensions add specific functionality to Items API. * They rely on modules within LT being available. * * -- * * This script changes the essay validation check on * string length to be character based, instead of * the default word based. * * It ignores spaces by default, so they are not * treated as characters to validate length. * * Works with `longtextV2` and `plaintext` question types. * @module Extensions/Assessment/essayLimitByCharacter */ const state = { includeSpaces: false, renderedCss: false, validTypes: ['longtextV2', 'plaintext'], }; /** * Looks for relevent question types and overrides validation * to be on character length. Uses the `max_length` (Word limit) * that was set up in authoring, treating the value as a character * length instead of word length. * * **Known limitations** * * If the assessment player is in responsive mode (< 800px) and * you *don't* have the review screen enabled (which it is by default) * then we can't inject a custom button. This means we render the * default Finish button, and no prevent submission will occur. * The solution is to use any of the valid regions (`main`, * `horizontal`, or `horizontal-fixed`) and don't decouple the * submit button from the review button in Items API configuration. * * Essentially, if you're using default options, you'll be fine. * * If submitting via the JavaScript `submit()` method, this will * skip the validation check. * * **Preventing submission** * * By default, questions are authored to prevent the user from * submitting their session in the event of word limit violations. * The same behaviour is inherited in this extension. If you don't * want to prevent submission, you can check the `Submit over limit` * option in the question authoring area. * * To prevent submission, we need to add a custom button to Items * API becuase we can't easily inject a validation check when the * default submit button is clicked, so we need to replace it with * a custom one. * * Adding a custom button is a capability in Items API. Below is a code * snippet of an Items API configuration object. Note the custom button * is added in `region_overrides`. * * You MUST use the `icon_class` and `name` as defined in the custom * button `options` object below. * * **`main` region** * ``` * { * "config": { * "regions": "main", * "region_overrides": { * "bottom-right": [ * { * "type": "custom_button", * "options": { * "name": "btn-essay-character-limit-submit", * "label": "Finish", * "icon_class": "item-next hidden" * }, * "position": "right" * }, * { * "type": "next_button", * "position": "right" * }, * { * "type": "previous_button", * "position": "right" * } * ] * } * } * } * ``` * * **`horizontal` or `horizontal-fixed` regions** * ``` * { * "config": { * "regions": "horizontal", * "region_overrides": { * "bottom": [ * { * "type": "custom_button", * "options": { * "name": "btn-essay-character-limit-submit", * "label": "Finish", * "icon_class": "item-next hidden" * }, * "position": "right" * }, * { * "type": "next_button", * "position": "right" * }, * { * "type": "horizontaltoc_element", * "position": "right" * }, * { * "type": "previous_button", * "position": "right" * } * ] * } * } * } * ``` * * **Changing labels** * * This extension will automatically change `Word Limit` to `Character Limit` in the * footer of the essay question types. However, for full coverage in review mode, or * in authoring and reporting, you should use label bundles. Eg: * * **Assessment label bundle** * * Use this in Items and Reports APIs. * * Caveat: this will update Math Essay and Chemistry Essay footers as well. * ``` * { * "config": { * "questions_api_init_options": { * "labelBundle": { * "wordLength": "Character Limit" * } * } * } * } * ``` * * **Authoring label bundle** * * Use this in Author API. * ``` * { * "config": { * "dependencies": { * "question_editor_api": { * "init_options": { * "label_bundle": { * "help.longtextV2.name:max_length": "Character limit", * "help.longtextV2.name:show_word_limit": "Character limit", * "help.longtextV2.description:max_length": "Maximum number of characters that can be entered in the text entry area (max 10,000 characters).", * "help.longtextV2.description:show_word_limit": "Defines whether the character limit should be displayed in the toolbar or not. The options are: <ul><li><strong>Always On</strong> - Character Limit is always displayed.</li><li><strong>On Limit</strong> - Character Limit will only be displayed when the limit is reached.</li><li><strong>Off</strong> - Character Limit will not be displayed.</li></ul>", * "help.longtextV2.description:submit_over_limit": "Determines if the user can save/submit text when the character limit has been exceeded.", * "longtextV2:max_length": "Character limit", * "longtextV2:show_word_count": "Show character count", * "longtextV2:show_word_limit": "Character limit", * "help.plaintext.name:max_length": "Character limit", * "help.plaintext.name:show_word_limit": "Character limit", * "help.plaintext.description:max_length": "Maximum number of characters that can be entered in the text entry area (max 10,000 characters).", * "help.plaintext.description:show_word_limit": "Defines whether the character limit should be displayed in the toolbar or not. The options are: <ul><li><strong>Always On</strong> - Character Limit is always displayed.</li><li><strong>On Limit</strong> - Character Limit will only be displayed when the limit is reached.</li><li><strong>Off</strong> - Character Limit will not be displayed.</li></ul>", * "plaintext:max_length": "Character limit", * "plaintext:show_word_limit": "Character limit" * } * } * }, * "questions_api": { * "init_options": { * "labelBundle": { * "wordLength": "Character Limit" * } * } * } * } * } * } * ``` * * @example * import { LT } from '@caspingus/lt/src/assessment/index'; * * LT.init(itemsApp); // Set up LT with the Items API application instance variable * LT.extensions.essayLimitByCharacter.run(); * @param {boolean} includeSpaces Whether to include spaces in the character count * Default is `false`. * @since 0.10.0 */ export function run(includeSpaces = false) { state.includeSpaces = Boolean(includeSpaces); state.renderedCss || injectCSS(); setQuestionListeners(); // Set up a listener on item load to check Finish button state app.appInstance().on('item:load', () => { setSubmitButtonState(); }); const elCustomSubmit = document.querySelector('.custom_btn.item-next'); if (elCustomSubmit) { elCustomSubmit.classList.add('lrn_btn_blue'); setupSubmitPrevention(); } else { logger.error('No custom submit button found. Character length validation will occur, but no submission prevention.'); } } /** * Checks resume mode, on load of the API to see whether we have * existing responses to load an accurate character count for. * Also sets a change listener on all valid types to check limit. * @since 1.3.0 * @ignore */ function setQuestionListeners() { const appInstance = app.appInstance(); const questions = Object.values(appInstance.getQuestions()); questions .filter(question => state.validTypes.includes(question.type)) .forEach(question => { const questionInstance = appInstance.question(question.response_id); questionInstance.on('rendered', () => { setupEssayValidationUI(questionInstance); // Check on load for existing responses if (activity.isResuming()) { checkLimit(questionInstance); } }); questionInstance.on('changed', () => checkLimit(questionInstance)); }); } /** * Checks the user response to see if they are * over the validation limit. * @param {object} questionInstance * @param {boolean} setUI Whether to add UI validation. * @returns {boolean} * @since 0.10.0 * @ignore */ function checkLimit(questionInstance, setUI = true) { const type = questionInstance.getQuestion().type; const maxLength = questionInstance.getQuestion().max_length; const rawResponse = questionInstance.getResponse()?.value ? questionInstance.getResponse()?.value : ''; let validLength = true; let response; let strLength; if (type === 'plaintext') { response = state.includeSpaces ? rawResponse : stripSpaces(rawResponse); strLength = response.length; } else { response = state.includeSpaces ? stripHtml(rawResponse) : stripSpaces(stripHtml(rawResponse)); strLength = entities.decodeHTML(response).length; } if (maxLength) { if (strLength > maxLength) { validLength = false; } } if (setUI) { setValidationUI(questionInstance, validLength, strLength); } return validLength; } /** * Updates the character count in the UI and, if * necessary, sets validation classes. * @param {object} questionInstance * @param {boolean} isValid * @param {number} strLength * @since 0.10.0 * @ignore */ function setValidationUI(questionInstance, isValid, strLength) { const id = questionInstance.getQuestion().response_id; const elContainer = document.getElementById(id); const elEditor = elContainer.querySelector('.lrn_texteditor_editable'); const elWordCount = elContainer.querySelector('.lrn_word_count'); const elLengthIndicator = elContainer.querySelector('.lrn_length_indicator'); const warningClassIndicator = 'lrn_wordcount_warning_label'; const warningClassEditor = 'lrn_wordcount_warning'; let characterCount = strLength; if (questionInstance.getQuestion().type === 'plaintext') { characterCount = strLength + ' /'; setTimeout(() => { setUI(); }, 10); } else { setUI(); } function setUI() { elWordCount.textContent = characterCount; if (!isValid) { elEditor.classList.add(warningClassEditor); elLengthIndicator.classList.add(warningClassIndicator); } else { elEditor.classList.remove(warningClassEditor); elLengthIndicator.classList.remove(warningClassIndicator); } } } /** * Replaces `Word` with `Character` in the default UI if a * label bundle hasn't been set in Items API config. * @param {object} questionInstance * @since 0.10.0 * @ignore */ function setupEssayValidationUI(questionInstance) { const hasLabelBundle = activity.activity()?.config?.questions_api_init_options?.labelBundle?.wordLength; if (!hasLabelBundle) { const id = questionInstance.getQuestion().response_id; const elContainer = document.getElementById(id); const elWordLimit = elContainer.querySelector('.lrn_word_limit'); const wordLimitText = elWordLimit.textContent; const newWordLimitText = wordLimitText.replace('Word', 'Character'); elWordLimit.textContent = newWordLimitText; } } /** * Sets up click events on the possible submit buttons, then * calls checkValidResponses() when clicked. * Possible submit buttons are "Finish" inside the review screen, * or the custom button declared in Items API configuration. * @since 1.1.0 * @ignore */ function setupSubmitPrevention() { const elCustomSubmit = document.querySelector('.custom_btn.item-next'); if (elCustomSubmit) { elCustomSubmit.addEventListener('click', checkValidResponses); app.appInstance().on('test:panel:shown', () => { const elReviewSubmit = document.querySelector('.panel-footer .test-submit'); if (elReviewSubmit) { elReviewSubmit.addEventListener('click', checkValidResponses); } }); } } /** * Checks any essays on the session and their character length. * If the length on any is invalid, and the `submit_over_limit` * flag isn't set, we prevent submission. * Works when the custom submit button is clicked, or when we * override the "Finish" button in the review screen. We only * do the latter when a custom submit button also exists. * @param {object} e Click event object. * @since 1.1.0 * @ignore */ function checkValidResponses(e) { const sessionQuestions = app.appInstance().getQuestions(); const invalidResponseIds = []; for (const q in sessionQuestions) { if (state.validTypes.includes(sessionQuestions[q].type)) { if (!sessionQuestions[q]?.submit_over_limit && !checkLimit(questions.questionInstance(q), false)) { invalidResponseIds.push(q); } } } if (invalidResponseIds.length) { logger.warn('Invalid essay response length found.'); e.preventDefault(); e.stopPropagation(); const itemReferences = []; for (let i = 0; i < invalidResponseIds.length; i++) { const temp = items.itemByResponseId(invalidResponseIds[i]); if (temp) { itemReferences.push(temp.source.reference); } } loadErrorDialog(itemReferences); } else { submit(); } } /** * Handles showing/hiding the default "Finish" button with a * custom button as declared in Items API configuration. We * need to do this because we can't preventDefault on the default * submit (Finish) button. * Executes on every item:load event. * @since 1.1.0 * @ignore */ function setSubmitButtonState() { const elDefaultSubmit = document.querySelector('.test-submit.item-next'); const elCustomSubmit = document.querySelector('.custom_btn.item-next'); if (elCustomSubmit && !player.isResponsiveMode()) { if (!items.isLastItem()) { elCustomSubmit.classList.add('hidden'); } else { if (hasReviewScreenOnFinish() && activity.region()) { elCustomSubmit.classList.add('hidden'); } else { elDefaultSubmit.classList.add('hidden'); elCustomSubmit.classList.remove('hidden'); } } } } /** * Checks to see if the session was set up with a review * screen as the last step prior to submission. We need * to know this because in that scenario, there is a "Review" * button ono the last item instead of "Finish". * @returns {boolean} * @since 1.1.0 * @ignore */ function hasReviewScreenOnFinish() { const hasReviewElement = document.querySelector('.review-screen'); const isDecoupled = activity.activity()?.config?.configuration?.decouple_submit_from_review; if (!hasReviewElement || isDecoupled) { return false; } return true; } /** * Loads a custom Items API dialog to alert the user they * have invalid response. This is the same as the default * modal we have for word count violations. * We check for labels from the Items API config object * first, otherwise we use the default (english) labels. * @param {array} itemReferences * @since 1.1.0 * @ignore */ function loadErrorDialog(itemReferences) { const labels = { question: activity.activity()?.config?.labelBundle?.question || 'Question', submitTest: activity.activity()?.config?.labelBundle?.submitTest || 'Submit activity', decline: activity.activity()?.config?.labelBundle?.decline || 'Cancel', invalidQuestionsMessage: activity.activity()?.config?.labelBundle?.invalidQuestionsMessage || 'The following questions are not currently valid. Please follow the links to review', }; let template = ` <p>${labels.invalidQuestionsMessage}</p> <ul> `; for (let i = 0; i < itemReferences.length; i++) { template += `<li class="link essay-limit-character-item" data-item-reference="${itemReferences[i]}">${labels.question}</li>`; } template += '</ul>'; app.assessApp().on('button:btn_essay_character_limit_cancel:clicked', () => { player.hideDialog(); }); app.appInstance().on('test:panel:show', () => { setTimeout(() => { const elLinks = document.querySelectorAll('.essay-limit-character-item'); if (elLinks) { elLinks.forEach(el => { const itemReference = el.getAttribute('data-item-reference'); el.addEventListener('click', () => { app.appInstance().items().goto(itemReference); player.hideDialog(); }); }); } }, 500); }); player.dialog({ header: labels.submitTest, body: template, buttons: [ { button_id: 'btn_essay_character_limit_cancel', label: labels.decline, is_primary: true, }, ], }); } /** * Because we are using a custom submit button, we need * to submit manually when the button is clicked. However, * we do this by sending a click through the (hidden) * default submit button. This way we get the player behaviour * for submission that isn't available using the submit() method. * If for some reason there is no default submit button, we * submit using the method, with no default checks. * @since 1.1.0 * @ignore */ function submit() { const elDefaultSubmit = document.getElementById('lrn_assess_next_btn'); if (elDefaultSubmit) { elDefaultSubmit.click(); } else { const settings = { show_submit_confirmation: true, show_submit_ui: true, success: response_ids => { logger.info('Submit was successful', response_ids); }, error: event => { logger.error('Submit has failed', event); }, }; app.appInstance().submit(settings); } } /** * Strips HTML from a string. * @param {string} s * @returns {string} * @since 0.10.0 * @ignore */ function stripHtml(s) { return s.replace(/<[^>]*>/g, '').trim(); } /** * Strips spaces from a string. * @param {string} s * @returns {string} * @since 0.10.0 * @ignore */ function stripSpaces(s) { return s.replace(/\s+/g, ''); } /** * Injects the necessary CSS to the header * @since 0.10.0 * @ignore */ function injectCSS() { const elStyle = document.createElement('style'); const css = ` /* Learnosity essay limit by character styles */ .lrn_widget .lrn_word_count, .lrn_widget .lrn_character_count { margin-right: 0px; } `; elStyle.textContent = css; document.head.append(elStyle); state.renderedCss = true; }