diff --git a/amd/build/view_question.min.js b/amd/build/view_question.min.js index d5f48290..821c9f59 100644 --- a/amd/build/view_question.min.js +++ b/amd/build/view_question.min.js @@ -1,3 +1,3 @@ -define("qtype_questionpy/view_question",["exports","theme_boost/bootstrap/popover"],(function(_exports,_popover){function _classPrivateFieldInitSpec(obj,privateMap,value){!function(obj,privateCollection){if(privateCollection.has(obj))throw new TypeError("Cannot initialize the same private elements twice on an object")}(obj,privateMap),privateMap.set(obj,value)}function _classPrivateFieldGet(receiver,privateMap){return function(receiver,descriptor){if(descriptor.get)return descriptor.get.call(receiver);return descriptor.value}(receiver,_classExtractFieldDescriptor(receiver,privateMap,"get"))}function _classPrivateFieldSet(receiver,privateMap,value){return function(receiver,descriptor,value){if(descriptor.set)descriptor.set.call(receiver,value);else{if(!descriptor.writable)throw new TypeError("attempted to set read only private field");descriptor.value=value}}(receiver,_classExtractFieldDescriptor(receiver,privateMap,"set"),value),value}function _classExtractFieldDescriptor(receiver,privateMap,action){if(!privateMap.has(receiver))throw new TypeError("attempted to "+action+" private field on non-instance");return privateMap.get(receiver)}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.addIframeFormDataOnSubmit=function(iframeId,responseFieldName){const iframe=window.document.getElementById(iframeId);if(null===iframe)return void window.console.error("Could not find question iframe ".concat(iframeId,". Cannot save answers."));iframe.closest("form").addEventListener("formdata",(event=>{const iframeForm=iframe.contentDocument.getElementById("qpy-formulation");if(null===iframeForm)return void window.console.error("Could not find form in question iframe "+iframeId);const iframeFormData=new FormData(iframeForm),iframeObject=Object.fromEntries(iframeFormData);for(const name of iframeFormData.keys()){const values=iframeFormData.getAll(name);values.length>1&&(iframeObject[name]=values)}event.formData.append(responseFieldName,JSON.stringify(iframeObject))}))},_exports.getAttempt=function(){if(null===attempt)throw new Error("Attempt not initialized");return attempt},_exports.init=async function(readOnly,showGeneralFeedback,showSpecificFeedback,showRightAnswer,showCorrectness,autoSaveHintInputId,roles){for(const element of document.querySelectorAll("\n [required], [pattern],\n [minlength], [maxlength],\n [min], [max]\n "))element.addEventListener("change",(event=>validateInput(event.target)));const form=window.document.getElementById("qpy-formulation");if(form){form.addEventListener("submit",(event=>{event.preventDefault(),window.frameElement.closest("form").submit()}));const autoSaveHintElement=parent.document.getElementById(autoSaveHintInputId);autoSaveHintElement&&form.addEventListener("change",(function(){autoSaveHintElement.value=parseInt(autoSaveHintElement.value)+1}))}attempt=new Attempt(readOnly,showGeneralFeedback,showSpecificFeedback,showRightAnswer,showCorrectness,window.document.getElementById("qpy-formulation"),window.document.getElementById("qpy-general-feedback"),window.document.getElementById("qpy-specific-feedback"),window.document.getElementById("qpy-right-answer"),roles)};let attempt=null;function validateInput(element){element.checkValidity()?(element.classList.remove("is-invalid"),element.removeAttribute("aria-invalid")):(element.classList.add("is-invalid"),element.validity.valueMissing?element.removeAttribute("aria-invalid"):element.setAttribute("aria-invalid","true"))}var _readOnly=new WeakMap,_showGeneralFeedback=new WeakMap,_showSpecificFeedback=new WeakMap,_showRightAnswer=new WeakMap,_showCorrectness=new WeakMap,_formulation=new WeakMap,_generalFeedback=new WeakMap,_specificFeedback=new WeakMap,_rightAnswer=new WeakMap,_roles=new WeakMap;class Attempt{constructor(readOnly,showGeneralFeedback,showSpecificFeedback,showRightAnswer,showCorrectness,formulationElement,generalFeedbackElement,specificFeedbackElement,rightAnswer,roles){_classPrivateFieldInitSpec(this,_readOnly,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_showGeneralFeedback,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_showSpecificFeedback,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_showRightAnswer,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_showCorrectness,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_formulation,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_generalFeedback,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_specificFeedback,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_rightAnswer,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_roles,{writable:!0,value:void 0}),_classPrivateFieldSet(this,_readOnly,readOnly),_classPrivateFieldSet(this,_showGeneralFeedback,showGeneralFeedback),_classPrivateFieldSet(this,_showSpecificFeedback,showSpecificFeedback),_classPrivateFieldSet(this,_showRightAnswer,showRightAnswer),_classPrivateFieldSet(this,_showCorrectness,showCorrectness),_classPrivateFieldSet(this,_formulation,formulationElement),_classPrivateFieldSet(this,_generalFeedback,generalFeedbackElement),_classPrivateFieldSet(this,_specificFeedback,specificFeedbackElement),_classPrivateFieldSet(this,_rightAnswer,rightAnswer),_classPrivateFieldSet(this,_roles,roles)}get readOnly(){return _classPrivateFieldGet(this,_readOnly)}get showGeneralFeedback(){return _classPrivateFieldGet(this,_showGeneralFeedback)}get showSpecificFeedback(){return _classPrivateFieldGet(this,_showSpecificFeedback)}get showRightAnswer(){return _classPrivateFieldGet(this,_showRightAnswer)}get showCorrectness(){return _classPrivateFieldGet(this,_showCorrectness)}get formulationElement(){return _classPrivateFieldGet(this,_formulation)}get generalFeedbackElement(){return _classPrivateFieldGet(this,_generalFeedback)}get specificFeedbackElement(){return _classPrivateFieldGet(this,_specificFeedback)}get rightAnswerElement(){return _classPrivateFieldGet(this,_rightAnswer)}get userRoles(){return _classPrivateFieldGet(this,_roles)}}})); +define("qtype_questionpy/view_question",["exports","theme_boost/bootstrap/popover","core/utils"],(function(_exports,_popover,_utils){function _classPrivateFieldInitSpec(obj,privateMap,value){!function(obj,privateCollection){if(privateCollection.has(obj))throw new TypeError("Cannot initialize the same private elements twice on an object")}(obj,privateMap),privateMap.set(obj,value)}function _classPrivateFieldGet(receiver,privateMap){return function(receiver,descriptor){if(descriptor.get)return descriptor.get.call(receiver);return descriptor.value}(receiver,_classExtractFieldDescriptor(receiver,privateMap,"get"))}function _classPrivateFieldSet(receiver,privateMap,value){return function(receiver,descriptor,value){if(descriptor.set)descriptor.set.call(receiver,value);else{if(!descriptor.writable)throw new TypeError("attempted to set read only private field");descriptor.value=value}}(receiver,_classExtractFieldDescriptor(receiver,privateMap,"set"),value),value}function _classExtractFieldDescriptor(receiver,privateMap,action){if(!privateMap.has(receiver))throw new TypeError("attempted to "+action+" private field on non-instance");return privateMap.get(receiver)}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.addIframeFormDataOnSubmit=function(iframeId,responseFieldName){const iframe=window.document.getElementById(iframeId);if(null===iframe)return void window.console.error("Could not find question iframe ".concat(iframeId,". Cannot save answers."));iframe.closest("form").addEventListener("formdata",(event=>{const iframeForm=iframe.contentDocument.getElementById("qpy-formulation");if(null===iframeForm)return void window.console.error("Could not find form in question iframe "+iframeId);const jsonFormData=createJsonFromFormData(iframeForm);event.formData.set(responseFieldName,jsonFormData)}))},_exports.getAttempt=function(){if(null===attempt)throw new Error("Attempt not initialized");return attempt},_exports.init=async function(readOnly,showGeneralFeedback,showSpecificFeedback,showRightAnswer,showCorrectness,responseId,roles){for(const element of document.querySelectorAll("\n [required], [pattern],\n [minlength], [maxlength],\n [min], [max]\n "))element.addEventListener("change",(event=>validateInput(event.target)));const form=window.document.getElementById("qpy-formulation");if(form){form.addEventListener("submit",(event=>{event.preventDefault(),window.frameElement.closest("form").submit()}));const responseElement=parent.document.getElementById(responseId);responseElement&&form.addEventListener("change",(0,_utils.throttle)((()=>{responseElement.value=createJsonFromFormData(form)}),250))}attempt=new Attempt(readOnly,showGeneralFeedback,showSpecificFeedback,showRightAnswer,showCorrectness,window.document.getElementById("qpy-formulation"),window.document.getElementById("qpy-general-feedback"),window.document.getElementById("qpy-specific-feedback"),window.document.getElementById("qpy-right-answer"),roles)};let attempt=null;function validateInput(element){element.checkValidity()?(element.classList.remove("is-invalid"),element.removeAttribute("aria-invalid")):(element.classList.add("is-invalid"),element.validity.valueMissing?element.removeAttribute("aria-invalid"):element.setAttribute("aria-invalid","true"))}var _readOnly=new WeakMap,_showGeneralFeedback=new WeakMap,_showSpecificFeedback=new WeakMap,_showRightAnswer=new WeakMap,_showCorrectness=new WeakMap,_formulation=new WeakMap,_generalFeedback=new WeakMap,_specificFeedback=new WeakMap,_rightAnswer=new WeakMap,_roles=new WeakMap;class Attempt{constructor(readOnly,showGeneralFeedback,showSpecificFeedback,showRightAnswer,showCorrectness,formulationElement,generalFeedbackElement,specificFeedbackElement,rightAnswer,roles){_classPrivateFieldInitSpec(this,_readOnly,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_showGeneralFeedback,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_showSpecificFeedback,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_showRightAnswer,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_showCorrectness,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_formulation,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_generalFeedback,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_specificFeedback,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_rightAnswer,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_roles,{writable:!0,value:void 0}),_classPrivateFieldSet(this,_readOnly,readOnly),_classPrivateFieldSet(this,_showGeneralFeedback,showGeneralFeedback),_classPrivateFieldSet(this,_showSpecificFeedback,showSpecificFeedback),_classPrivateFieldSet(this,_showRightAnswer,showRightAnswer),_classPrivateFieldSet(this,_showCorrectness,showCorrectness),_classPrivateFieldSet(this,_formulation,formulationElement),_classPrivateFieldSet(this,_generalFeedback,generalFeedbackElement),_classPrivateFieldSet(this,_specificFeedback,specificFeedbackElement),_classPrivateFieldSet(this,_rightAnswer,rightAnswer),_classPrivateFieldSet(this,_roles,roles)}get readOnly(){return _classPrivateFieldGet(this,_readOnly)}get showGeneralFeedback(){return _classPrivateFieldGet(this,_showGeneralFeedback)}get showSpecificFeedback(){return _classPrivateFieldGet(this,_showSpecificFeedback)}get showRightAnswer(){return _classPrivateFieldGet(this,_showRightAnswer)}get showCorrectness(){return _classPrivateFieldGet(this,_showCorrectness)}get formulationElement(){return _classPrivateFieldGet(this,_formulation)}get generalFeedbackElement(){return _classPrivateFieldGet(this,_generalFeedback)}get specificFeedbackElement(){return _classPrivateFieldGet(this,_specificFeedback)}get rightAnswerElement(){return _classPrivateFieldGet(this,_rightAnswer)}get userRoles(){return _classPrivateFieldGet(this,_roles)}}function createJsonFromFormData(form){const iframeFormData=new FormData(form),iframeObject=Object.fromEntries(iframeFormData);for(const name of iframeFormData.keys()){const values=iframeFormData.getAll(name);values.length>1&&(iframeObject[name]=values)}return JSON.stringify(iframeObject)}})); //# sourceMappingURL=view_question.min.js.map \ No newline at end of file diff --git a/amd/build/view_question.min.js.map b/amd/build/view_question.min.js.map index e1e55ce8..fd9ee552 100644 --- a/amd/build/view_question.min.js.map +++ b/amd/build/view_question.min.js.map @@ -1 +1 @@ -{"version":3,"file":"view_question.min.js","sources":["../src/view_question.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.org\n *\n * Moodle is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Moodle is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Moodle. If not, see .\n */\n\nimport \"theme_boost/bootstrap/popover\";\n\n/**\n * @type {?Attempt} Attempt object that is passed to the question package.\n */\nlet attempt = null;\n\n/**\n * Validates constraints on the given element and adds / removes Bootstrap's `is-invalid` class.\n *\n * Bootstrap 4 will automatically mark valid and invalid inputs when a parent has the `was-validated` class, but that\n * will also display a check mark for inputs which aren't invalid. Since that might suggest that the entered value is\n * correct (which isn't checked here), we don't use that feature.\n *\n * The `aria-invalid` attribute is also added or removed.\n *\n * @param {HTMLInputElement} element\n */\nfunction validateInput(element) {\n const isValid = element.checkValidity();\n if (isValid) {\n element.classList.remove(\"is-invalid\");\n element.removeAttribute(\"aria-invalid\");\n } else {\n // Aria-invalid shouldn't be set for missing inputs until the user has tried to submit them.\n // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-invalid\n element.classList.add(\"is-invalid\");\n if (!element.validity.valueMissing) {\n element.setAttribute(\"aria-invalid\", \"true\");\n } else {\n element.removeAttribute(\"aria-invalid\");\n }\n }\n}\n\n/**\n * Initializes the question.\n *\n * This function must be called within the iframe.\n *\n * @param {boolean} readOnly\n * @param {boolean} showGeneralFeedback\n * @param {boolean} showSpecificFeedback\n * @param {boolean} showRightAnswer\n * @param {boolean} showCorrectness\n * @param {string} autoSaveHintInputId\n * @param {string[]} roles QPy role names that the user has.\n */\nexport async function init(\n readOnly,\n showGeneralFeedback,\n showSpecificFeedback,\n showRightAnswer,\n showCorrectness,\n autoSaveHintInputId,\n roles\n) {\n for (const element of document.querySelectorAll(`\n [required], [pattern],\n [minlength], [maxlength],\n [min], [max]\n `)) {\n element.addEventListener(\"change\", event => validateInput(event.target));\n }\n\n const form = window.document.getElementById(\"qpy-formulation\");\n if (form) {\n // On form submit, submit the quiz's main form in the parent window instead.\n form.addEventListener(\"submit\", event => {\n event.preventDefault();\n window.frameElement.closest(\"form\").submit();\n });\n\n // Modify a field in the main form in order to tell the Quiz's autosaver that the user changed an answer.\n const autoSaveHintElement = parent.document.getElementById(autoSaveHintInputId);\n if (autoSaveHintElement) {\n form.addEventListener(\"change\", function() {\n autoSaveHintElement.value = parseInt(autoSaveHintElement.value) + 1;\n });\n }\n }\n\n // Attempt object that is passed to the question package.\n attempt = new Attempt(\n readOnly,\n showGeneralFeedback,\n showSpecificFeedback,\n showRightAnswer,\n showCorrectness,\n window.document.getElementById(\"qpy-formulation\"),\n window.document.getElementById(\"qpy-general-feedback\"),\n window.document.getElementById(\"qpy-specific-feedback\"),\n window.document.getElementById(\"qpy-right-answer\"),\n roles\n );\n}\n\n/**\n * Get a QuestionPy attempt.\n *\n * @returns {Attempt}\n */\nexport function getAttempt() {\n if (attempt === null) {\n throw new Error(\"Attempt not initialized\");\n }\n return attempt;\n}\n\nclass Attempt {\n #readOnly;\n #showGeneralFeedback;\n #showSpecificFeedback;\n #showRightAnswer;\n #showCorrectness;\n #formulation;\n #generalFeedback;\n #specificFeedback;\n #rightAnswer;\n #roles;\n\n /**\n * @param {boolean} readOnly\n * @param {boolean} showGeneralFeedback\n * @param {boolean} showSpecificFeedback\n * @param {boolean} showRightAnswer\n * @param {boolean} showCorrectness\n * @param {Element} formulationElement\n * @param {?Element} generalFeedbackElement\n * @param {?Element} specificFeedbackElement\n * @param {?Element} rightAnswer\n * @param {string[]} roles\n */\n constructor(\n readOnly,\n showGeneralFeedback,\n showSpecificFeedback,\n showRightAnswer,\n showCorrectness,\n formulationElement,\n generalFeedbackElement,\n specificFeedbackElement,\n rightAnswer,\n roles\n ) {\n this.#readOnly = readOnly;\n this.#showGeneralFeedback = showGeneralFeedback;\n this.#showSpecificFeedback = showSpecificFeedback;\n this.#showRightAnswer = showRightAnswer;\n this.#showCorrectness = showCorrectness;\n this.#formulation = formulationElement;\n this.#generalFeedback = generalFeedbackElement;\n this.#specificFeedback = specificFeedbackElement;\n this.#rightAnswer = rightAnswer;\n this.#roles = roles;\n }\n\n /**\n * Whether the question should be displayed as a read-only review.\n *\n * @returns {boolean}\n */\n get readOnly() {\n return this.#readOnly;\n }\n\n /**\n * Whether the general feedback should be visible.\n *\n * This is typically feedback shown to all students after the question\n * is finished, irrespective of which answer they gave.\n *\n * @returns {boolean}\n */\n get showGeneralFeedback() {\n return this.#showGeneralFeedback;\n }\n\n /**\n * Whether the specific feedback should be visible.\n *\n * Specific feedback is typically the part of the feedback that changes based on the\n * answer that the student gave.\n *\n * @returns {boolean}\n */\n get showSpecificFeedback() {\n return this.#showSpecificFeedback;\n }\n\n /**\n * Whether the automatically generated display of what the correct answer is should be visible.\n *\n * @returns {boolean}\n */\n get showRightAnswer() {\n return this.#showRightAnswer;\n }\n\n /**\n * Whether the student should have what they got right and wrong clearly indicated.\n *\n * @returns {boolean}\n */\n get showCorrectness() {\n return this.#showCorrectness;\n }\n\n /**\n * Get the top html element where the question's formulation xhtml was inserted.\n *\n * @returns {Element}\n */\n get formulationElement() {\n return this.#formulation;\n }\n\n /**\n * Get the top html element where the question's general feedback xhtml was inserted (if available).\n *\n * @returns {?Element}\n */\n get generalFeedbackElement() {\n return this.#generalFeedback;\n }\n\n /**\n * Get the top html element where the question's specific feedback xhtml was inserted (if available).\n *\n * @returns {?Element}\n */\n get specificFeedbackElement() {\n return this.#specificFeedback;\n }\n\n /**\n * Get the top html element where the question's right answer xhtml was inserted (if available).\n *\n * @returns {?Element}\n */\n get rightAnswerElement() {\n return this.#rightAnswer;\n }\n\n /**\n * Get the names of the roles that the current user has.\n *\n * @typedef {'teacher' | 'developer' | 'scorer' | 'proctor'} roleName\n * @returns {roleName[]}\n */\n get userRoles() {\n return this.#roles;\n }\n}\n\n/**\n * JSON-encodes and adds the question's form data located in the iframe to the main form when it is submitted.\n *\n * This function must be called outside the iframe, on the parent window.\n *\n * @param {string} iframeId - The ID of the question's iframe.\n * @param {string} responseFieldName - The complete field name for the JSON-encoded iframe form data.\n */\nexport function addIframeFormDataOnSubmit(iframeId, responseFieldName) {\n const iframe = window.document.getElementById(iframeId);\n if (iframe === null) {\n window.console.error(`Could not find question iframe ${iframeId}. Cannot save answers.`);\n return;\n }\n\n const form = iframe.closest(\"form\");\n form.addEventListener(\"formdata\", event => {\n const iframeForm = iframe.contentDocument.getElementById(\"qpy-formulation\");\n if (iframeForm === null) {\n window.console.error(\"Could not find form in question iframe \" + iframeId);\n return;\n }\n const iframeFormData = new FormData(iframeForm);\n const iframeObject = Object.fromEntries(iframeFormData);\n for (const name of iframeFormData.keys()) {\n const values = iframeFormData.getAll(name);\n if (values.length > 1) {\n iframeObject[name] = values;\n }\n }\n event.formData.append(responseFieldName, JSON.stringify(iframeObject));\n });\n}\n"],"names":["iframeId","responseFieldName","iframe","window","document","getElementById","console","error","closest","addEventListener","event","iframeForm","contentDocument","iframeFormData","FormData","iframeObject","Object","fromEntries","name","keys","values","getAll","length","formData","append","JSON","stringify","attempt","Error","readOnly","showGeneralFeedback","showSpecificFeedback","showRightAnswer","showCorrectness","autoSaveHintInputId","roles","element","querySelectorAll","validateInput","target","form","preventDefault","frameElement","submit","autoSaveHintElement","parent","value","parseInt","Attempt","checkValidity","classList","remove","removeAttribute","add","validity","valueMissing","setAttribute","constructor","formulationElement","generalFeedbackElement","specificFeedbackElement","rightAnswer","this","rightAnswerElement","userRoles"],"mappings":"quCAwR0CA,SAAUC,yBAC1CC,OAASC,OAAOC,SAASC,eAAeL,aAC/B,OAAXE,mBACAC,OAAOG,QAAQC,+CAAwCP,oCAI9CE,OAAOM,QAAQ,QACvBC,iBAAiB,YAAYC,cACxBC,WAAaT,OAAOU,gBAAgBP,eAAe,sBACtC,OAAfM,uBACAR,OAAOG,QAAQC,MAAM,0CAA4CP,gBAG/Da,eAAiB,IAAIC,SAASH,YAC9BI,aAAeC,OAAOC,YAAYJ,oBACnC,MAAMK,QAAQL,eAAeM,OAAQ,OAChCC,OAASP,eAAeQ,OAAOH,MACjCE,OAAOE,OAAS,IAChBP,aAAaG,MAAQE,QAG7BV,MAAMa,SAASC,OAAOvB,kBAAmBwB,KAAKC,UAAUX,qDAtL5C,OAAZY,cACM,IAAIC,MAAM,kCAEbD,sCAzDPE,SACAC,oBACAC,qBACAC,gBACAC,gBACAC,oBACAC,WAEK,MAAMC,WAAWhC,SAASiC,oHAK3BD,QAAQ3B,iBAAiB,UAAUC,OAAS4B,cAAc5B,MAAM6B,gBAG9DC,KAAOrC,OAAOC,SAASC,eAAe,sBACxCmC,KAAM,CAENA,KAAK/B,iBAAiB,UAAUC,QAC5BA,MAAM+B,iBACNtC,OAAOuC,aAAalC,QAAQ,QAAQmC,kBAIlCC,oBAAsBC,OAAOzC,SAASC,eAAe6B,qBACvDU,qBACAJ,KAAK/B,iBAAiB,UAAU,WAC5BmC,oBAAoBE,MAAQC,SAASH,oBAAoBE,OAAS,KAM9EnB,QAAU,IAAIqB,QACVnB,SACAC,oBACAC,qBACAC,gBACAC,gBACA9B,OAAOC,SAASC,eAAe,mBAC/BF,OAAOC,SAASC,eAAe,wBAC/BF,OAAOC,SAASC,eAAe,yBAC/BF,OAAOC,SAASC,eAAe,oBAC/B8B,YAxFJR,QAAU,cAaLW,cAAcF,SACHA,QAAQa,iBAEpBb,QAAQc,UAAUC,OAAO,cACzBf,QAAQgB,gBAAgB,kBAIxBhB,QAAQc,UAAUG,IAAI,cACjBjB,QAAQkB,SAASC,aAGlBnB,QAAQgB,gBAAgB,gBAFxBhB,QAAQoB,aAAa,eAAgB,sSAiF3CR,QAwBFS,YACI5B,SACAC,oBACAC,qBACAC,gBACAC,gBACAyB,mBACAC,uBACAC,wBACAC,YACA1B,+xBAEiBN,0DACWC,sEACCC,kEACLC,6DACAC,yDACJyB,gEACIC,qEACCC,iEACLC,+CACN1B,OAQdN,4CACOiC,gBAWPhC,uDACOgC,2BAWP/B,wDACO+B,4BAQP9B,mDACO8B,uBAQP7B,mDACO6B,uBAQPJ,sDACOI,mBAQPH,0DACOG,uBAQPF,2DACOE,wBAQPC,sDACOD,mBASPE,6CACOF"} \ No newline at end of file +{"version":3,"file":"view_question.min.js","sources":["../src/view_question.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.org\n *\n * Moodle is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Moodle is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Moodle. If not, see .\n */\n\nimport \"theme_boost/bootstrap/popover\";\nimport {throttle} from \"core/utils\";\n\n/**\n * @type {?Attempt} Attempt object that is passed to the question package.\n */\nlet attempt = null;\n\n/**\n * Validates constraints on the given element and adds / removes Bootstrap's `is-invalid` class.\n *\n * Bootstrap 4 will automatically mark valid and invalid inputs when a parent has the `was-validated` class, but that\n * will also display a check mark for inputs which aren't invalid. Since that might suggest that the entered value is\n * correct (which isn't checked here), we don't use that feature.\n *\n * The `aria-invalid` attribute is also added or removed.\n *\n * @param {HTMLInputElement} element\n */\nfunction validateInput(element) {\n const isValid = element.checkValidity();\n if (isValid) {\n element.classList.remove(\"is-invalid\");\n element.removeAttribute(\"aria-invalid\");\n } else {\n // Aria-invalid shouldn't be set for missing inputs until the user has tried to submit them.\n // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-invalid\n element.classList.add(\"is-invalid\");\n if (!element.validity.valueMissing) {\n element.setAttribute(\"aria-invalid\", \"true\");\n } else {\n element.removeAttribute(\"aria-invalid\");\n }\n }\n}\n\n/**\n * Initializes the question.\n *\n * This function must be called within the iframe.\n *\n * @param {boolean} readOnly\n * @param {boolean} showGeneralFeedback\n * @param {boolean} showSpecificFeedback\n * @param {boolean} showRightAnswer\n * @param {boolean} showCorrectness\n * @param {string} responseId\n * @param {string[]} roles QPy role names that the user has.\n */\nexport async function init(\n readOnly,\n showGeneralFeedback,\n showSpecificFeedback,\n showRightAnswer,\n showCorrectness,\n responseId,\n roles\n) {\n for (const element of document.querySelectorAll(`\n [required], [pattern],\n [minlength], [maxlength],\n [min], [max]\n `)) {\n element.addEventListener(\"change\", event => validateInput(event.target));\n }\n\n const form = window.document.getElementById(\"qpy-formulation\");\n if (form) {\n // On form submit, submit the quiz's main form in the parent window instead.\n form.addEventListener(\"submit\", event => {\n event.preventDefault();\n window.frameElement.closest(\"form\").submit();\n });\n\n // Modify a field in the main form in order to tell the Quiz's autosaver that the user changed an answer.\n const responseElement = parent.document.getElementById(responseId);\n if (responseElement) {\n // We throttle here, as `JSON.stringify` might affect the performance.\n form.addEventListener(\"change\", throttle(() => {\n responseElement.value = createJsonFromFormData(form);\n }, 250));\n }\n }\n\n // Attempt object that is passed to the question package.\n attempt = new Attempt(\n readOnly,\n showGeneralFeedback,\n showSpecificFeedback,\n showRightAnswer,\n showCorrectness,\n window.document.getElementById(\"qpy-formulation\"),\n window.document.getElementById(\"qpy-general-feedback\"),\n window.document.getElementById(\"qpy-specific-feedback\"),\n window.document.getElementById(\"qpy-right-answer\"),\n roles\n );\n}\n\n/**\n * Get a QuestionPy attempt.\n *\n * @returns {Attempt}\n */\nexport function getAttempt() {\n if (attempt === null) {\n throw new Error(\"Attempt not initialized\");\n }\n return attempt;\n}\n\nclass Attempt {\n #readOnly;\n #showGeneralFeedback;\n #showSpecificFeedback;\n #showRightAnswer;\n #showCorrectness;\n #formulation;\n #generalFeedback;\n #specificFeedback;\n #rightAnswer;\n #roles;\n\n /**\n * @param {boolean} readOnly\n * @param {boolean} showGeneralFeedback\n * @param {boolean} showSpecificFeedback\n * @param {boolean} showRightAnswer\n * @param {boolean} showCorrectness\n * @param {Element} formulationElement\n * @param {?Element} generalFeedbackElement\n * @param {?Element} specificFeedbackElement\n * @param {?Element} rightAnswer\n * @param {string[]} roles\n */\n constructor(\n readOnly,\n showGeneralFeedback,\n showSpecificFeedback,\n showRightAnswer,\n showCorrectness,\n formulationElement,\n generalFeedbackElement,\n specificFeedbackElement,\n rightAnswer,\n roles\n ) {\n this.#readOnly = readOnly;\n this.#showGeneralFeedback = showGeneralFeedback;\n this.#showSpecificFeedback = showSpecificFeedback;\n this.#showRightAnswer = showRightAnswer;\n this.#showCorrectness = showCorrectness;\n this.#formulation = formulationElement;\n this.#generalFeedback = generalFeedbackElement;\n this.#specificFeedback = specificFeedbackElement;\n this.#rightAnswer = rightAnswer;\n this.#roles = roles;\n }\n\n /**\n * Whether the question should be displayed as a read-only review.\n *\n * @returns {boolean}\n */\n get readOnly() {\n return this.#readOnly;\n }\n\n /**\n * Whether the general feedback should be visible.\n *\n * This is typically feedback shown to all students after the question\n * is finished, irrespective of which answer they gave.\n *\n * @returns {boolean}\n */\n get showGeneralFeedback() {\n return this.#showGeneralFeedback;\n }\n\n /**\n * Whether the specific feedback should be visible.\n *\n * Specific feedback is typically the part of the feedback that changes based on the\n * answer that the student gave.\n *\n * @returns {boolean}\n */\n get showSpecificFeedback() {\n return this.#showSpecificFeedback;\n }\n\n /**\n * Whether the automatically generated display of what the correct answer is should be visible.\n *\n * @returns {boolean}\n */\n get showRightAnswer() {\n return this.#showRightAnswer;\n }\n\n /**\n * Whether the student should have what they got right and wrong clearly indicated.\n *\n * @returns {boolean}\n */\n get showCorrectness() {\n return this.#showCorrectness;\n }\n\n /**\n * Get the top html element where the question's formulation xhtml was inserted.\n *\n * @returns {Element}\n */\n get formulationElement() {\n return this.#formulation;\n }\n\n /**\n * Get the top html element where the question's general feedback xhtml was inserted (if available).\n *\n * @returns {?Element}\n */\n get generalFeedbackElement() {\n return this.#generalFeedback;\n }\n\n /**\n * Get the top html element where the question's specific feedback xhtml was inserted (if available).\n *\n * @returns {?Element}\n */\n get specificFeedbackElement() {\n return this.#specificFeedback;\n }\n\n /**\n * Get the top html element where the question's right answer xhtml was inserted (if available).\n *\n * @returns {?Element}\n */\n get rightAnswerElement() {\n return this.#rightAnswer;\n }\n\n /**\n * Get the names of the roles that the current user has.\n *\n * @typedef {'teacher' | 'developer' | 'scorer' | 'proctor'} roleName\n * @returns {roleName[]}\n */\n get userRoles() {\n return this.#roles;\n }\n}\n\n/**\n * Creates JSON from the FormData of the given form.\n *\n * @param {HTMLFormElement} form\n * @returns {string}\n */\nfunction createJsonFromFormData(form) {\n const iframeFormData = new FormData(form);\n const iframeObject = Object.fromEntries(iframeFormData);\n for (const name of iframeFormData.keys()) {\n const values = iframeFormData.getAll(name);\n if (values.length > 1) {\n iframeObject[name] = values;\n }\n }\n return JSON.stringify(iframeObject);\n}\n\n/**\n * JSON-encodes and adds the question's form data located in the iframe to the main form when it is submitted.\n *\n * This function must be called outside the iframe, on the parent window.\n *\n * @param {string} iframeId - The ID of the question's iframe.\n * @param {string} responseFieldName - The complete field name for the JSON-encoded iframe form data.\n */\nexport function addIframeFormDataOnSubmit(iframeId, responseFieldName) {\n const iframe = window.document.getElementById(iframeId);\n if (iframe === null) {\n window.console.error(`Could not find question iframe ${iframeId}. Cannot save answers.`);\n return;\n }\n\n const form = iframe.closest(\"form\");\n form.addEventListener(\"formdata\", event => {\n const iframeForm = iframe.contentDocument.getElementById(\"qpy-formulation\");\n if (iframeForm === null) {\n window.console.error(\"Could not find form in question iframe \" + iframeId);\n return;\n }\n // Since we are throttling the updating process of the response element on a change, it might happen that the\n // value is outdated - this is why we get the data again.\n const jsonFormData = createJsonFromFormData(iframeForm);\n event.formData.set(responseFieldName, jsonFormData);\n });\n}\n"],"names":["iframeId","responseFieldName","iframe","window","document","getElementById","console","error","closest","addEventListener","event","iframeForm","contentDocument","jsonFormData","createJsonFromFormData","formData","set","attempt","Error","readOnly","showGeneralFeedback","showSpecificFeedback","showRightAnswer","showCorrectness","responseId","roles","element","querySelectorAll","validateInput","target","form","preventDefault","frameElement","submit","responseElement","parent","value","Attempt","checkValidity","classList","remove","removeAttribute","add","validity","valueMissing","setAttribute","constructor","formulationElement","generalFeedbackElement","specificFeedbackElement","rightAnswer","this","rightAnswerElement","userRoles","iframeFormData","FormData","iframeObject","Object","fromEntries","name","keys","values","getAll","length","JSON","stringify"],"mappings":"yvCA4S0CA,SAAUC,yBAC1CC,OAASC,OAAOC,SAASC,eAAeL,aAC/B,OAAXE,mBACAC,OAAOG,QAAQC,+CAAwCP,oCAI9CE,OAAOM,QAAQ,QACvBC,iBAAiB,YAAYC,cACxBC,WAAaT,OAAOU,gBAAgBP,eAAe,sBACtC,OAAfM,uBACAR,OAAOG,QAAQC,MAAM,0CAA4CP,gBAK/Da,aAAeC,uBAAuBH,YAC5CD,MAAMK,SAASC,IAAIf,kBAAmBY,oDAnM1B,OAAZI,cACM,IAAIC,MAAM,kCAEbD,sCA1DPE,SACAC,oBACAC,qBACAC,gBACAC,gBACAC,WACAC,WAEK,MAAMC,WAAWtB,SAASuB,oHAK3BD,QAAQjB,iBAAiB,UAAUC,OAASkB,cAAclB,MAAMmB,gBAG9DC,KAAO3B,OAAOC,SAASC,eAAe,sBACxCyB,KAAM,CAENA,KAAKrB,iBAAiB,UAAUC,QAC5BA,MAAMqB,iBACN5B,OAAO6B,aAAaxB,QAAQ,QAAQyB,kBAIlCC,gBAAkBC,OAAO/B,SAASC,eAAemB,YACnDU,iBAEAJ,KAAKrB,iBAAiB,UAAU,oBAAS,KACrCyB,gBAAgBE,MAAQtB,uBAAuBgB,QAChD,MAKXb,QAAU,IAAIoB,QACVlB,SACAC,oBACAC,qBACAC,gBACAC,gBACApB,OAAOC,SAASC,eAAe,mBAC/BF,OAAOC,SAASC,eAAe,wBAC/BF,OAAOC,SAASC,eAAe,yBAC/BF,OAAOC,SAASC,eAAe,oBAC/BoB,YAzFJR,QAAU,cAaLW,cAAcF,SACHA,QAAQY,iBAEpBZ,QAAQa,UAAUC,OAAO,cACzBd,QAAQe,gBAAgB,kBAIxBf,QAAQa,UAAUG,IAAI,cACjBhB,QAAQiB,SAASC,aAGlBlB,QAAQe,gBAAgB,gBAFxBf,QAAQmB,aAAa,eAAgB,sSAkF3CR,QAwBFS,YACI3B,SACAC,oBACAC,qBACAC,gBACAC,gBACAwB,mBACAC,uBACAC,wBACAC,YACAzB,+xBAEiBN,0DACWC,sEACCC,kEACLC,6DACAC,yDACJwB,gEACIC,qEACCC,iEACLC,+CACNzB,OAQdN,4CACOgC,gBAWP/B,uDACO+B,2BAWP9B,wDACO8B,4BAQP7B,mDACO6B,uBAQP5B,mDACO4B,uBAQPJ,sDACOI,mBAQPH,0DACOG,uBAQPF,2DACOE,wBAQPC,sDACOD,mBASPE,6CACOF,uBAUNrC,uBAAuBgB,YACtBwB,eAAiB,IAAIC,SAASzB,MAC9B0B,aAAeC,OAAOC,YAAYJ,oBACnC,MAAMK,QAAQL,eAAeM,OAAQ,OAChCC,OAASP,eAAeQ,OAAOH,MACjCE,OAAOE,OAAS,IAChBP,aAAaG,MAAQE,eAGtBG,KAAKC,UAAUT"} \ No newline at end of file diff --git a/amd/src/view_question.js b/amd/src/view_question.js index d19d7c2a..a2bfcb3d 100644 --- a/amd/src/view_question.js +++ b/amd/src/view_question.js @@ -16,6 +16,7 @@ */ import "theme_boost/bootstrap/popover"; +import {throttle} from "core/utils"; /** * @type {?Attempt} Attempt object that is passed to the question package. @@ -60,7 +61,7 @@ function validateInput(element) { * @param {boolean} showSpecificFeedback * @param {boolean} showRightAnswer * @param {boolean} showCorrectness - * @param {string} autoSaveHintInputId + * @param {string} responseId * @param {string[]} roles QPy role names that the user has. */ export async function init( @@ -69,7 +70,7 @@ export async function init( showSpecificFeedback, showRightAnswer, showCorrectness, - autoSaveHintInputId, + responseId, roles ) { for (const element of document.querySelectorAll(` @@ -89,11 +90,12 @@ export async function init( }); // Modify a field in the main form in order to tell the Quiz's autosaver that the user changed an answer. - const autoSaveHintElement = parent.document.getElementById(autoSaveHintInputId); - if (autoSaveHintElement) { - form.addEventListener("change", function() { - autoSaveHintElement.value = parseInt(autoSaveHintElement.value) + 1; - }); + const responseElement = parent.document.getElementById(responseId); + if (responseElement) { + // We throttle here, as `JSON.stringify` might affect the performance. + form.addEventListener("change", throttle(() => { + responseElement.value = createJsonFromFormData(form); + }, 250)); } } @@ -270,6 +272,24 @@ class Attempt { } } +/** + * Creates JSON from the FormData of the given form. + * + * @param {HTMLFormElement} form + * @returns {string} + */ +function createJsonFromFormData(form) { + const iframeFormData = new FormData(form); + const iframeObject = Object.fromEntries(iframeFormData); + for (const name of iframeFormData.keys()) { + const values = iframeFormData.getAll(name); + if (values.length > 1) { + iframeObject[name] = values; + } + } + return JSON.stringify(iframeObject); +} + /** * JSON-encodes and adds the question's form data located in the iframe to the main form when it is submitted. * @@ -292,14 +312,9 @@ export function addIframeFormDataOnSubmit(iframeId, responseFieldName) { window.console.error("Could not find form in question iframe " + iframeId); return; } - const iframeFormData = new FormData(iframeForm); - const iframeObject = Object.fromEntries(iframeFormData); - for (const name of iframeFormData.keys()) { - const values = iframeFormData.getAll(name); - if (values.length > 1) { - iframeObject[name] = values; - } - } - event.formData.append(responseFieldName, JSON.stringify(iframeObject)); + // Since we are throttling the updating process of the response element on a change, it might happen that the + // value is outdated - this is why we get the data again. + const jsonFormData = createJsonFromFormData(iframeForm); + event.formData.set(responseFieldName, jsonFormData); }); } diff --git a/renderer.php b/renderer.php index 476cb51f..5304f77d 100644 --- a/renderer.php +++ b/renderer.php @@ -83,19 +83,21 @@ public function formulation_and_controls(question_attempt $qa, question_display_ try { $questiondivid = $qa->get_outer_question_div_unique_id(); - $autosavehintid = $questiondivid . '-autosave'; - $formulationcb = function (qtype_questionpy_renderer $renderer) use ($qa, $question, $options, $autosavehintid) { - return $renderer->formulation_controls_feedback_in_iframe($qa, $question->ui, $options, $autosavehintid); + $qpyresponseid = $questiondivid . '-qpy-response'; + $formulationcb = function (qtype_questionpy_renderer $renderer) use ($qa, $question, $options, $qpyresponseid) { + return $renderer->formulation_controls_feedback_in_iframe($qa, $question->ui, $options, $qpyresponseid); }; $iframesrc = $this->get_iframe_document($options->context, $question, $formulationcb); $iframeid = $questiondivid . '-iframe'; + $qpyresponsename = $qa->get_field_prefix() . constants::QT_VAR_RESPONSE; + if (!$options->readonly) { $this->page->requires->js_call_amd( 'qtype_questionpy/view_question', 'addIframeFormDataOnSubmit', - [$iframeid, $qa->get_field_prefix() . constants::QT_VAR_RESPONSE] + [$iframeid, $qpyresponsename] ); } @@ -112,13 +114,13 @@ public function formulation_and_controls(question_attempt $qa, question_display_ . get_string('attempt_detail_link', 'qtype_questionpy') . ''; } - // A hidden input field is used to tell the quiz autosaver that the user changed their question answer - // in the iframe. The value is increased by one every time. The autosaver detects this modification and will - // save all answers. - $autosavehintname = 'qpy-autosave-' . $questiondivid; - + // When the user changes their answer within the iframe, the value of the following hidden input field gets updated. + // The quiz autosaver detects this modification and will save all answers. + // This hidden field must exist in the outer form for the autosaver to work properly as it relies on the + // `HTMLFormElement.elements` attribute. + $lastqpyresponse = s($qa->get_last_qt_var(constants::QT_VAR_RESPONSE) ?? '{}'); $result .= << + EOA; @@ -212,13 +214,13 @@ protected function get_iframe_document(context $context, qtype_questionpy_questi * @param question_attempt $qa the question attempt to display. * @param attempt_ui $ui * @param question_display_options $options controls what should and should not be displayed. - * @param string $autosavehintinputid + * @param string $qpyresponseid * @return string HTML fragment. * @throws moodle_exception */ protected function formulation_controls_feedback_in_iframe( question_attempt $qa, attempt_ui $ui, - question_display_options $options, string $autosavehintinputid + question_display_options $options, string $qpyresponseid ): string { $renderer = question_ui_renderer::render($ui->formulation, $ui->placeholders, $options, $qa); @@ -249,7 +251,7 @@ protected function formulation_controls_feedback_in_iframe( $options->feedback === question_display_options::VISIBLE, $options->rightanswer === question_display_options::VISIBLE, $options->correctness === question_display_options::VISIBLE, - $autosavehintinputid, + $qpyresponseid, $roles, ] );