diff --git a/amd/build/view_question.min.js b/amd/build/view_question.min.js index c68e653a..3818e694 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","core/utils","core_form/events","core/event_dispatcher"],(function(_exports,_popover,_utils,_events,_event_dispatcher){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,data,environmentVersion){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()})),form.addEventListener("formdata",(event=>{event.formData.set("data",JSON.stringify(attempt.data))}));const responseElement=parent.document.getElementById(responseId);responseElement&&form.addEventListener("change",(0,_utils.throttle)((()=>{responseElement.value=createJsonFromFormData(form)}),250)),form.addEventListener(_events.eventTypes.uploadChanged,(()=>{(0,_event_dispatcher.dispatchEvent)(_events.eventTypes.uploadChanged,{},window.frameElement.closest("form"),{bubbles:!0,cancelable:!1,composed:!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,data,environmentVersion)};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 _name=new WeakMap,_version=new WeakMap;class AttemptEnvironment{constructor(name,version){_classPrivateFieldInitSpec(this,_name,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_version,{writable:!0,value:void 0}),_classPrivateFieldSet(this,_name,name),_classPrivateFieldSet(this,_version,version)}get name(){return _classPrivateFieldGet(this,_name)}get version(){return _classPrivateFieldGet(this,_version)}}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,_data=new WeakMap,_environment=new WeakMap;class Attempt{constructor(readOnly,showGeneralFeedback,showSpecificFeedback,showRightAnswer,showCorrectness,formulationElement,generalFeedbackElement,specificFeedbackElement,rightAnswer,roles,data,environmentVersion){_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}),_classPrivateFieldInitSpec(this,_data,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_environment,{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);var onChange;_classPrivateFieldSet(this,_data,(onChange=()=>this.formulationElement.dispatchEvent(new Event("change")),new Proxy(data,{set(target,propertyKey,newValue,receiver){const success=Reflect.set(target,propertyKey,newValue,receiver);return onChange(),success},defineProperty(target,propertyKey,attributes){const success=Reflect.defineProperty(target,propertyKey,attributes);return onChange(),success},deleteProperty(target,propertyKey){const success=Reflect.deleteProperty(target,propertyKey);return onChange(),success}}))),_classPrivateFieldSet(this,_environment,new AttemptEnvironment("Moodle",environmentVersion))}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)}get data(){return _classPrivateFieldGet(this,_data)}get environment(){return _classPrivateFieldGet(this,_environment)}}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 iframeObject.data?iframeObject.data=JSON.parse(iframeObject.data):window.console.warn("The form data field 'data' is missing in the question iframe form."),JSON.stringify(iframeObject)}})); +define("qtype_questionpy/view_question",["exports","theme_boost/bootstrap/popover","core/utils","core_form/events","core/event_dispatcher"],(function(_exports,_popover,_utils,_events,_event_dispatcher){var _templateObject,_templateObject2;function _taggedTemplateLiteral(strings,raw){return raw||(raw=strings.slice(0)),Object.freeze(Object.defineProperties(strings,{raw:{value:Object.freeze(raw)}}))}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,editorsFieldName,editorNames){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[responseData,editorsData]=collectFormData(iframeForm,editorNames);event.formData.set(responseFieldName,responseData),event.formData.set(editorsFieldName,editorsData)}))},_exports.getAttempt=function(){if(null===attempt)throw new Error("Attempt not initialized");return attempt},_exports.init=async function(readOnly,showGeneralFeedback,showSpecificFeedback,showRightAnswer,showCorrectness,responseId,editorsId,editorNames,roles,data,environmentVersion){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()})),form.addEventListener("formdata",(event=>{event.formData.set("data",JSON.stringify(attempt.data))}));const responseElement=parent.document.getElementById(responseId),editorsElement=parent.document.getElementById(editorsId);(responseElement||editorsElement)&&form.addEventListener("change",(0,_utils.throttle)((()=>{const[responseData,editorsData]=collectFormData(form,editorNames);responseElement&&(responseElement.value=responseData),editorsElement&&(editorsElement.value=editorsData)}),250)),form.addEventListener(_events.eventTypes.uploadChanged,(()=>{(0,_event_dispatcher.dispatchEvent)(_events.eventTypes.uploadChanged,{},window.frameElement.closest("form"),{bubbles:!0,cancelable:!1,composed:!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,data,environmentVersion)};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 _name=new WeakMap,_version=new WeakMap;class AttemptEnvironment{constructor(name,version){_classPrivateFieldInitSpec(this,_name,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_version,{writable:!0,value:void 0}),_classPrivateFieldSet(this,_name,name),_classPrivateFieldSet(this,_version,version)}get name(){return _classPrivateFieldGet(this,_name)}get version(){return _classPrivateFieldGet(this,_version)}}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,_data=new WeakMap,_environment=new WeakMap;class Attempt{constructor(readOnly,showGeneralFeedback,showSpecificFeedback,showRightAnswer,showCorrectness,formulationElement,generalFeedbackElement,specificFeedbackElement,rightAnswer,roles,data,environmentVersion){_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}),_classPrivateFieldInitSpec(this,_data,{writable:!0,value:void 0}),_classPrivateFieldInitSpec(this,_environment,{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);var onChange;_classPrivateFieldSet(this,_data,(onChange=()=>this.formulationElement.dispatchEvent(new Event("change")),new Proxy(data,{set(target,propertyKey,newValue,receiver){const success=Reflect.set(target,propertyKey,newValue,receiver);return onChange(),success},defineProperty(target,propertyKey,attributes){const success=Reflect.defineProperty(target,propertyKey,attributes);return onChange(),success},deleteProperty(target,propertyKey){const success=Reflect.deleteProperty(target,propertyKey);return onChange(),success}}))),_classPrivateFieldSet(this,_environment,new AttemptEnvironment("Moodle",environmentVersion))}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)}get data(){return _classPrivateFieldGet(this,_data)}get environment(){return _classPrivateFieldGet(this,_environment)}}function buildDraftFileUrlRegex(){const wwwrootWithoutScheme=M.cfg.wwwroot.replace(/^https?:\/\//,"");return new RegExp(String.raw(_templateObject||(_templateObject=_taggedTemplateLiteral(["https?://","/draftfile.php/(?d+)"],["https?://","/draftfile\\.php/(?\\d+)"])),wwwrootWithoutScheme)+String.raw(_templateObject2||(_templateObject2=_taggedTemplateLiteral(["/user/draft/(?d+)/(?[^'\",&<>|`s:\\\\]+)"],["/user/draft/(?\\d+)/(?[^\\'\\\",&<>|\\`\\s:\\\\\\\\]+)"]))),"g")}function collectFormData(form,editorNames){const iframeFormData=new FormData(form),editorData={};for(const name of editorNames){const textKey="".concat(name,"[text]"),formatKey="".concat(name,"[format]"),itemidKey="".concat(name,"[itemid]"),text=iframeFormData.get(textKey);if(null===text)continue;const replacedText=text.replaceAll(buildDraftFileUrlRegex(),"@@PLUGINFILE@@/$");editorData[name]={text:replacedText},iframeFormData.delete(textKey);const format=iframeFormData.get(formatKey);null!==format&&(editorData[name].format=format,iframeFormData.delete(formatKey)),iframeFormData.delete(itemidKey)}const responseObject=Object.fromEntries(iframeFormData);for(const name of iframeFormData.keys()){const values=iframeFormData.getAll(name);values.length>1&&(responseObject[name]=values)}return responseObject.data?responseObject.data=JSON.parse(responseObject.data):window.console.warn("The form data field 'data' is missing in the question iframe form."),[JSON.stringify(responseObject),JSON.stringify(editorData)]}})); //# 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 7e12d903..1548845e 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\";\nimport {throttle} from \"core/utils\";\nimport {eventTypes} from \"core_form/events\";\nimport {dispatchEvent} from \"core/event_dispatcher\";\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 * @param {Object.} data Dynamic data.\n * @param {Number} environmentVersion\n */\nexport async function init(\n readOnly,\n showGeneralFeedback,\n showSpecificFeedback,\n showRightAnswer,\n showCorrectness,\n responseId,\n roles,\n data,\n environmentVersion,\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 // Since we cannot directly access the attempt object from outside the iframe, we set the `data` field here.\n form.addEventListener(\"formdata\", event => {\n event.formData.set(\"data\", JSON.stringify(attempt.data));\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 // Filemanager doesn't use the \"change\" event, so the above doesn't cover it.\n // Instead, we \"forward\" its specific event to the parent DOM.\n form.addEventListener(eventTypes.uploadChanged, () => {\n dispatchEvent(\n eventTypes.uploadChanged,\n {},\n window.frameElement.closest(\"form\"),\n {\n bubbles: true,\n cancelable: false,\n composed: false\n }\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 data,\n environmentVersion,\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\n/**\n * Creates a proxy which fires the given callback when the properties of the object are modified.\n *\n * @template T, U\n * @param {Object.} obj\n * @param {() => void} onChange\n * @returns {Object.}\n */\nfunction createChangeNotifyingProxy(obj, onChange) {\n return new Proxy(obj, {\n set(target, propertyKey, newValue, receiver) {\n const success = Reflect.set(target, propertyKey, newValue, receiver);\n onChange();\n return success;\n },\n defineProperty(target, propertyKey, attributes) {\n const success = Reflect.defineProperty(target, propertyKey, attributes);\n onChange();\n return success;\n },\n deleteProperty(target, propertyKey) {\n const success = Reflect.deleteProperty(target, propertyKey);\n onChange();\n return success;\n },\n });\n}\n\n/**\n * Contains information about the current environment.\n */\nclass AttemptEnvironment {\n #name;\n #version;\n\n /**\n * @param {String} name\n * @param {Number} version\n */\n constructor(name, version) {\n this.#name = name;\n this.#version = version;\n }\n\n /**\n * Get the name of the current environment.\n *\n * @returns {String}\n */\n get name() {\n return this.#name;\n }\n\n /**\n * Get the version of the current environment.\n *\n * To make versions trivially comparable, a number is returned. Make sure to check how these are mapped for the\n * different environments. The higher the number, the more recent the version.\n *\n * @returns {Number}\n */\n get version() {\n return this.#version;\n }\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 #data;\n #environment;\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 * @param {Object.} data\n * @param {Number} environmentVersion\n */\n constructor(\n readOnly,\n showGeneralFeedback,\n showSpecificFeedback,\n showRightAnswer,\n showCorrectness,\n formulationElement,\n generalFeedbackElement,\n specificFeedbackElement,\n rightAnswer,\n roles,\n data,\n environmentVersion,\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 // We also want the autosaver to act when dynamic data was changed.\n const callback = () => this.formulationElement.dispatchEvent(new Event(\"change\"));\n this.#data = createChangeNotifyingProxy(data, callback);\n\n this.#environment = new AttemptEnvironment(\"Moodle\", environmentVersion);\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 * Get the object used to store dynamic data.\n *\n * @note\n * This object will be serialized to JSON by using `JSON.stringify` and therefore follows the conversion\n * rules of this function. This also means that the keys of (nested) objects will be converted to\n * strings.\n *\n * The depth of the object should not exceed 16.\n *\n * @returns {Object.}\n */\n get data() {\n return this.#data;\n }\n\n /**\n * Get information about the current environment.\n *\n * @returns {AttemptEnvironment}\n */\n get environment() {\n return this.#environment;\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\n if (iframeObject.data) {\n iframeObject.data = JSON.parse(iframeObject.data);\n } else {\n window.console.warn(\"The form data field 'data' is missing in the question iframe form.\");\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","data","environmentVersion","element","querySelectorAll","validateInput","target","form","preventDefault","frameElement","submit","JSON","stringify","responseElement","parent","value","eventTypes","uploadChanged","bubbles","cancelable","composed","Attempt","checkValidity","classList","remove","removeAttribute","add","validity","valueMissing","setAttribute","AttemptEnvironment","constructor","name","version","this","formulationElement","generalFeedbackElement","specificFeedbackElement","rightAnswer","onChange","dispatchEvent","Event","Proxy","propertyKey","newValue","receiver","success","Reflect","defineProperty","attributes","deleteProperty","rightAnswerElement","userRoles","environment","iframeFormData","FormData","iframeObject","Object","fromEntries","keys","values","getAll","length","parse","warn"],"mappings":"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"} \ 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\";\nimport {eventTypes} from \"core_form/events\";\nimport {dispatchEvent} from \"core/event_dispatcher\";\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 Id of the hidden input containing the JSON-encoded main response.\n * @param {string} editorsId Id of the hidden input containing the JSON-encoded WYSIWYG editors data.\n * @param {string[]} editorNames Names of the WYSIWYG editors.\n * @param {string[]} roles QPy role names that the user has.\n * @param {Object.} data Dynamic data.\n * @param {Number} environmentVersion\n */\nexport async function init(\n readOnly,\n showGeneralFeedback,\n showSpecificFeedback,\n showRightAnswer,\n showCorrectness,\n responseId,\n editorsId,\n editorNames,\n roles,\n data,\n environmentVersion,\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 // Since we cannot directly access the attempt object from outside the iframe, we set the `data` field here.\n form.addEventListener(\"formdata\", event => {\n event.formData.set(\"data\", JSON.stringify(attempt.data));\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 const editorsElement = parent.document.getElementById(editorsId);\n if (responseElement || editorsElement) {\n // We throttle here, as `JSON.stringify` might affect the performance.\n form.addEventListener(\"change\", throttle(() => {\n const [responseData, editorsData] = collectFormData(form, editorNames);\n if (responseElement) {\n responseElement.value = responseData;\n }\n if (editorsElement) {\n editorsElement.value = editorsData;\n }\n }, 250));\n }\n\n // Filemanager doesn't use the \"change\" event, so the above doesn't cover it.\n // Instead, we \"forward\" its specific event to the parent DOM.\n form.addEventListener(eventTypes.uploadChanged, () => {\n dispatchEvent(\n eventTypes.uploadChanged,\n {},\n window.frameElement.closest(\"form\"),\n {\n bubbles: true,\n cancelable: false,\n composed: false\n }\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 data,\n environmentVersion,\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\n/**\n * Creates a proxy which fires the given callback when the properties of the object are modified.\n *\n * @template T, U\n * @param {Object.} obj\n * @param {() => void} onChange\n * @returns {Object.}\n */\nfunction createChangeNotifyingProxy(obj, onChange) {\n return new Proxy(obj, {\n set(target, propertyKey, newValue, receiver) {\n const success = Reflect.set(target, propertyKey, newValue, receiver);\n onChange();\n return success;\n },\n defineProperty(target, propertyKey, attributes) {\n const success = Reflect.defineProperty(target, propertyKey, attributes);\n onChange();\n return success;\n },\n deleteProperty(target, propertyKey) {\n const success = Reflect.deleteProperty(target, propertyKey);\n onChange();\n return success;\n },\n });\n}\n\n/**\n * Contains information about the current environment.\n */\nclass AttemptEnvironment {\n #name;\n #version;\n\n /**\n * @param {String} name\n * @param {Number} version\n */\n constructor(name, version) {\n this.#name = name;\n this.#version = version;\n }\n\n /**\n * Get the name of the current environment.\n *\n * @returns {String}\n */\n get name() {\n return this.#name;\n }\n\n /**\n * Get the version of the current environment.\n *\n * To make versions trivially comparable, a number is returned. Make sure to check how these are mapped for the\n * different environments. The higher the number, the more recent the version.\n *\n * @returns {Number}\n */\n get version() {\n return this.#version;\n }\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 #data;\n #environment;\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 * @param {Object.} data\n * @param {Number} environmentVersion\n */\n constructor(\n readOnly,\n showGeneralFeedback,\n showSpecificFeedback,\n showRightAnswer,\n showCorrectness,\n formulationElement,\n generalFeedbackElement,\n specificFeedbackElement,\n rightAnswer,\n roles,\n data,\n environmentVersion,\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 // We also want the autosaver to act when dynamic data was changed.\n const callback = () => this.formulationElement.dispatchEvent(new Event(\"change\"));\n this.#data = createChangeNotifyingProxy(data, callback);\n\n this.#environment = new AttemptEnvironment(\"Moodle\", environmentVersion);\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 * Get the object used to store dynamic data.\n *\n * @note\n * This object will be serialized to JSON by using `JSON.stringify` and therefore follows the conversion\n * rules of this function. This also means that the keys of (nested) objects will be converted to\n * strings.\n *\n * The depth of the object should not exceed 16.\n *\n * @returns {Object.}\n */\n get data() {\n return this.#data;\n }\n\n /**\n * Get information about the current environment.\n *\n * @returns {AttemptEnvironment}\n */\n get environment() {\n return this.#environment;\n }\n}\n\n/**\n * Builds a regular expression that matches draftfile.php URLs.\n *\n * @returns {RegExp}\n */\nfunction buildDraftFileUrlRegex() {\n const wwwrootWithoutScheme = M.cfg.wwwroot.replace(/^https?:\\/\\//, \"\");\n\n return new RegExp(\n // Phpcs:disable -- phpcs is massively confused by this.\n String.raw`https?://${wwwrootWithoutScheme}/draftfile\\.php/(?\\d+)`\n + String.raw`/user/draft/(?\\d+)/(?[^\\'\\\",&<>|\\`\\s:\\\\\\\\]+)`,\n // Phpcs:enable\n \"g\",\n );\n}\n\n/**\n * Creates JSON from the FormData of the given form.\n *\n * @param {HTMLFormElement} form\n * @param {string[]} editorNames\n * @returns {[string, string]}\n */\nfunction collectFormData(form, editorNames) {\n const iframeFormData = new FormData(form);\n\n const editorData = {};\n for (const name of editorNames) {\n const textKey = `${name}[text]`;\n const formatKey = `${name}[format]`;\n const itemidKey = `${name}[itemid]`;\n\n const text = iframeFormData.get(textKey);\n if (text === null) {\n continue;\n }\n\n // TODO: Handle content pasted from other editors, where the draft item id would be different. We'd probably\n // need to pass the encountered foreign files somewhere and copy them to our area in qbehaviour_questionpy.\n const replacedText = text.replaceAll(buildDraftFileUrlRegex(), \"@@PLUGINFILE@@/$\");\n\n editorData[name] = {text: replacedText};\n iframeFormData.delete(textKey);\n\n const format = iframeFormData.get(formatKey);\n if (format !== null) {\n editorData[name].format = format;\n iframeFormData.delete(formatKey);\n }\n\n // The itemid is added by qpy_rich_text_editor to the list that gets sent from outside the iframe.\n // No need to duplicate it here.\n iframeFormData.delete(itemidKey);\n }\n\n const responseObject = Object.fromEntries(iframeFormData);\n for (const name of iframeFormData.keys()) {\n const values = iframeFormData.getAll(name);\n if (values.length > 1) {\n responseObject[name] = values;\n }\n }\n\n if (responseObject.data) {\n responseObject.data = JSON.parse(responseObject.data);\n } else {\n window.console.warn(\"The form data field 'data' is missing in the question iframe form.\");\n }\n\n return [JSON.stringify(responseObject), JSON.stringify(editorData)];\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 * @param {string} editorsFieldName - The complete field name for the JSON-encoded WYSIWYG editors data.\n * @param {string[]} editorNames - The input names that are WYSIWYG editors.\n */\nexport function addIframeFormDataOnSubmit(iframeId, responseFieldName, editorsFieldName, editorNames) {\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\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 [responseData, editorsData] = collectFormData(iframeForm, editorNames);\n event.formData.set(responseFieldName, responseData);\n event.formData.set(editorsFieldName, editorsData);\n });\n}\n"],"names":["iframeId","responseFieldName","editorsFieldName","editorNames","iframe","window","document","getElementById","console","error","closest","addEventListener","event","iframeForm","contentDocument","responseData","editorsData","collectFormData","formData","set","attempt","Error","readOnly","showGeneralFeedback","showSpecificFeedback","showRightAnswer","showCorrectness","responseId","editorsId","roles","data","environmentVersion","element","querySelectorAll","validateInput","target","form","preventDefault","frameElement","submit","JSON","stringify","responseElement","parent","editorsElement","value","eventTypes","uploadChanged","bubbles","cancelable","composed","Attempt","checkValidity","classList","remove","removeAttribute","add","validity","valueMissing","setAttribute","AttemptEnvironment","constructor","name","version","this","formulationElement","generalFeedbackElement","specificFeedbackElement","rightAnswer","onChange","dispatchEvent","Event","Proxy","propertyKey","newValue","receiver","success","Reflect","defineProperty","attributes","deleteProperty","rightAnswerElement","userRoles","environment","buildDraftFileUrlRegex","wwwrootWithoutScheme","M","cfg","wwwroot","replace","RegExp","String","raw","iframeFormData","FormData","editorData","textKey","formatKey","itemidKey","text","get","replacedText","replaceAll","delete","format","responseObject","Object","fromEntries","keys","values","getAll","length","parse","warn"],"mappings":"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"} \ No newline at end of file diff --git a/amd/src/view_question.js b/amd/src/view_question.js index e589e5b8..6c4d813d 100644 --- a/amd/src/view_question.js +++ b/amd/src/view_question.js @@ -63,7 +63,9 @@ function validateInput(element) { * @param {boolean} showSpecificFeedback * @param {boolean} showRightAnswer * @param {boolean} showCorrectness - * @param {string} responseId + * @param {string} responseId Id of the hidden input containing the JSON-encoded main response. + * @param {string} editorsId Id of the hidden input containing the JSON-encoded WYSIWYG editors data. + * @param {string[]} editorNames Names of the WYSIWYG editors. * @param {string[]} roles QPy role names that the user has. * @param {Object.} data Dynamic data. * @param {Number} environmentVersion @@ -75,6 +77,8 @@ export async function init( showRightAnswer, showCorrectness, responseId, + editorsId, + editorNames, roles, data, environmentVersion, @@ -102,10 +106,17 @@ 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 responseElement = parent.document.getElementById(responseId); - if (responseElement) { + const editorsElement = parent.document.getElementById(editorsId); + if (responseElement || editorsElement) { // We throttle here, as `JSON.stringify` might affect the performance. form.addEventListener("change", throttle(() => { - responseElement.value = createJsonFromFormData(form); + const [responseData, editorsData] = collectFormData(form, editorNames); + if (responseElement) { + responseElement.value = responseData; + } + if (editorsElement) { + editorsElement.value = editorsData; + } }, 250)); } @@ -403,29 +414,77 @@ class Attempt { } } +/** + * Builds a regular expression that matches draftfile.php URLs. + * + * @returns {RegExp} + */ +function buildDraftFileUrlRegex() { + const wwwrootWithoutScheme = M.cfg.wwwroot.replace(/^https?:\/\//, ""); + + return new RegExp( + // Phpcs:disable -- phpcs is massively confused by this. + String.raw`https?://${wwwrootWithoutScheme}/draftfile\.php/(?\d+)` + + String.raw`/user/draft/(?\d+)/(?[^\'\",&<>|\`\s:\\\\]+)`, + // Phpcs:enable + "g", + ); +} + /** * Creates JSON from the FormData of the given form. * * @param {HTMLFormElement} form - * @returns {string} + * @param {string[]} editorNames + * @returns {[string, string]} */ -function createJsonFromFormData(form) { +function collectFormData(form, editorNames) { const iframeFormData = new FormData(form); - const iframeObject = Object.fromEntries(iframeFormData); + + const editorData = {}; + for (const name of editorNames) { + const textKey = `${name}[text]`; + const formatKey = `${name}[format]`; + const itemidKey = `${name}[itemid]`; + + const text = iframeFormData.get(textKey); + if (text === null) { + continue; + } + + // TODO: Handle content pasted from other editors, where the draft item id would be different. We'd probably + // need to pass the encountered foreign files somewhere and copy them to our area in qbehaviour_questionpy. + const replacedText = text.replaceAll(buildDraftFileUrlRegex(), "@@PLUGINFILE@@/$"); + + editorData[name] = {text: replacedText}; + iframeFormData.delete(textKey); + + const format = iframeFormData.get(formatKey); + if (format !== null) { + editorData[name].format = format; + iframeFormData.delete(formatKey); + } + + // The itemid is added by qpy_rich_text_editor to the list that gets sent from outside the iframe. + // No need to duplicate it here. + iframeFormData.delete(itemidKey); + } + + const responseObject = Object.fromEntries(iframeFormData); for (const name of iframeFormData.keys()) { const values = iframeFormData.getAll(name); if (values.length > 1) { - iframeObject[name] = values; + responseObject[name] = values; } } - if (iframeObject.data) { - iframeObject.data = JSON.parse(iframeObject.data); + if (responseObject.data) { + responseObject.data = JSON.parse(responseObject.data); } else { window.console.warn("The form data field 'data' is missing in the question iframe form."); } - return JSON.stringify(iframeObject); + return [JSON.stringify(responseObject), JSON.stringify(editorData)]; } /** @@ -435,8 +494,10 @@ function createJsonFromFormData(form) { * * @param {string} iframeId - The ID of the question's iframe. * @param {string} responseFieldName - The complete field name for the JSON-encoded iframe form data. + * @param {string} editorsFieldName - The complete field name for the JSON-encoded WYSIWYG editors data. + * @param {string[]} editorNames - The input names that are WYSIWYG editors. */ -export function addIframeFormDataOnSubmit(iframeId, responseFieldName) { +export function addIframeFormDataOnSubmit(iframeId, responseFieldName, editorsFieldName, editorNames) { const iframe = window.document.getElementById(iframeId); if (iframe === null) { window.console.error(`Could not find question iframe ${iframeId}. Cannot save answers.`); @@ -450,9 +511,11 @@ export function addIframeFormDataOnSubmit(iframeId, responseFieldName) { window.console.error("Could not find form in question iframe " + iframeId); return; } + // 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); + const [responseData, editorsData] = collectFormData(iframeForm, editorNames); + event.formData.set(responseFieldName, responseData); + event.formData.set(editorsFieldName, editorsData); }); } diff --git a/classes/constants.php b/classes/constants.php index 128097da..f55cf257 100644 --- a/classes/constants.php +++ b/classes/constants.php @@ -47,7 +47,8 @@ class constants { public const FILEAREA_RESPONSE_FILES = 'response_files'; /** @var string */ public const QT_VAR_RESPONSE_FILES = 'files'; - + /** @var string */ + public const QT_VAR_EDITORS = 'editors'; /** @var string */ public const FILEAREA_OPTIONS = 'options'; diff --git a/classes/local/api/package_api.php b/classes/local/api/package_api.php index 2e655725..6ea06c05 100644 --- a/classes/local/api/package_api.php +++ b/classes/local/api/package_api.php @@ -134,18 +134,20 @@ public function start_attempt(string $questionstate, int $variant, ?array $attri * @param string $attemptstate the attempt state previously returned from {@see start_attempt()} * @param string|null $scoringstate the last scoring state if this attempt has already been scored * @param object|null $response data currently entered by the student + * @param array|null $editors * @return attempt the attempt's metadata. The state is not returned since it never changes. * @throws GuzzleException - * @throws request_error * @throws moodle_exception + * @throws request_error */ public function view_attempt(string $questionstate, ?array $attributes, string $attemptstate, ?string $scoringstate = null, - ?object $response = null): attempt { + ?object $response = null, ?array $editors = null): attempt { $options['multipart'] = $this->transform_to_multipart( [ 'attempt_state' => $attemptstate, 'scoring_state' => $scoringstate, 'response' => $response, + 'editors' => $editors === null ? null : (object) $editors, 'context' => $this->get_context_id(), 'lms_provided_attributes' => $attributes, ], @@ -163,18 +165,20 @@ public function view_attempt(string $questionstate, ?array $attributes, string $ * @param string $attemptstate the attempt state previously returned from {@see start_attempt()} * @param string|null $scoringstate the last scoring state if this attempt had been scored before * @param object $response data submitted by the student + * @param wysiwyg_editor_data[] $editors * @return attempt_scored the attempt's metadata. The state is not returned since it never changes. * @throws GuzzleException - * @throws request_error * @throws moodle_exception + * @throws request_error */ public function score_attempt(string $questionstate, ?array $attributes, string $attemptstate, ?string $scoringstate, - object $response): attempt_scored { + object $response, array $editors): attempt_scored { $options['multipart'] = $this->transform_to_multipart( [ 'attempt_state' => $attemptstate, 'scoring_state' => $scoringstate, 'response' => $response, + 'editors' => (object) $editors, 'generate_hint' => false, 'context' => $this->get_context_id(), 'lms_provided_attributes' => $attributes, diff --git a/classes/local/api/wysiwyg_editor_data.php b/classes/local/api/wysiwyg_editor_data.php index 180b2e3f..abf0366a 100644 --- a/classes/local/api/wysiwyg_editor_data.php +++ b/classes/local/api/wysiwyg_editor_data.php @@ -16,8 +16,12 @@ namespace qtype_questionpy\local\api; +use coding_exception; +use JsonSerializable; +use qtype_questionpy\local\array_converter\array_converter; use qtype_questionpy\local\array_converter\attributes\array_element_class; use qtype_questionpy\local\array_converter\attributes\array_key; +use qtype_questionpy\local\array_converter\conversion_exception; use qtype_questionpy\local\files\file_metadata; /** @@ -28,7 +32,7 @@ * @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class wysiwyg_editor_data { +class wysiwyg_editor_data implements JsonSerializable { /** * Initialize a new WYSIWYG editor data instance. * @@ -47,4 +51,16 @@ public function __construct( public array $files = [], ) { } + + /** + * Specify data which should be serialized to JSON + * @link https://php.net/manual/en/jsonserializable.jsonserialize.php + * @return mixed data which can be serialized by json_encode, + * which is a value of any type other than a resource. + * @throws coding_exception + * @throws conversion_exception + */ + public function jsonSerialize(): mixed { + return array_converter::to_array($this); + } } diff --git a/classes/local/attempt_ui/custom_xhtml_element.php b/classes/local/attempt_ui/custom_xhtml_element.php new file mode 100644 index 00000000..19acba9d --- /dev/null +++ b/classes/local/attempt_ui/custom_xhtml_element.php @@ -0,0 +1,67 @@ +. + +namespace qtype_questionpy\local\attempt_ui; + + +use core\exception\coding_exception; +use DOMElement; +use DOMNode; +use DOMXPath; +use file_exception; +use Iterator; +use moodle_exception; +use question_attempt; +use stored_file_creation_exception; + +/** + * Represents a `` element in the question UI XML. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface custom_xhtml_element { + /** + * Finds all matching elements within the provided DOMXPath. + * + * @param DOMXPath $xpath + * @return Iterator + */ + public static function find_all_in(DOMXPath $xpath): Iterator; + + /** + * Parses the given DOMElement if possible. + * + * @param DOMElement $element + * @return static|null + */ + public static function from_element(DOMElement $element): ?static; + + /** + * Renders this element to a DOMNode. + * + * @param question_attempt $qa + * @param question_ui_renderer $renderer + * @return DOMNode + * @throws coding_exception + * @throws file_exception + * @throws moodle_exception + * @throws stored_file_creation_exception + */ + public function render(question_attempt $qa, question_ui_renderer $renderer): DOMNode; +} diff --git a/classes/local/attempt_ui/qpy_file_upload.php b/classes/local/attempt_ui/qpy_file_upload.php index b76bd631..3458d1d3 100644 --- a/classes/local/attempt_ui/qpy_file_upload.php +++ b/classes/local/attempt_ui/qpy_file_upload.php @@ -22,8 +22,10 @@ use DOMDocument; use DOMElement; use DOMNode; +use DOMXPath; use file_exception; use form_filemanager; +use Iterator; use moodle_exception; use qtype_questionpy\local\files\response_file_service; use qtype_questionpy\local\files\validatable_upload_limits; @@ -39,7 +41,7 @@ * @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class qpy_file_upload { +class qpy_file_upload implements custom_xhtml_element { /** * Trivial private constructor. Use {@see from_element()}. * @param DOMElement $element @@ -47,7 +49,7 @@ class qpy_file_upload { */ private function __construct( /** @var DOMElement */ - private readonly DOMElement $element, + public readonly DOMElement $element, /** @var string */ public readonly string $name, ) { @@ -81,12 +83,12 @@ public function get_limits_in(context $context): validatable_upload_limits { } /** - * Creates a new {@see qpy_file_upload} from a given {@see DOMElement}. + * Parses the given DOMElement if possible. * * @param DOMElement $element - * @return self|null + * @return static|null */ - public static function from_element(DOMElement $element): ?self { + public static function from_element(DOMElement $element): ?static { $name = $element->getAttribute('name'); if (!$name) { debugging('qpy:file-upload without a name'); @@ -102,10 +104,6 @@ public static function from_element(DOMElement $element): ?self { * @param question_attempt $qa * @param question_ui_renderer $renderer * @return DOMNode - * @throws coding_exception - * @throws file_exception - * @throws moodle_exception - * @throws stored_file_creation_exception */ public function render(question_attempt $qa, question_ui_renderer $renderer): DOMNode { if ($renderer->options->readonly) { @@ -160,4 +158,22 @@ private function render_writable(question_attempt $qa, question_ui_renderer $ren $html = $filesrenderer->render($fm); return dom_utils::html_to_fragment($this->element->ownerDocument, $html); } + + /** + * Finds all matching elements within the provided DOMXPath. + * + * @param DOMXPath $xpath + * @return Iterator + */ + public static function find_all_in(DOMXPath $xpath): Iterator { + /** @var DOMElement $element */ + foreach ($xpath->query('//qpy:file-upload') as $element) { + $upload = self::from_element($element); + if (!$upload) { + continue; + } + + yield $upload; + } + } } diff --git a/classes/local/attempt_ui/qpy_rich_text_editor.php b/classes/local/attempt_ui/qpy_rich_text_editor.php new file mode 100644 index 00000000..9ade332a --- /dev/null +++ b/classes/local/attempt_ui/qpy_rich_text_editor.php @@ -0,0 +1,269 @@ +. + +namespace qtype_questionpy\local\attempt_ui; + +use core\context; +use core\di; +use core\exception\coding_exception; +use DOMDocumentFragment; +use DOMElement; +use DOMNode; +use DOMXPath; +use file_exception; +use html_writer; +use Iterator; +use moodle_exception; +use moodle_url; +use MoodleQuickForm_editor; +use qtype_questionpy\constants; +use qtype_questionpy\local\files\response_file_service; +use qtype_questionpy\local\files\validatable_upload_limits; +use qtype_questionpy\utils; +use qtype_questionpy_renderer; +use question_attempt; +use stored_file_creation_exception; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/form/editor.php'); + +/** + * Represents a `` element in the question UI XML. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qpy_rich_text_editor implements custom_xhtml_element { + // Same as MoodleQuickForm_editor. + /** @var int */ + private const DEFAULT_ROWS = 15; + /** @var int */ + private const DEFAULT_COLS = 80; + + /** + * Trivial private constructor. Use {@see from_element()}. + * @param DOMElement $element + * @param string $name + * @param bool $required + * @param string|null $default + * @param int $rows + * @param int $cols + */ + private function __construct( + /** @var DOMElement */ + public readonly DOMElement $element, + /** @var string */ + public readonly string $name, + /** @var bool */ + public readonly bool $required, + /** @var string */ + public readonly ?string $default, + /** @var int */ + public readonly int $rows, + /** @var int */ + public readonly int $cols, + ) { + } + + /** + * Gets the limits that should be validated for the current user in the given context when using this upload field. + * + * @param context $context + * @return validatable_upload_limits + */ + public function get_limits_in(context $context): validatable_upload_limits { + global $CFG, $PAGE; + require_once($CFG->libdir . '/formslib.php'); // For EDITOR_UNLIMITED_FILES. + + if ($this->element->hasAttribute('max-files')) { + debugging('qpy:rich-text-editor does not support max-files, the attribute is ignored.'); + } + + $maxbytes = $this->element->getAttribute('max-bytes-per-file'); + $maxbytes = is_numeric($maxbytes) ? intval($maxbytes) : FILE_AREA_MAX_BYTES_UNLIMITED; + $coursemaxbytes = 0; + if (!empty($PAGE->course->maxbytes)) { + $coursemaxbytes = $PAGE->course->maxbytes; + } + $maxbytes = get_user_max_upload_file_size($context, $CFG->maxbytes, $coursemaxbytes, $maxbytes); + + $areamaxbytes = $this->element->getAttribute('max-bytes-total'); + $areamaxbytes = is_numeric($areamaxbytes) ? intval($areamaxbytes) : FILE_AREA_MAX_BYTES_UNLIMITED; + + // Moodle's tinymce media plugin hardcodes maxfiles to -1, so we can't place any restrictions here. + return new validatable_upload_limits(maxfiles: EDITOR_UNLIMITED_FILES, maxbytes: $maxbytes, areamaxbytes: $areamaxbytes); + } + + /** + * Parses the given DOMElement if possible. + * + * @param DOMElement $element + * @return static|null + */ + public static function from_element(DOMElement $element): ?static { + $name = $element->getAttribute('name'); + if (!$name) { + debugging('qpy:file-upload without a name'); + return null; + } + + return new static( + $element, + $name, + required: $element->hasAttribute('required'), + default: $element->hasAttribute('default') ? $element->getAttribute('default') : null, + rows: $element->getAttribute('initial-rows') ?: self::DEFAULT_ROWS, + cols: $element->getAttribute('initial-cols') ?: self::DEFAULT_COLS + ); + } + + /** + * Renders this element to a DOMNode. + * + * @param question_attempt $qa + * @param question_ui_renderer $renderer + * @return DOMNode + * @throws coding_exception + * @throws file_exception + * @throws moodle_exception + * @throws stored_file_creation_exception + */ + public function render(question_attempt $qa, question_ui_renderer $renderer): DOMNode { + $html = html_writer::start_div('qpy-editor-container ' . ($renderer->options->readonly ? 'readonly' : '')); + if ($renderer->options->readonly) { + global $PAGE; + /** @var qtype_questionpy_renderer $qpyrenderer */ + $qpyrenderer = $PAGE->get_renderer('qtype_questionpy'); + + $responsestep = $qa->get_last_step_with_qt_var(constants::QT_VAR_RESPONSE); + $editorsdata = utils::get_qpy_editors_data($responsestep->get_qt_data()); + $editordata = $editorsdata[$this->name] ?? null; + + $html .= $qpyrenderer->render_readonly_editor_content( + $editordata, + qubaid: $qa->get_usage_id(), + slot: $qa->get_slot(), + stepid: $responsestep->get_id(), + fieldname: $this->name, + options: $renderer->options, + rows: $this->rows, + cols: $this->cols + ); + } else { + $html .= $this->render_writable($renderer, $qa); + } + + $html .= html_writer::end_div(); + + return dom_utils::html_to_fragment($this->element->ownerDocument, $html); + } + + /** + * Renders an editor using the usual Moodle APIs. + * + * @param question_ui_renderer $renderer + * @param question_attempt $qa + * @return string HTML + * @throws coding_exception + * @throws file_exception + * @throws moodle_exception + * @throws stored_file_creation_exception + */ + public function render_writable(question_ui_renderer $renderer, question_attempt $qa): string { + $limits = $this->get_limits_in($renderer->options->context); + + $alleditorsdata = utils::get_qpy_editors_data($qa); + $mydata = $alleditorsdata[$this->name] ?? null; + + $options = [ + 'context' => $renderer->options->context, + ]; + $values = [ + 'text' => $this->default === null ? '' : s($this->default), + 'format' => $mydata !== null ? $mydata->format : FORMAT_HTML, + ]; + if ($limits->maxfiles === 0) { + $options['enable_filemanagement'] = false; + + if ($mydata !== null) { + $values['text'] = $mydata->text; + } + } else { + $combinedfilearea = $renderer->prepare_combined_draft_area($qa); + $rfs = di::get(response_file_service::class); + global $USER; + $splitdraftitemid = $rfs->prepare_split_draft_area($this->name, $USER->id, $combinedfilearea); + + // This is used to tell the qbehaviour what draft areas to save. + $renderer->draftareas[$this->name] = $splitdraftitemid; + + $options['subdirs'] = false; + $options['maxfiles'] = $limits->maxfiles; + $options['maxbytes'] = $limits->maxbytes; + $options['areamaxbytes'] = $limits->areamaxbytes; + + $values['itemid'] = $splitdraftitemid; + + if ($mydata !== null) { + // Replace the @@PLUGINFILE@@ placeholders with the correct draftfile-URL prefix. + // The inverse is done in JS for want of a better place. + $prefix = moodle_url::make_draftfile_url($splitdraftitemid, '/', ''); + assert(str_ends_with($prefix, '/')); + $values['text'] = str_replace('@@PLUGINFILE@@/', $prefix, $mydata->text); + } + } + + // This will be used by the JS code to separately handle the editor data. + $renderer->editornames[] = $this->name; + + $meditor = new MoodleQuickForm_editor( + elementName: $this->name, + elementLabel: null, + attributes: [ + 'rows' => $this->rows, + 'cols' => $this->cols, + ], + options: $options + ); + $meditor->_generateId(); + + $meditor->setValue($values); + + return $meditor->toHtml(); + } + + /** + * Finds all matching elements within the provided DOMXPath. + * + * @param DOMXPath $xpath + * @return Iterator + */ + public static function find_all_in(DOMXPath $xpath): Iterator { + /** @var DOMElement $element */ + foreach ($xpath->query('//qpy:rich-text-editor') as $element) { + $editor = self::from_element($element); + if (!$editor) { + continue; + } + + yield $editor; + } + } +} diff --git a/classes/local/attempt_ui/question_ui_metadata_extractor.php b/classes/local/attempt_ui/question_ui_metadata_extractor.php index a751b6b0..f2174f9c 100644 --- a/classes/local/attempt_ui/question_ui_metadata_extractor.php +++ b/classes/local/attempt_ui/question_ui_metadata_extractor.php @@ -22,6 +22,7 @@ use DOMElement; use DOMXPath; use qtype_questionpy\constants; +use qtype_questionpy\local\files\validatable_upload_limits; /** * Parses the question UI XML and extracts the metadata. @@ -51,8 +52,13 @@ class question_ui_metadata_extractor { */ private array|false $requiredfields = false; - /** @var array|false File uploads fields in the XML. */ - private array|false $fileuploads = false; + /** + * @var string[] $requirededitors `false` if not yet extracted. + * @see \question_manually_gradable::is_complete_response() + * @see \question_manually_gradable::is_gradable_response() + */ + private array|false $requirededitors = false; + /** * Parses the given XML and initializes a new {@see question_ui_metadata_extractor} instance. @@ -69,11 +75,11 @@ public function __construct(string $xml) { } /** - * Extracts the names of required fields from the question UI XML. + * Extracts the names of required main response fields from the question UI XML. * * @return string[] */ - public function get_required_fields(): array { + public function get_required_response_fields(): array { if ($this->requiredfields !== false) { return $this->requiredfields; } @@ -95,6 +101,26 @@ public function get_required_fields(): array { return $this->requiredfields; } + /** + * Extracts the names of required rich text editors from the question UI XML. + * + * @return string[] + */ + public function get_required_editors(): array { + if ($this->requirededitors !== false) { + return $this->requirededitors; + } + + $this->requirededitors = []; + foreach (qpy_rich_text_editor::find_all_in($this->xpath) as $editor) { + if ($editor->required) { + $this->requirededitors[] = $editor->name; + } + } + + return $this->requirededitors; + } + /** * Returns the correct response for any fields that use the `@qpy:correct-response` attribute, or null if none do. * @@ -132,22 +158,23 @@ public function get_correct_response(): ?array { return $this->correctresponse; } - /** - * Returns {@see qpy_file_upload}s for all `` elements in the XML, indexed by their name. + * Returns limits for all `` and `` elements in the XML, indexed by their name. * - * @return array + * @param context $attemptcontext + * @return array */ - public function get_upload_limits(): array { - if ($this->fileuploads === false) { - $this->fileuploads = []; - foreach ($this->xpath->query('//qpy:file-upload') as $element) { - $upload = qpy_file_upload::from_element($element); - $this->fileuploads[$upload->name] = $upload; - } - return $this->fileuploads; + public function get_upload_limits(context $attemptcontext): array { + $uploadlimits = []; + + foreach (qpy_file_upload::find_all_in($this->xpath) as $upload) { + $uploadlimits[$upload->name] = $upload->get_limits_in($attemptcontext); } - return $this->correctresponse; + foreach (qpy_rich_text_editor::find_all_in($this->xpath) as $editor) { + $uploadlimits[$editor->name] = $editor->get_limits_in($attemptcontext); + } + + return $uploadlimits; } } diff --git a/classes/local/attempt_ui/question_ui_renderer.php b/classes/local/attempt_ui/question_ui_renderer.php index 8a65b99b..8be74c5d 100644 --- a/classes/local/attempt_ui/question_ui_renderer.php +++ b/classes/local/attempt_ui/question_ui_renderer.php @@ -54,6 +54,9 @@ class question_ui_renderer { /** @var array Mapping of input names to draft item ids. */ public array $draftareas = []; + /** @var string[] */ + public array $editornames = []; + /** @var invalid_option_warning[] $warnings warnings emitted during rendering */ public array $warnings; @@ -279,7 +282,7 @@ private function replace_shuffled_indices(DOMElement $container, DOMElement $ele } /** - * Renders all the ``-elements. + * Renders all the `` and ``-elements. * * @param question_attempt $qa * @throws file_exception @@ -288,14 +291,14 @@ private function replace_shuffled_indices(DOMElement $container, DOMElement $ele * @throws moodle_exception */ private function render_custom_elements(question_attempt $qa): void { - /** @var DOMElement $element */ - foreach (iterator_to_array($this->xpath->query('//qpy:file-upload')) as $element) { - $newnode = qpy_file_upload::from_element($element); - if (!$newnode) { - continue; - } + /** @var qpy_file_upload $upload */ + foreach (iterator_to_array(qpy_file_upload::find_all_in($this->xpath)) as $upload) { + $upload->element->parentNode->replaceChild($upload->render($qa, $this), $upload->element); + } - $element->parentNode->replaceChild($newnode->render($qa, $this), $element); + /** @var qpy_rich_text_editor $editor */ + foreach (iterator_to_array(qpy_rich_text_editor::find_all_in($this->xpath)) as $editor) { + $editor->element->parentNode->replaceChild($editor->render($qa, $this), $editor->element); } } @@ -329,6 +332,11 @@ private function set_input_values_and_readonly(question_attempt $attempt): void continue; } + if (in_array($name, $this->editornames)) { + // This is a rich text editor, whose value is managed by qpy_rich_text_editor. + continue; + } + // Get the last saved value. $lastvalue = $lastresponse->{$name} ?? null; if (is_null($lastvalue)) { diff --git a/classes/local/files/file_metadata.php b/classes/local/files/file_metadata.php index 71c85a58..f6ce66e0 100644 --- a/classes/local/files/file_metadata.php +++ b/classes/local/files/file_metadata.php @@ -16,8 +16,10 @@ namespace qtype_questionpy\local\files; +use core\exception\coding_exception; use DateTimeImmutable; use qtype_questionpy\local\array_converter\attributes\array_key; +use stored_file; /** * Metadata of a file in the form data sent to and expected from the QPy server. @@ -56,4 +58,24 @@ public function __construct( public int $size, ) { } + + /** + * Build from the given {@see stored_file}. + * + * @param stored_file $file + * @param string|null $overridename Use a different filename than the one of the stored file. + * @return file_metadata + * @throws coding_exception + */ + public static function from_stored_file(stored_file $file, ?string $overridename = null): static { + $fileref = qpy_file_ref::from_stored_file($file); + return new static( + path: $file->get_filepath(), + filename: $overridename ?? $file->get_filename(), + fileref: $fileref, + uploadedat: DateTimeImmutable::createFromFormat('U', $file->get_timemodified()), + mimetype: $file->get_mimetype(), + size: $file->get_filesize(), + ); + } } diff --git a/classes/local/files/options_file_service.php b/classes/local/files/options_file_service.php index f0c05fa3..54ee4900 100644 --- a/classes/local/files/options_file_service.php +++ b/classes/local/files/options_file_service.php @@ -158,20 +158,7 @@ public function get_qpy_files_metadata_from_draftitem(int $userid, int $draftite $fs = get_file_storage(); $files = $fs->get_area_files(context_user::instance($userid)->id, 'user', 'draft', $draftitemid, includedirs: false); - $metadata = []; - foreach ($files as $file) { - $fileref = qpy_file_ref::from_stored_file($file); - $metadata[] = new file_metadata( - path: $file->get_filepath(), - filename: $file->get_filename(), - fileref: $fileref, - uploadedat: DateTimeImmutable::createFromFormat('U', $file->get_timemodified()), - mimetype: $file->get_mimetype(), - size: $file->get_filesize(), - ); - } - - return $metadata; + return array_map(fn($file) => file_metadata::from_stored_file($file), $files); } /** diff --git a/classes/local/files/response_file_service.php b/classes/local/files/response_file_service.php index c9ff67bc..2dc78140 100644 --- a/classes/local/files/response_file_service.php +++ b/classes/local/files/response_file_service.php @@ -18,11 +18,12 @@ use coding_exception; use context_user; -use core\context; use file_exception; use Generator; use moodle_exception; +use qtype_questionpy\constants; use qtype_questionpy\local\attempt_ui\qpy_file_upload; +use question_response_files; use stored_file; use stored_file_creation_exception; @@ -50,8 +51,6 @@ class response_file_service { * @throws stored_file_creation_exception */ public function combine_response_file_draft_areas(array $draftareas, int $targetdraftarea, int $userid): int { - // TODO: Check file size & count restrictions. - if (!$draftareas) { return $targetdraftarea; } @@ -113,17 +112,15 @@ public function combine_response_file_draft_areas(array $draftareas, int $target * Validates that the combined draft area follows the limits imposed by the given {@see qpy_file_upload}s. * * @param int $draftareaid - * @param qpy_file_upload[] $uploadfields + * @param validatable_upload_limits[] $limitsbyfield * @param int $userid - * @param context $attemptcontext * @return void * @throws coding_exception */ public function validate_combined_draft_area( int $draftareaid, - array $uploadfields, - int $userid, - context $attemptcontext + array $limitsbyfield, + int $userid ): void { $fs = get_file_storage(); $usercontext = context_user::instance($userid); @@ -144,14 +141,13 @@ public function validate_combined_draft_area( } foreach ($filesbyfield as $fieldname => $files) { - $uploadfield = $uploadfields[$fieldname] ?? null; - if (!$uploadfield) { + $fieldlimits = $limitsbyfield[$fieldname] ?? null; + if (!$fieldlimits) { throw new coding_exception("There were files uploaded for field '$fieldname', but no corresponding upload field " . 'was found.'); } - $uploadfield->get_limits_in($attemptcontext) - ->validate_files($files, "upload field '$fieldname'"); + $fieldlimits->validate_files($files, "upload or editor field '$fieldname'"); } } @@ -219,7 +215,17 @@ public function prepare_split_draft_area( */ public static function mangle_filename(string $fieldname, string $filename): string { // URL-encoding the fieldname ensures that our separator is the first occurrence of the separator. - return urlencode($fieldname) . static::MANGLE_SEPARATOR . $filename; + return self::mangled_prefix_for($fieldname) . $filename; + } + + /** + * Returns the prefix used to mangle filenames belonging to the given field. + * + * @param string $fieldname The upload field name the file(s) belongs to. + * @return string + */ + public static function mangled_prefix_for(string $fieldname): string { + return urlencode($fieldname) . static::MANGLE_SEPARATOR; } /** @@ -236,4 +242,27 @@ public static function unmangle_filename(string $filename): array { [$urlencfieldname, $filename] = explode(static::MANGLE_SEPARATOR, $filename, 2); return [urldecode($urlencfieldname), $filename]; } + + /** + * On a response (where {@see \question_attempt::get_last_qt_files()} & co. isn't available), gets the files from the response. + * + * @param array $response As returned by {@see question_attempt::get_last_qt_data()} and passed to + * {@see qtype_questionpy_question::grade_response()}. + * @return stored_file[] Files belonging to the response. + * @throws coding_exception + */ + public function get_all_files_from_qt_data(array $response): array { + $accessor = $response[constants::QT_VAR_RESPONSE_FILES] ?? null; + if ($accessor === null || $accessor === '') { + // When empty (i.e. no files), no question_file_loader is created when loading. + return []; + } + + if (!($accessor instanceof question_response_files)) { + $key = constants::QT_VAR_RESPONSE_FILES; + throw new coding_exception("The '$key' qt var exists, but is not an instance of question_response_files."); + } + + return $accessor->get_files(); + } } diff --git a/classes/utils.php b/classes/utils.php index 5361b584..606773cb 100644 --- a/classes/utils.php +++ b/classes/utils.php @@ -160,4 +160,53 @@ public static function get_qpy_response(question_attempt|array $qa): ?object { return $response; } + + /** + * Parses the JSON-encoded response data for all WYSIWYG editors from either a specific submission or a question attempt. + * + * @param question_attempt|array $qa Either the last submission (a.k.a. qt data) or the entire question attempt, in which case + * the last submitted response is used. + * @return array An associative array of editor names to editor data objects, or an empty array if the last submission + * didn't include any editor data. + * @throws coding_exception + */ + public static function get_qpy_editors_data(question_attempt|array $qa): array { + if (is_array($qa)) { + $str = $qa[constants::QT_VAR_EDITORS] ?? null; + } else { + // In the unlikely event that the editor is removed between steps, the editors qt var would be missing. + // We don't want to use the old editor data in that case, so we choose the step based on the response qt var. + $lastresponsestep = $qa->get_last_step_with_qt_var(constants::QT_VAR_RESPONSE); + $str = $lastresponsestep->get_qt_var(constants::QT_VAR_EDITORS); + } + + if (!$str) { + return []; + } + + $editors = json_decode($str, depth: 3); + if (json_last_error() != JSON_ERROR_NONE) { + throw new coding_exception('Could not decode editor data JSON: ' . json_last_error_msg()); + } + if (!is_object($editors)) { + throw new coding_exception('Expected editor data JSON to be an object, got: ' . gettype($editors)); + } + + $editors = (array) $editors; + + foreach ($editors as $editorname => $editordata) { + if (!is_object($editordata)) { + throw new coding_exception("Expected editor data for '$editorname' to be an object, got: " . gettype($editordata)); + } + + if ( + !isset($editordata->text) || !is_string($editordata->text) + || !isset($editordata->format) || !is_numeric($editordata->format) + ) { + throw new coding_exception("Editor data for editor '$editorname' has wrong shape."); + } + } + + return $editors; + } } diff --git a/question.php b/question.php index 97cd19e4..9a595500 100644 --- a/question.php +++ b/question.php @@ -30,7 +30,10 @@ use qtype_questionpy\local\api\package_dependency; use qtype_questionpy\local\api\question_data; use qtype_questionpy\local\api\scoring_code; +use qtype_questionpy\local\api\wysiwyg_editor_data; use qtype_questionpy\local\attempt_ui\question_ui_metadata_extractor; +use qtype_questionpy\local\files\file_metadata; +use qtype_questionpy\local\files\response_file_service; use qtype_questionpy\question_bridge_base; use qtype_questionpy\utils; @@ -44,6 +47,8 @@ class qtype_questionpy_question extends question_graded_automatically_with_count // Properties which do not change between attempts. /** @var api */ private api $api; + /** @var response_file_service */ + private response_file_service $rfs; /** @var string */ public string $packagehash; /** @var string */ @@ -81,12 +86,19 @@ class qtype_questionpy_question extends question_graded_automatically_with_count * @param question_data $questiondata * @param stored_file|null $packagefile * @param api $api + * @param response_file_service $rfs */ public function __construct( - string $packagehash, string $questionstate, question_data $questiondata, ?stored_file $packagefile, api $api + string $packagehash, + string $questionstate, + question_data $questiondata, + ?stored_file $packagefile, + api $api, + response_file_service $rfs ) { parent::__construct(); $this->api = $api; + $this->rfs = $rfs; $this->packagehash = $packagehash; $this->questionstate = $questionstate; $this->questiondata = $questiondata; @@ -185,19 +197,29 @@ public function apply_attempt_state(question_attempt_step $step) { $this->attemptstate = $attemptstate; $this->scoringstate = $qa->get_last_qt_var(constants::QT_VAR_SCORING_STATE); - $lastresponse = utils::get_qpy_response($qa); - /* TODO: This method is also called from question_attempt->regrade and question_attempt->start_question_based_on, where we shouldn't need to get the UI. */ try { + $lastresponsestep = $qa->get_last_step_with_qt_var(constants::QT_VAR_RESPONSE); + $lastresponse = utils::get_qpy_response($lastresponsestep->get_qt_data()); + + $allfiles = $this->rfs->get_all_files_from_qt_data($lastresponsestep->get_qt_data()); + $editors = utils::get_qpy_editors_data($lastresponsestep->get_qt_data()); + array_walk( + $editors, + fn(&$editordata, $editorname) => $editordata = $this->build_wysiwyg_data($editorname, $editordata, $allfiles) + ); + $attributes = $this->get_requested_attributes(); + $attempt = $this->api->package($this->packagehash, $this->packagefile) ->view_attempt( $this->questionstate, $attributes, $this->attemptstate, $this->scoringstate, - $lastresponse + $lastresponse, + $editors, ); $this->update_attempt($attempt); $this->errorduringload = false; @@ -262,6 +284,7 @@ private function get_behaviour(): qbehaviour_questionpy { public function get_expected_data(): array|string { return [ constants::QT_VAR_RESPONSE => PARAM_RAW_TRIMMED, + constants::QT_VAR_EDITORS => PARAM_RAW_TRIMMED, constants::QT_VAR_RESPONSE_FILES => question_attempt::PARAM_FILES, ]; } @@ -286,7 +309,7 @@ public function get_correct_response(): ?array { } return [ - constants::QT_VAR_RESPONSE => json_encode((object) $correctresponse), + constants::QT_VAR_RESPONSE => json_encode((object)$correctresponse), ]; } @@ -311,11 +334,21 @@ public function is_complete_response(array $response): bool { return !empty($qpyresponse); } - foreach ($this->metadata->get_required_fields() as $requiredfield) { + foreach ($this->metadata->get_required_response_fields() as $requiredfield) { if (!isset($qpyresponse->{$requiredfield}) || $qpyresponse->{$requiredfield} === '') { return false; } } + + $editors = utils::get_qpy_editors_data($response); + foreach ($this->metadata->get_required_editors() as $requirededitorname) { + if (!isset($editors[$requirededitorname]) || $editors[$requirededitorname]->text === '') { + return false; + } + } + + // TODO: Check if file uploads have their min-files satisfied (#220). + return true; } @@ -336,6 +369,10 @@ public function is_same_response(array $prevresponse, array $newresponse): bool return false; } + if (utils::get_qpy_editors_data($prevresponse) != utils::get_qpy_editors_data($newresponse)) { + return false; + } + // We compare the hashes question_file_saver generates over all files. $prevfilehash = strval($prevresponse[constants::QT_VAR_RESPONSE_FILES] ?? ''); $newfilehash = strval($newresponse[constants::QT_VAR_RESPONSE_FILES] ?? ''); @@ -354,13 +391,15 @@ public function is_same_response(array $prevresponse, array $newresponse): bool * @throws moodle_exception */ public function summarise_response(array $response) { + // TODO: Include WYSIWYG editor data. + $summary = ''; $qpyresponse = utils::get_qpy_response($response); if ($qpyresponse) { $qpyresponse = get_object_vars($qpyresponse); - $dynamicdata = get_object_vars($qpyresponse['data'] ?? (object) []); + $dynamicdata = get_object_vars($qpyresponse['data'] ?? (object)[]); unset($qpyresponse['data']); if ($qpyresponse) { @@ -405,6 +444,30 @@ public function get_validation_error(array $response) { return ''; } + /** + * Joins the raw editor data with the files that belong to it ands returns a {@see wysiwyg_editor_data} object. + * + * @param string $editorname + * @param object $rawdata + * @param stored_file[] $allfiles + * @return wysiwyg_editor_data + * @throws coding_exception + */ + private function build_wysiwyg_data(string $editorname, object $rawdata, array $allfiles): wysiwyg_editor_data { + $filemetas = []; + foreach (response_file_service::filter_combined_files_for_field($allfiles, $editorname) as $filename => $file) { + $filemetas[] = file_metadata::from_stored_file($file, overridename: $filename); + } + + // TODO: Turn @@PLUGINFILE@@-links into QPy-URLs? + + return new wysiwyg_editor_data( + text: $rawdata->text, + textformat: $rawdata->format, + files: $filemetas, + ); + } + /** * Grade a response to the question, returning a fraction between * get_min_fraction() and get_max_fraction(), and the corresponding {@see question_state} @@ -421,12 +484,21 @@ public function grade_response(array $response): array { try { $attributes = $this->get_requested_attributes(); + + $allfiles = $this->rfs->get_all_files_from_qt_data($response); + $editors = utils::get_qpy_editors_data($response); + array_walk( + $editors, + fn(&$editordata, $editorname) => $editordata = $this->build_wysiwyg_data($editorname, $editordata, $allfiles) + ); + $attemptscored = $this->api->package($this->packagehash, $this->packagefile)->score_attempt( $this->questionstate, $attributes, $this->attemptstate, $this->scoringstate, - utils::get_qpy_response($response) ?? (object)[] + utils::get_qpy_response($response) ?? (object)[], + $editors ); $this->update_attempt($attemptscored); } catch (Throwable $t) { @@ -522,8 +594,8 @@ public function check_file_access($qa, $options, $component, $filearea, $args, $ /** * Get the QuestionPy bridge used to retrieve additional information about an attempt. * - * @throws moodle_exception * @return question_bridge_base|null + * @throws moodle_exception */ public function get_bridge(): ?question_bridge_base { if ($this->bridge === null) { diff --git a/questiontype.php b/questiontype.php index d9098bea..1909c77b 100644 --- a/questiontype.php +++ b/questiontype.php @@ -25,8 +25,8 @@ use core\di; use qtype_questionpy\constants; use qtype_questionpy\local\api\api; -use qtype_questionpy\local\files\options_file_service; use qtype_questionpy\local\api\question_data; +use qtype_questionpy\local\files\response_file_service; use qtype_questionpy\package_file_service; use qtype_questionpy\question_service; @@ -44,16 +44,16 @@ */ class qtype_questionpy extends question_type { /** @var api */ - private api $api; + private readonly api $api; /** @var package_file_service */ - private package_file_service $packagefileservice; + private readonly package_file_service $packagefileservice; /** @var question_service */ - private question_service $questionservice; + private readonly question_service $questionservice; - /** @var options_file_service */ - private options_file_service $ofs; + /** @var response_file_service */ + private readonly response_file_service $rfs; /** * Initializes the instance. Called by Moodle. @@ -63,7 +63,7 @@ public function __construct() { $this->api = di::get(api::class); $this->packagefileservice = di::get(package_file_service::class); $this->questionservice = di::get(question_service::class); - $this->ofs = di::get(options_file_service::class); + $this->rfs = di::get(response_file_service::class); } /** @@ -218,6 +218,7 @@ protected function make_question_instance($questiondata) { question_data::from_json($questiondata->qpy_question_data), $packagefile, $this->api, + $this->rfs, ); } } diff --git a/renderer.php b/renderer.php index c5557043..3a1fae02 100644 --- a/renderer.php +++ b/renderer.php @@ -86,35 +86,43 @@ public function formulation_and_controls(question_attempt $qa, question_display_ } try { - /** @var array|null $uploaddraftareas */ - $uploaddraftareas = null; + /** @var question_ui_renderer|null $quirenderer */ + $quirenderer = null; $questiondivid = $qa->get_outer_question_div_unique_id(); - $qpyresponseid = $questiondivid . '-qpy-response'; + $responseinputid = $questiondivid . '-qpy-response'; + $editorsinputid = $questiondivid . '-qpy-editors'; + $formulationcb = function (qtype_questionpy_renderer $renderer) - use ($qa, $question, $options, $qpyresponseid, &$uploaddraftareas) { + use ($qa, $question, $options, $responseinputid, $editorsinputid, &$quirenderer) { + // The ::render function must be called within the context of the iframe page. $quirenderer = question_ui_renderer::render($question->ui->formulation, $question->ui->placeholders, $options, $qa); - $uploaddraftareas = $quirenderer->draftareas; return $renderer->formulation_controls_feedback_in_iframe( $qa, $question->ui, $quirenderer, $options, - $qpyresponseid + $responseinputid, + $editorsinputid, ); }; $iframesrc = $this->get_iframe_document($options->context, $question, $formulationcb); $iframeid = $questiondivid . '-iframe'; - $qpyresponsename = $qa->get_field_prefix() . constants::QT_VAR_RESPONSE; + $responsedataname = $qa->get_field_prefix() . constants::QT_VAR_RESPONSE; + $editordataname = $qa->get_field_prefix() . constants::QT_VAR_EDITORS; + + if ($quirenderer === null) { + throw new coding_exception('$quirenderer was not set'); + } if (!$options->readonly) { $this->page->requires->js_call_amd( 'qtype_questionpy/view_question', 'addIframeFormDataOnSubmit', - [$iframeid, $qpyresponsename] + [$iframeid, $responsedataname, $editordataname, $quirenderer->editornames] ); } @@ -137,18 +145,17 @@ public function formulation_and_controls(question_attempt $qa, question_display_ // `HTMLFormElement.elements` attribute. $lastqpyresponse = s($qa->get_last_qt_var(constants::QT_VAR_RESPONSE) ?? '{}'); $result .= << + EOA; - if ($uploaddraftareas === null) { - throw new coding_exception('$uploaddraftareas was not set'); - } - if (count($uploaddraftareas) > 0) { + if (count($quirenderer->draftareas) > 0) { + // This also includes the files for WYSIWYG editors. + $result .= html_writer::empty_tag('input', [ 'type' => 'hidden', 'name' => $qa->get_field_prefix() . constants::FORM_DRAFT_AREAS, - 'value' => json_encode($uploaddraftareas, JSON_FORCE_OBJECT), + 'value' => json_encode($quirenderer->draftareas, JSON_FORCE_OBJECT), ]); $combineddraftareaid = file_get_unused_draft_itemid(); $result .= html_writer::empty_tag('input', [ @@ -158,6 +165,16 @@ public function formulation_and_controls(question_attempt $qa, question_display_ ]); } + if (count($quirenderer->editornames) > 0) { + $lasteditordata = $qa->get_last_qt_var(constants::QT_VAR_EDITORS) ?? '{}'; + + $result .= html_writer::empty_tag('input', [ + 'type' => 'hidden', + 'name' => $editordataname, + 'value' => $lasteditordata, + ]); + } + return $result; } catch (Throwable $t) { global $USER; @@ -253,13 +270,15 @@ protected function get_iframe_document(context $context, qtype_questionpy_questi * @param attempt_ui $ui * @param question_ui_renderer $renderer Render result. * @param question_display_options $options controls what should and should not be displayed. - * @param string $qpyresponseid + * @param string $responseinputid ID of the input that will generate {@see constants::QT_VAR_RESPONSE}. + * @param string $editorsinputid ID of the input that will generate {@see constants::QT_VAR_EDITORS}. * @return string HTML fragment. * @throws moodle_exception + * @throws coding_exception */ protected function formulation_controls_feedback_in_iframe( question_attempt $qa, attempt_ui $ui, question_ui_renderer $renderer, - question_display_options $options, string $qpyresponseid + question_display_options $options, string $responseinputid, string $editorsinputid ): string { global $CFG; @@ -290,7 +309,9 @@ 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, - $qpyresponseid, + $responseinputid, + $editorsinputid, + $renderer->editornames, $roles, utils::get_qpy_response($qa)->data ?? (object)[], intval($CFG->branch), @@ -595,4 +616,56 @@ public function render_readonly_file_view(question_attempt $qa, string $fieldnam return $result; } + + /** + * Renders a read-only view of content entered into a {@see qpy_rich_text_editor} field. + * + * @param object|null $editordata + * @param int $qubaid + * @param int $slot + * @param int $stepid + * @param string $fieldname + * @param question_display_options $options + * @param int $rows + * @param int $cols + * @return string + * @throws coding_exception + */ + public function render_readonly_editor_content( + ?object $editordata, + int $qubaid, + int $slot, + int $stepid, + string $fieldname, + question_display_options $options, + int $rows, + int $cols + ): string { + // Loosely based on qtype_essay_format_editor_renderer::response_area_read_only. + + $content = ''; + if ($editordata) { + // Replace the @@PLUGINFILE@@ placeholders with the correct pluginfile-URL prefix. + // TODO: We use the standard question attempt file serving here, which saves us from having to do access control + // ourselves, but also prevents us from using the actual (unmangled) filename when serving. + $prefix = moodle_url::make_pluginfile_url( + contextid: $options->context->id, + component: 'question', + area: constants::FILEAREA_RESPONSE_FILES, + itemid: null, + pathname: "/$qubaid/$slot/$stepid/", + filename: response_file_service::mangled_prefix_for($fieldname) + ); + $text = str_replace('@@PLUGINFILE@@/', $prefix, $editordata->text); + + $content = format_text($text, $editordata->format, options: ['para' => false]); + } + + return html_writer::tag('div', $content, [ + 'role' => 'textbox', + 'aria-readonly' => 'true', + 'class' => 'readonly', + 'style' => 'min-height: ' . ($rows * 1.5) . 'em;', + ]); + } } diff --git a/styles.css b/styles.css index 75f182ff..afb1b8c5 100644 --- a/styles.css +++ b/styles.css @@ -47,7 +47,7 @@ body.questionpy-iframe-body { top: 4vh !important; /* stylelint-disable-line declaration-no-important */ } - &:has(.moodle-dialogue:not(.moodle-dialogue-hidden)) { + &:has(.moodle-dialogue:not(.moodle-dialogue-hidden), .modal.show) { /* The iframe needs to be taller than the filepicker dialog, or it'll be unusable. */ @media (max-width: 780px) { min-height: 1000px; @@ -60,6 +60,11 @@ body.questionpy-iframe-body { } } +.qpy-editor-container.readonly { + /* Adopted from .que.essay .qtype_essay_response.readonly. */ + background-color: var(--bs-white); +} + /* Make question iframe seamless. */ body.questionpy-iframe-body .formulation { padding: 0; diff --git a/tests/local/attempt_ui/question_ui_metadata_extractor_test.php b/tests/local/attempt_ui/question_ui_metadata_extractor_test.php index e66d6971..4000ca00 100644 --- a/tests/local/attempt_ui/question_ui_metadata_extractor_test.php +++ b/tests/local/attempt_ui/question_ui_metadata_extractor_test.php @@ -42,6 +42,6 @@ public function test_should_extract_correct_metadata(): void { 'my_text' => 'Lorem ipsum dolor sit amet.', ], $metadata->get_correct_response()); - $this->assertEquals(['my_number'], $metadata->get_required_fields()); + $this->assertEquals(['my_number'], $metadata->get_required_response_fields()); } } diff --git a/tests/local/attempt_ui/question_ui_renderer_test.php b/tests/local/attempt_ui/question_ui_renderer_test.php index 7e168547..586b0299 100644 --- a/tests/local/attempt_ui/question_ui_renderer_test.php +++ b/tests/local/attempt_ui/question_ui_renderer_test.php @@ -27,6 +27,7 @@ use qtype_questionpy\constants; use qtype_questionpy\local\api\api; use qtype_questionpy\local\api\question_data; +use qtype_questionpy\local\files\response_file_service; use qtype_questionpy_question; use question_attempt; use question_attempt_step; @@ -515,6 +516,7 @@ private function create_question_attempt_stub(?string $packagehash = null, ?int $this->createStub(question_data::class), null, $this->createStub(api::class), + $this->createStub(response_file_service::class) ); $step = new question_attempt_step([constants::QT_VAR_RESPONSE => json_encode((object) $lastresponse)]); diff --git a/tests/question_test.php b/tests/question_test.php index 4ed4b4d7..99b83d81 100644 --- a/tests/question_test.php +++ b/tests/question_test.php @@ -17,6 +17,7 @@ namespace qtype_questionpy; use coding_exception; +use core\di; use moodle_exception; use qbehaviour_questionpy; use qtype_questionpy\event\grading_response_failed; @@ -28,13 +29,22 @@ use qtype_questionpy\local\api\question_response; use qtype_questionpy\local\api\scoring_method; use qtype_questionpy\local\attempt_ui\question_ui_metadata_extractor; +use qtype_questionpy\local\files\response_file_service; use qtype_questionpy_question; use question_attempt; +use question_attempt_step; use question_bank; use question_engine; use question_state; +use testable_question_attempt; use Throwable; +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/question/behaviour/questionpy/behaviour.php'); +require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); + /** * Unit tests for the questionpy question class. * @@ -53,6 +63,8 @@ final class question_test extends \advanced_testcase { */ private readonly package_api $packageapi; + /** @var qbehaviour_questionpy $behaviour */ + private readonly qbehaviour_questionpy $behaviour; /** * This method is called before each test. @@ -70,6 +82,8 @@ protected function setUp(): void { $this->packageapi = $this->createMock(package_api::class); $this->api->method('package') ->willReturn($this->packageapi); + + $this->behaviour = $this->createStub(qbehaviour_questionpy::class); } /** @@ -89,9 +103,10 @@ private function create_question(): qtype_questionpy_question { $state, $questiondata, packagefile: null, - api: $this->api + api: $this->api, + rfs: $this->createStub(response_file_service::class), ); - $question->behaviour = $this->createStub(qbehaviour_questionpy::class); + $question->behaviour = $this->behaviour; return $question; } @@ -112,7 +127,7 @@ public function test_start_attempt_request_failed(): void { // Calling expectExpectation and assertDebuggingCalled seems buggy. try { - $question->start_attempt(new \question_attempt_step(), 1); + $question->start_attempt(new question_attempt_step(), 1); $this->fail('An exception should have been thrown.'); } catch (\Exception $e) { $this->assertEquals($exception, $e); @@ -146,10 +161,13 @@ public function test_apply_attempt_state_failed(): void { $this->packageapi->method('view_attempt')->willThrowException($exception); $question = $this->create_question(); + $qa = new testable_question_attempt($question, 1); + $this->behaviour->method('get_qa')->willReturn($qa); // Pretend that the question was started successfully. - $step = new \question_attempt_step(); + $step = new question_attempt_step(); $step->set_qt_var(constants::QT_VAR_ATTEMPT_STATE, 'state'); + $qa->add_step($step); $sink = $this->redirectEvents(); $question->apply_attempt_state($step); @@ -221,6 +239,7 @@ public function test_get_expected_data(): void { $this->assertEquals([ constants::QT_VAR_RESPONSE => PARAM_RAW_TRIMMED, constants::QT_VAR_RESPONSE_FILES => question_attempt::PARAM_FILES, + constants::QT_VAR_EDITORS => PARAM_RAW_TRIMMED, ], $question->get_expected_data()); }