diff --git a/.github/workflows/moodle-ci.yml b/.github/workflows/moodle-ci.yml
index 2d3b2bde185..f33c9e526f2 100644
--- a/.github/workflows/moodle-ci.yml
+++ b/.github/workflows/moodle-ci.yml
@@ -8,7 +8,7 @@ jobs:
services:
postgres:
- image: postgres:16
+ image: postgres:17
env:
POSTGRES_USER: 'postgres'
POSTGRES_HOST_AUTH_METHOD: 'trust'
diff --git a/.gitignore b/.gitignore
index 61b44de1563..586d202ee35 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,8 @@
site/*
+tests/jest/node_modules/*
# Ignore macOS .DS_Store files
-.DS_Store
+.DS_Store
# Ignore eclipse project files
.project
diff --git a/amd/build/metadata/container.min.js b/amd/build/metadata/container.min.js
new file mode 100644
index 00000000000..b722e8328b6
--- /dev/null
+++ b/amd/build/metadata/container.min.js
@@ -0,0 +1,11 @@
+define("qtype_stack/metadata/container",["exports","core/reactive","qtype_stack/metadata/metadata","core_form/events"],(function(_exports,_reactive,_metadata,_events){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;
+/**
+ * Main STACK metadata component
+ *
+ * @module qtype_stack/metadata
+ * @copyright 2025 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+class _default extends _reactive.BaseComponent{create(){this.name="stack-metadata-container",this.selectors={METADATACONTAINER:"[data-for='qtype-stack-metadata']",UPDATEJSON:"#stack-metadata-update",UPDATEINPUTS:"#stack-metadata-update-inputs",EDITTOGGLE:"#stack-metadata-edit-switch",ADDITEM:'[name="smd_add"]',DELETEITEM:'[name="smd_delete"]',MAKEAUTHOR:"#stack-metadata-make-author",REVERT:"#stack-metadata-revert",FORMJSON:'input[name="metadata"]',FORM:"#qtype-stack-metadata-content",JSONINPUT:"#id_metadata_json",REQUIREDINPUTS:'#qtype-stack-metadata-content input[aria-required="true"]',VALIDATIONERRORS:"#qtype-stack-metadata-content .is-invalid",ALLINPUTS:'#qtype-stack-metadata-content [id^="smdi"]',DETAILS:"#qtype-stack-metadata-content details[id]",ADDAUTHOR:'[id^="smdi_"][id$="_author_firstName"]',ADDLANGUAGE:'[id^="smdi_"][id$="_language_value"]',ADDPROPERTY:'[id^="smdi_"][id$="_additional_property"]',ADDSCOPE:'[id^="smdi_"][id$="_additional_scope"]',ADDBUTTONS:'[name="smd_add"], #stack-metadata-make-author',SCOPECARD:".smd-scope-card",DELETEADDITIONAL:'[id^="smd_property_"][id$="_add"]',DELETEAUTHOR:"#smd_author_0_add",DELETELANGUAGE:"#smd_language_0_add",DELETESCOPE:"#smd_scope_0_add"},this.isEditing=!1,this.detailsOpen={},this.pendingFocus=null,_metadata.metadata.container=this}static init(target,selectors){return new this({element:document.querySelector(target),reactive:_metadata.metadata,selectors:selectors})}async stateReady(state){await this.reloadContainerComponent({state:state})}getWatchers(){return[{watch:"state:updated",handler:this.reloadContainerComponent}]}createDataElement(required,id,tag,value){return{required:required,element:{value:value,wrapperid:"fitem_smdi_"+id+"_"+tag,id:"smdi_"+id+"_"+tag,name:"smdi_"+id+"_"+tag,iderror:"smde_"+id+"_"+tag+"_error"}}}async reloadContainerComponent(_ref){var _this$detailsOpen$qty;let{state:state}=_ref;const details=this.getElements(this.selectors.DETAILS);for(const detail of details)this.detailsOpen[detail.id]=detail.open;const data={author:[],language:[],license:this.createDataElement(!0,0,"license_value",state.license.value),isPartOf:this.createDataElement(!1,0,"isPartOf_value",state.isPartOf.value),scope:[],freeform:this.createDataElement(!1,0,"freeform_value",state.freeform.value||"{}"),isEditing:this.isEditing,isJsonOpen:null!==(_this$detailsOpen$qty=this.detailsOpen["qtype-stack-metadata-json-section"])&&void 0!==_this$detailsOpen$qty&&_this$detailsOpen$qty};data.license.element.options=JSON.parse(JSON.stringify(_metadata.metadata.lib.licenses));const selectedLicense=state.license.value;let selectedOption=data.license.element.options.find((op=>op.value===selectedLicense));selectedOption?selectedOption.selected=!0:data.license.element.options.push({value:state.license.value,text:state.license.value,selected:!0}),data.license.element.tags="[]",data.license.element.ajax="",data.license.element.placeholder=_metadata.metadata.lib.placeholder,data.license.element.noselectionstring="",data.license.element.showsuggestions="true",data.license.element.casesensitive="false",state.language.forEach((language=>{const element={id:language.id,lang:this.createDataElement(!0,language.id,"language_value",language.value)};data.language.push({...element})})),state.author.forEach((author=>{const element={firstname:this.createDataElement(!1,author.id,"author_firstName",author.firstName),lastname:this.createDataElement(!1,author.id,"author_lastName",author.lastName),institution:this.createDataElement(!1,author.id,"author_institution",author.institution),year:this.createDataElement(!1,author.id,"author_year",author.year),id:author.id};data.author.push({...element})}));const scopeHolder={};state.additional.forEach((additional=>{const element={property:this.createDataElement(!0,additional.id,"additional_property",additional.property),qualifier:this.createDataElement(!1,additional.id,"additional_qualifier",additional.qualifier),value:this.createDataElement(!1,additional.id,"additional_value",additional.value),id:additional.id};scopeHolder[additional.scope]||(scopeHolder[additional.scope]=[]),scopeHolder[additional.scope].push(element)}));for(const scope in scopeHolder){var _this$detailsOpen$sco;const scopeId="qtype-stack-metadata-scope-"+scopeHolder[scope][0].id,current={id:scopeId,name:scope,firstProp:scopeHolder[scope][0].id,properties:scopeHolder[scope],input:this.createDataElement(!0,scopeHolder[scope][0].id,"additional_scope",scope),isOpen:null===(_this$detailsOpen$sco=this.detailsOpen[scopeId])||void 0===_this$detailsOpen$sco||_this$detailsOpen$sco};data.scope.push(current)}data.json={required:!0,element:{value:_metadata.metadata.jsonStringify(state,4),attributes:'rows="10"',wrapperid:"fitem_metadata_json",id:"id_metadata_json",name:"metadata_json"}};const metadataContainer=this.getElement(this.selectors.METADATACONTAINER);if(!metadataContainer)throw new Error("Missing metadata container.");await this.renderComponent(metadataContainer,"qtype_stack/metadata/metadatacontent",data);const detailSections=this.getElements(this.selectors.DETAILS);for(const detailSection of detailSections)this.addEventListener(detailSection,"toggle",this.setDetailsState);this.addEventListener(document.querySelector(this.selectors.EDITTOGGLE),"change",this.toggleEditing),this.addEventListener(this.getElement(this.selectors.UPDATEJSON),"click",(async()=>{this.pendingFocus={selector:this.selectors.UPDATEJSON},await this.update()}));const addButtons=this.getElements(this.selectors.ADDITEM);for(const addButton of addButtons)this.addEventListener(addButton,"click",this.addItem);const deleteButtons=this.getElements(this.selectors.DELETEITEM);for(const deleteButton of deleteButtons)this.addEventListener(deleteButton,"click",this.deleteItem);if(this.addEventListener(this.getElement(this.selectors.UPDATEINPUTS),"click",(async()=>{this.pendingFocus={selector:this.selectors.UPDATEINPUTS},await this.updateInputs()})),this.addEventListener(this.getElement(this.selectors.MAKEAUTHOR),"click",this.makeAuthor),this.addEventListener(this.getElement(this.selectors.REVERT),"click",(async()=>{this.pendingFocus={selector:this.selectors.REVERT},await this.revert()})),_metadata.metadata.lib.brokenMetadata){var _document$querySelect;const jsonElement=this.getElement(this.selectors.JSONINPUT);jsonElement.value=null!==(_document$querySelect=document.querySelector(this.selectors.FORMJSON).value)&&void 0!==_document$querySelect?_document$querySelect:"",(0,_events.notifyFieldValidationFailure)(jsonElement,_metadata.metadata.lib.brokenMetadata),delete _metadata.metadata.lib.brokenMetadata}this.restorePendingFocus()}async toggleEditing(event){if(event.currentTarget.checked)return this.isEditing=!0,void await this.reloadContainerComponent({state:this.reactive.state});if(!this.validateInputs())return void(event.currentTarget.checked=!0);this.isEditing=!1;await this.update(!1)||(this.isEditing=!0,event.currentTarget.checked=!0,await this.reloadContainerComponent({state:this.reactive.state}))}validateInputs(){const requiredElements=this.getElements(this.selectors.REQUIREDINPUTS);let isError=!1;for(const element of requiredElements)""===element.value?(isError=!0,(0,_events.notifyFieldValidationFailure)(element,"Required")):element.classList.contains("is-invalid")&&(0,_events.notifyFieldValidationFailure)(element,"");const freeformElement=this.getElement("#smdi_0_freeform_value");if(freeformElement&&""!==freeformElement.value.trim())try{JSON.parse(freeformElement.value),(0,_events.notifyFieldValidationFailure)(freeformElement,"")}catch(e){(0,_events.notifyFieldValidationFailure)(freeformElement,e.message),isError=!0}return!isError}async update(){if((!(arguments.length>0&&void 0!==arguments[0])||arguments[0])&&!this.validateInputs())return!1;let inputElements=this.getElements(this.selectors.ALLINPUTS);inputElements=Array.from(inputElements).map((el=>[el.id,el.value]));try{await this.reactive.dispatch("updateAll",inputElements)}catch(e){const addIds=e.split(",");for(const id of addIds){const element=this.getElement('#qtype-stack-metadata-content [id="smdi_'+id+'_additional_qualifier"]');(0,_events.notifyFieldValidationFailure)(element,"Required")}return!1}return!0}async addItem(event){if(await this.update(!0)){const parts=event.currentTarget.id.split("_");this.queueFocus("add",parts[1],event.currentTarget),await this.reactive.dispatch("addItem",parts[1],parts[2])}}async deleteItem(event){if(await this.update(!1)){const parts=event.currentTarget.id.split("_");this.queueFocus("delete",parts[1],event.currentTarget),await this.reactive.dispatch("deleteRow",parts[1],parts[2])}}async updateInputs(){const jsonElement=this.getElement(this.selectors.JSONINPUT);let data=null;try{data=_metadata.metadata.jsonToState(jsonElement.value),(0,_events.notifyFieldValidationFailure)(jsonElement,"")}catch(e){return void(0,_events.notifyFieldValidationFailure)(jsonElement,e.message)}jsonElement.value=_metadata.metadata.jsonStringify(data,4),await this.reactive.dispatch("updateFromJson",data)}async makeAuthor(){await this.update(!1)&&(this.queueFocus("add","author",this.getElement(this.selectors.MAKEAUTHOR)),await this.reactive.dispatch("addItem","author","user"))}async revert(){var _document$querySelect2;const jsonElement=this.getElement(this.selectors.JSONINPUT);let previousdataJSON=null!==(_document$querySelect2=document.querySelector(this.selectors.FORMJSON).value)&&void 0!==_document$querySelect2?_document$querySelect2:null,previousdata=null;try{previousdata=_metadata.metadata.jsonToState(previousdataJSON),(0,_events.notifyFieldValidationFailure)(jsonElement,"")}catch(e){return(0,_events.notifyFieldValidationFailure)(jsonElement,e.message),jsonElement.value=previousdataJSON,void(_metadata.metadata.lib.brokenMetadata=e.message)}jsonElement.value=_metadata.metadata.jsonStringify(previousdata,4),await this.reactive.dispatch("updateFromJson",previousdata)}setDetailsState(event){const detail=event.currentTarget;this.detailsOpen[detail.id]=detail.open}queueFocus(action,category,target){this.pendingFocus=null;const selectorName=(action+category).toUpperCase(),selector=this.selectors[selectorName];if(selector){switch(action){case"add":this.pendingFocus={selector:selector,selection:"last"};break;case"delete":this.pendingFocus={selector:selector,fallbackSelector:"DELETEADDITIONAL"===selectorName?this.selectors.DELETESCOPE:null,selection:"scope"}}if("ADDPROPERTY"===selectorName||"DELETEADDITIONAL"===selectorName){var _target$closest,_scopeCard$querySelec,_scopeCard$querySelec2,_scopeCard$querySelec3;const scopeCard=null==target||null===(_target$closest=target.closest)||void 0===_target$closest?void 0:_target$closest.call(target,this.selectors.SCOPECARD);this.pendingFocus.scopeName=null!==(_scopeCard$querySelec=null==scopeCard||null===(_scopeCard$querySelec2=scopeCard.querySelector)||void 0===_scopeCard$querySelec2||null===(_scopeCard$querySelec3=_scopeCard$querySelec2.call(scopeCard,this.selectors.ADDSCOPE))||void 0===_scopeCard$querySelec3?void 0:_scopeCard$querySelec3.value)&&void 0!==_scopeCard$querySelec?_scopeCard$querySelec:null}}}restorePendingFocus(){if(!this.pendingFocus)return;const pendingFocus=this.pendingFocus;this.pendingFocus=null,this.getElements(this.selectors.VALIDATIONERRORS).length||("undefined"!=typeof window&&window.setTimeout?window.setTimeout((()=>{this.focusPendingTarget(pendingFocus)}),0):this.focusPendingTarget(pendingFocus))}focusPendingTarget(pendingFocus){var _focusTarget,_focusTarget3;let focusTarget=null;const scopeCard=pendingFocus.scopeName?this.getScopeCard(pendingFocus.scopeName):null;if("last"===pendingFocus.selection){var _scopeCard$querySelec4;const elements=Array.from((null==scopeCard||null===(_scopeCard$querySelec4=scopeCard.querySelectorAll)||void 0===_scopeCard$querySelec4?void 0:_scopeCard$querySelec4.call(scopeCard,pendingFocus.selector))||this.getElements(pendingFocus.selector));focusTarget=elements[elements.length-1]||null}else if("scope"===pendingFocus.selection){var _scopeCard$querySelec5;focusTarget=(null==scopeCard||null===(_scopeCard$querySelec5=scopeCard.querySelector)||void 0===_scopeCard$querySelec5?void 0:_scopeCard$querySelec5.call(scopeCard,pendingFocus.selector))||this.getElement(pendingFocus.fallbackSelector||pendingFocus.selector)}else focusTarget=this.getElements(pendingFocus.selector)[0];var _focusTarget$matches,_focusTarget2;if(null!==(_focusTarget=focusTarget)&&void 0!==_focusTarget&&_focusTarget.focus)if(null!==(_focusTarget$matches=(_focusTarget2=focusTarget).matches)&&void 0!==_focusTarget$matches&&_focusTarget$matches.call(_focusTarget2,this.selectors.ADDBUTTONS))try{focusTarget.focus({focusVisible:!0})}catch{focusTarget.focus()}else focusTarget.focus();null!==(_focusTarget3=focusTarget)&&void 0!==_focusTarget3&&_focusTarget3.select&&focusTarget.select()}getScopeCard(scopeName){var _Array$from$find;return null!==(_Array$from$find=Array.from(this.getElements(this.selectors.SCOPECARD)).find((scopeCard=>{var _scopeCard$querySelec6,_scopeCard$querySelec7;return(null===(_scopeCard$querySelec6=scopeCard.querySelector)||void 0===_scopeCard$querySelec6||null===(_scopeCard$querySelec7=_scopeCard$querySelec6.call(scopeCard,this.selectors.ADDSCOPE))||void 0===_scopeCard$querySelec7?void 0:_scopeCard$querySelec7.value)===scopeName})))&&void 0!==_Array$from$find?_Array$from$find:null}}return _exports.default=_default,_exports.default}));
+
+//# sourceMappingURL=container.min.js.map
\ No newline at end of file
diff --git a/amd/build/metadata/container.min.js.map b/amd/build/metadata/container.min.js.map
new file mode 100644
index 00000000000..72dc129df29
--- /dev/null
+++ b/amd/build/metadata/container.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"container.min.js","sources":["../../src/metadata/container.js"],"sourcesContent":["// This file is part of Stack - http://stack.maths.ed.ac.uk/\n//\n// Stack 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// Stack 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 Stack. If not, see .\n\n/**\n * Main STACK metadata component\n *\n * @module qtype_stack/metadata\n * @copyright 2025 University of Edinburgh\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.\n */\n\nimport {BaseComponent} from 'core/reactive';\nimport {metadata} from 'qtype_stack/metadata/metadata';\nimport {notifyFieldValidationFailure} from 'core_form/events';\n\nexport default class extends BaseComponent {\n create() {\n this.name = 'stack-metadata-container';\n this.selectors = {\n METADATACONTAINER: `[data-for='qtype-stack-metadata']`,\n UPDATEJSON: `#stack-metadata-update`,\n UPDATEINPUTS: `#stack-metadata-update-inputs`,\n EDITTOGGLE: `#stack-metadata-edit-switch`,\n ADDITEM: `[name=\"smd_add\"]`,\n DELETEITEM: `[name=\"smd_delete\"]`,\n MAKEAUTHOR: `#stack-metadata-make-author`,\n REVERT: `#stack-metadata-revert`,\n FORMJSON: 'input[name=\"metadata\"]',\n FORM: '#qtype-stack-metadata-content',\n JSONINPUT: '#id_metadata_json',\n REQUIREDINPUTS: '#qtype-stack-metadata-content input[aria-required=\"true\"]',\n VALIDATIONERRORS: '#qtype-stack-metadata-content .is-invalid',\n ALLINPUTS: '#qtype-stack-metadata-content [id^=\"smdi\"]',\n DETAILS: '#qtype-stack-metadata-content details[id]',\n ADDAUTHOR: '[id^=\"smdi_\"][id$=\"_author_firstName\"]',\n ADDLANGUAGE: '[id^=\"smdi_\"][id$=\"_language_value\"]',\n ADDPROPERTY: '[id^=\"smdi_\"][id$=\"_additional_property\"]',\n ADDSCOPE: '[id^=\"smdi_\"][id$=\"_additional_scope\"]',\n ADDBUTTONS: '[name=\"smd_add\"], #stack-metadata-make-author',\n SCOPECARD: '.smd-scope-card',\n DELETEADDITIONAL: '[id^=\"smd_property_\"][id$=\"_add\"]',\n DELETEAUTHOR: '#smd_author_0_add',\n DELETELANGUAGE: '#smd_language_0_add',\n DELETESCOPE: '#smd_scope_0_add',\n };\n this.isEditing = false;\n this.detailsOpen = {};\n this.pendingFocus = null;\n metadata.container = this;\n }\n\n /**\n * Static method to create a component instance form the mustache template.\n *\n * @param {string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n return new this({\n element: document.querySelector(target),\n reactive: metadata,\n selectors,\n });\n }\n\n /**\n * Initial state ready method.\n *\n * @param {object} state the initial state\n */\n async stateReady(state) {\n await this.reloadContainerComponent({state});\n }\n\n /**\n * Set to refresh display on any state change.\n *\n * @returns {object} watchers\n */\n getWatchers() {\n return [\n {watch: `state:updated`, handler: this.reloadContainerComponent},\n ];\n }\n\n /**\n * Converts field information into element suitable for feeding into Mustache templates.\n *\n * @param {bool} required\n * @param {mixed} id to link to state\n * @param {string} tag type of field\n * @param {mixed} value of element\n * @returns {object}\n */\n createDataElement(required, id, tag, value) {\n return {\n required: required,\n element: {\n value: value,\n wrapperid: 'fitem_smdi_' + id + '_' + tag,\n id: 'smdi_' + id + '_' + tag,\n name: 'smdi_' + id + '_' + tag,\n iderror: 'smde_' + id + '_' + tag + '_error'\n }\n };\n }\n\n async reloadContainerComponent({state}) {\n const details = this.getElements(this.selectors.DETAILS);\n for (const detail of details) {\n this.detailsOpen[detail.id] = detail.open;\n }\n\n // Mustache data is not fully compatible with state object so we need to convert it\n // into a plain object.\n const data = {\n author: [],\n language: [],\n license: this.createDataElement(true, 0, 'license_value', state.license.value),\n isPartOf: this.createDataElement(false, 0, 'isPartOf_value', state.isPartOf.value),\n scope: [],\n freeform: this.createDataElement(false, 0, 'freeform_value', state.freeform.value || '{}'),\n isEditing: this.isEditing,\n isJsonOpen: this.detailsOpen['qtype-stack-metadata-json-section'] ?? false,\n };\n\n // Need to copy licenses list as we modify to mark as selected.\n data.license.element.options = JSON.parse(JSON.stringify(metadata.lib.licenses));\n const selectedLicense = state.license.value;\n let selectedOption = data.license.element.options.find((op) => op.value === selectedLicense);\n if (selectedOption) {\n selectedOption.selected = true;\n } else {\n data.license.element.options.push({value: state.license.value, text: state.license.value, selected: true});\n }\n data.license.element.tags = '[]';\n data.license.element.ajax = '';\n data.license.element.placeholder = metadata.lib.placeholder;\n data.license.element.noselectionstring = '';\n data.license.element.showsuggestions = 'true';\n data.license.element.casesensitive = 'false';\n\n state.language.forEach(language => {\n const element = { id: language.id, lang: this.createDataElement(true, language.id, 'language_value', language.value) };\n data.language.push({...element});\n });\n\n state.author.forEach(author => {\n const element = {\n firstname: this.createDataElement(false, author.id, 'author_firstName', author.firstName),\n lastname: this.createDataElement(false, author.id, 'author_lastName', author.lastName),\n institution: this.createDataElement(false, author.id, 'author_institution', author.institution),\n year: this.createDataElement(false, author.id, 'author_year', author.year),\n id: author.id,\n };\n data.author.push({...element});\n });\n\n const scopeHolder = {};\n // Rearrange additional metadata by scope.\n state.additional.forEach(additional => {\n const element = {\n property: this.createDataElement(true, additional.id, 'additional_property', additional.property),\n qualifier: this.createDataElement(false, additional.id, 'additional_qualifier', additional.qualifier),\n value: this.createDataElement(false, additional.id, 'additional_value', additional.value),\n id: additional.id,\n };\n if (!scopeHolder[additional.scope]) {\n scopeHolder[additional.scope] = [];\n }\n scopeHolder[additional.scope].push(element);\n });\n for (const scope in scopeHolder) {\n const scopeId = 'qtype-stack-metadata-scope-' + scopeHolder[scope][0].id;\n const current = {\n id: scopeId,\n name: scope,\n firstProp: scopeHolder[scope][0].id,\n properties: scopeHolder[scope],\n input: this.createDataElement(true, scopeHolder[scope][0].id, 'additional_scope', scope),\n isOpen: this.detailsOpen[scopeId] ?? true,\n };\n data.scope.push(current);\n }\n\n data.json = {\n required: true,\n element: {\n value: metadata.jsonStringify(state, 4),\n attributes: 'rows=\"10\"',\n wrapperid: 'fitem_metadata_json',\n id: 'id_metadata_json',\n name: 'metadata_json',\n }\n };\n\n // To render a child component we need a container.\n const metadataContainer = this.getElement(this.selectors.METADATACONTAINER);\n if (!metadataContainer) {\n throw new Error('Missing metadata container.');\n }\n\n await this.renderComponent(metadataContainer, 'qtype_stack/metadata/metadatacontent', data);\n\n // Add all the event listeners as all elements have been destroyed and rebuilt.\n const detailSections = this.getElements(this.selectors.DETAILS);\n for (const detailSection of detailSections) {\n this.addEventListener(\n detailSection,\n 'toggle',\n this.setDetailsState\n );\n }\n this.addEventListener(\n document.querySelector(this.selectors.EDITTOGGLE),\n 'change',\n this.toggleEditing\n );\n this.addEventListener(\n this.getElement(this.selectors.UPDATEJSON),\n 'click',\n async() => {\n this.pendingFocus = {selector: this.selectors.UPDATEJSON};\n await this.update();\n }\n );\n const addButtons = this.getElements(this.selectors.ADDITEM);\n for (const addButton of addButtons) {\n this.addEventListener(\n addButton,\n 'click',\n this.addItem\n );\n }\n const deleteButtons = this.getElements(this.selectors.DELETEITEM);\n for (const deleteButton of deleteButtons) {\n this.addEventListener(\n deleteButton,\n 'click',\n this.deleteItem\n );\n }\n this.addEventListener(\n this.getElement(this.selectors.UPDATEINPUTS),\n 'click',\n async() => {\n this.pendingFocus = {selector: this.selectors.UPDATEINPUTS};\n await this.updateInputs();\n }\n );\n this.addEventListener(\n this.getElement(this.selectors.MAKEAUTHOR),\n 'click',\n this.makeAuthor\n );\n this.addEventListener(\n this.getElement(this.selectors.REVERT),\n 'click',\n async() => {\n this.pendingFocus = {selector: this.selectors.REVERT};\n await this.revert();\n }\n );\n\n // Deal with case of brkon JSON in saved question. The errormessage is saved on initial setup.\n // We load in the original un-prettified JSON and display error message, giving user chance to edit.\n // After this, though, they'll need to sort it out - if we're back here again then we'll use\n // JSON created from current content of state.\n if (metadata.lib.brokenMetadata) {\n const jsonElement = this.getElement(this.selectors.JSONINPUT);\n jsonElement.value = document.querySelector(this.selectors.FORMJSON).value ?? '';\n notifyFieldValidationFailure(jsonElement, metadata.lib.brokenMetadata);\n delete metadata.lib.brokenMetadata;\n }\n\n this.restorePendingFocus();\n }\n\n /**\n * Toggle edit mode. Turning edit mode off validates and updates the state first.\n *\n * @param {Event} event\n */\n async toggleEditing(event) {\n if (event.currentTarget.checked) {\n this.isEditing = true;\n await this.reloadContainerComponent({state: this.reactive.state});\n return;\n }\n if (!this.validateInputs()) {\n event.currentTarget.checked = true;\n return;\n }\n this.isEditing = false;\n const result = await this.update(false);\n if (!result) {\n this.isEditing = true;\n event.currentTarget.checked = true;\n await this.reloadContainerComponent({state: this.reactive.state});\n }\n }\n\n /**\n * Validate visible metadata inputs without dispatching state changes.\n *\n * @returns {bool} Returns false on validation error.\n */\n validateInputs() {\n const requiredElements = this.getElements(this.selectors.REQUIREDINPUTS);\n let isError = false;\n for (const element of requiredElements) {\n if (element.value === '') {\n isError = true;\n notifyFieldValidationFailure(element, 'Required');\n } else if (element.classList.contains('is-invalid')) {\n // Reset warning as field no longer empty.\n notifyFieldValidationFailure(element, '');\n }\n }\n // Validate freeform JSON if non-empty.\n const freeformElement = this.getElement('#smdi_0_freeform_value');\n if (freeformElement && freeformElement.value.trim() !== '') {\n try {\n JSON.parse(freeformElement.value);\n notifyFieldValidationFailure(freeformElement, '');\n } catch(e) {\n notifyFieldValidationFailure(freeformElement, e.message);\n isError = true;\n }\n }\n return !isError;\n }\n\n /**\n * Updates state based on contents of inputs.\n *\n * @param {bool} mustValidate Do we want validation to occur?\n * We check when explicitly asked for and when attempting to close the modal other than by cancel.\n * We don't check when e.g. adding an author. This means state can be invalid but we only\n * update the edit form entry after successful validation on modal close.\n * @returns {bool} Returns false on validation error.\n */\n async update(mustValidate = true) {\n if (mustValidate && !this.validateInputs()) {\n return false;\n }\n // Elements have ids in form smdi_id_category_field e.g. smdi_1_author_year.\n // id is category entry id in state. 0 is used for single elements e.g. license.\n // Multi-elements begin counting from 1.\n let inputElements = this.getElements(this.selectors.ALLINPUTS);\n inputElements = Array.from(inputElements).map((el) => [el.id, el.value]);\n try {\n await this.reactive.dispatch('updateAll', inputElements);\n } catch (e) {\n const addIds = e.split(',');\n for (const id of addIds) {\n const element = this.getElement('#qtype-stack-metadata-content [id=\"smdi_' + id + '_additional_qualifier\"]');\n notifyFieldValidationFailure(element, 'Required');\n }\n return false;\n }\n return true;\n }\n\n /**\n * Add a new row to modal form.\n * We have to update state from the input fields first or any changes will\n * be wiped when we refresh the display to show the new row.\n *\n * @param {*} event\n */\n async addItem(event) {\n const result = await this.update(true);\n if (result) {\n const parts = event.currentTarget.id.split('_');\n this.queueFocus('add', parts[1], event.currentTarget);\n await this.reactive.dispatch('addItem', parts[1], parts[2]);\n }\n }\n /**\n * Delete a row from modal form.\n * We have to update state from the input fields first or any changes will\n * be wiped when we refresh the display toremove the row\n *\n * @param {*} event\n */\n async deleteItem(event) {\n const result = await this.update(false);\n if (result) {\n const parts = event.currentTarget.id.split('_');\n this.queueFocus('delete', parts[1], event.currentTarget);\n await this.reactive.dispatch('deleteRow', parts[1], parts[2]);\n }\n }\n\n /**\n * Update state from the currently entered JSON if JSON is valid.\n */\n async updateInputs() {\n const jsonElement = this.getElement(this.selectors.JSONINPUT);\n let data = null;\n try {\n data = metadata.jsonToState(jsonElement.value);\n notifyFieldValidationFailure(jsonElement, '');\n } catch (e) {\n notifyFieldValidationFailure(jsonElement, e.message);\n return;\n }\n jsonElement.value = metadata.jsonStringify(data, 4);\n await this.reactive.dispatch('updateFromJson', data);\n }\n\n /**\n * Add the current user as an author.\n */\n async makeAuthor() {\n const result = await this.update(false);\n if (result) {\n this.queueFocus('add', 'author', this.getElement(this.selectors.MAKEAUTHOR));\n await this.reactive.dispatch('addItem', 'author', 'user');\n }\n }\n\n /**\n * Return JSON to the current version on the edit form. This will be either the saved\n * version from the question or the update from a previous close and validate of the metadata modal.\n * If the JSON is valid, update the state so the inputs match. If invalid, setup as on initial failure\n * in metadata.js.\n */\n async revert() {\n const jsonElement = this.getElement(this.selectors.JSONINPUT);\n let previousdataJSON = document.querySelector(this.selectors.FORMJSON).value ?? null;\n let previousdata = null;\n try {\n previousdata = metadata.jsonToState(previousdataJSON);\n notifyFieldValidationFailure(jsonElement, '');\n } catch (e) {\n notifyFieldValidationFailure(jsonElement, e.message);\n jsonElement.value = previousdataJSON;\n metadata.lib.brokenMetadata = e.message;\n return;\n }\n jsonElement.value = metadata.jsonStringify(previousdata, 4);\n await this.reactive.dispatch('updateFromJson', previousdata);\n }\n\n /**\n * Update stored state when a collapsible section is toggled.\n *\n * @param {Event} event\n */\n setDetailsState(event) {\n const detail = event.currentTarget;\n this.detailsOpen[detail.id] = detail.open;\n }\n\n /**\n * Remember where focus should move after adding or deleting an item.\n *\n * @param {string} action\n * @param {string} category\n * @param {HTMLElement} target\n */\n queueFocus(action, category, target) {\n this.pendingFocus = null;\n const selectorName = (action + category).toUpperCase();\n const selector = this.selectors[selectorName];\n if (!selector) {\n return;\n }\n\n switch (action) {\n case 'add':\n this.pendingFocus = {selector, selection: 'last'};\n break;\n case 'delete':\n this.pendingFocus = {selector, fallbackSelector: selectorName === 'DELETEADDITIONAL' ?\n this.selectors.DELETESCOPE : null, selection: 'scope'};\n break;\n }\n if (selectorName === 'ADDPROPERTY' || selectorName === 'DELETEADDITIONAL') {\n const scopeCard = target?.closest?.(this.selectors.SCOPECARD);\n this.pendingFocus.scopeName = scopeCard?.querySelector?.(this.selectors.ADDSCOPE)?.value ?? null;\n }\n }\n\n /**\n * Restore focus after reactive re-rendering.\n */\n restorePendingFocus() {\n if (!this.pendingFocus) {\n return;\n }\n\n const pendingFocus = this.pendingFocus;\n this.pendingFocus = null;\n if (this.getElements(this.selectors.VALIDATIONERRORS).length) {\n return;\n }\n\n // Defer in the browser to deal with Moodle 4.2 issue.\n if (typeof window !== 'undefined' && window.setTimeout) {\n window.setTimeout(() => {\n this.focusPendingTarget(pendingFocus);\n }, 0);\n return;\n }\n\n // JEST test ends up here.\n this.focusPendingTarget(pendingFocus);\n }\n\n /**\n * Find and focus the element requested by a stored focus instruction.\n *\n * @param {object} pendingFocus\n */\n focusPendingTarget(pendingFocus) {\n let focusTarget = null;\n\n const scopeCard = pendingFocus.scopeName ? this.getScopeCard(pendingFocus.scopeName) : null;\n if (pendingFocus.selection === 'last') {\n const elements = Array.from(\n scopeCard?.querySelectorAll?.(pendingFocus.selector) || this.getElements(pendingFocus.selector)\n );\n focusTarget = elements[elements.length - 1] || null;\n } else if (pendingFocus.selection === 'scope') {\n focusTarget = scopeCard?.querySelector?.(pendingFocus.selector) ||\n this.getElement(pendingFocus.fallbackSelector || pendingFocus.selector);\n } else {\n focusTarget = this.getElements(pendingFocus.selector)[0];\n }\n\n if (focusTarget?.focus) {\n if (!focusTarget.matches?.(this.selectors.ADDBUTTONS)) {\n focusTarget.focus();\n } else {\n try {\n focusTarget.focus({focusVisible: true});\n } catch {\n focusTarget.focus();\n }\n }\n }\n if (focusTarget?.select) {\n focusTarget.select();\n }\n }\n\n /**\n * Get a scope card from its scope name.\n *\n * @param {string} scopeName\n * @returns {HTMLElement|null}\n */\n getScopeCard(scopeName) {\n return Array.from(this.getElements(this.selectors.SCOPECARD)).find((scopeCard) => {\n return scopeCard.querySelector?.(this.selectors.ADDSCOPE)?.value === scopeName;\n }) ?? null;\n }\n}\n"],"names":["BaseComponent","create","name","selectors","METADATACONTAINER","UPDATEJSON","UPDATEINPUTS","EDITTOGGLE","ADDITEM","DELETEITEM","MAKEAUTHOR","REVERT","FORMJSON","FORM","JSONINPUT","REQUIREDINPUTS","VALIDATIONERRORS","ALLINPUTS","DETAILS","ADDAUTHOR","ADDLANGUAGE","ADDPROPERTY","ADDSCOPE","ADDBUTTONS","SCOPECARD","DELETEADDITIONAL","DELETEAUTHOR","DELETELANGUAGE","DELETESCOPE","isEditing","detailsOpen","pendingFocus","container","this","target","element","document","querySelector","reactive","metadata","state","reloadContainerComponent","getWatchers","watch","handler","createDataElement","required","id","tag","value","wrapperid","iderror","details","getElements","detail","open","data","author","language","license","isPartOf","scope","freeform","isJsonOpen","options","JSON","parse","stringify","lib","licenses","selectedLicense","selectedOption","find","op","selected","push","text","tags","ajax","placeholder","noselectionstring","showsuggestions","casesensitive","forEach","lang","firstname","firstName","lastname","lastName","institution","year","scopeHolder","additional","property","qualifier","scopeId","current","firstProp","properties","input","isOpen","json","jsonStringify","attributes","metadataContainer","getElement","Error","renderComponent","detailSections","detailSection","addEventListener","setDetailsState","toggleEditing","async","selector","update","addButtons","addButton","addItem","deleteButtons","deleteButton","deleteItem","updateInputs","makeAuthor","revert","brokenMetadata","jsonElement","restorePendingFocus","event","currentTarget","checked","validateInputs","requiredElements","isError","classList","contains","freeformElement","trim","e","message","inputElements","Array","from","map","el","dispatch","addIds","split","parts","queueFocus","jsonToState","previousdataJSON","previousdata","action","category","selectorName","toUpperCase","selection","fallbackSelector","scopeCard","closest","_target$closest","scopeName","_scopeCard$querySelec2","_scopeCard$querySelec3","length","window","setTimeout","focusPendingTarget","focusTarget","getScopeCard","elements","querySelectorAll","_focusTarget","focus","matches","_focusTarget$matches","focusVisible","_focusTarget3","select"],"mappings":"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"}
\ No newline at end of file
diff --git a/amd/build/metadata/events.min.js b/amd/build/metadata/events.min.js
new file mode 100644
index 00000000000..8ec91e58ef1
--- /dev/null
+++ b/amd/build/metadata/events.min.js
@@ -0,0 +1,11 @@
+define("qtype_stack/metadata/events",["exports","core/event_dispatcher"],(function(_exports,_event_dispatcher){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.notifyQtypeStackStateUpdated=_exports.eventTypes=void 0;
+/**
+ * Javascript events for STACK metadata.
+ *
+ * @module qtype_stack/metadata
+ * @copyright 2025 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+const eventTypes={qtypeStackStateUpdated:"qtype_stack/stateUpdated"};_exports.eventTypes=eventTypes;_exports.notifyQtypeStackStateUpdated=(detail,container)=>(0,_event_dispatcher.dispatchEvent)(eventTypes.qtypeStackStateUpdated,detail,container)}));
+
+//# sourceMappingURL=events.min.js.map
\ No newline at end of file
diff --git a/amd/build/metadata/events.min.js.map b/amd/build/metadata/events.min.js.map
new file mode 100644
index 00000000000..16c72f7de9c
--- /dev/null
+++ b/amd/build/metadata/events.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"events.min.js","sources":["../../src/metadata/events.js"],"sourcesContent":["// This file is part of Stack - http://stack.maths.ed.ac.uk/\n//\n// Stack 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// Stack 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 Stack. If not, see .\n\nimport {dispatchEvent} from 'core/event_dispatcher';\n\n/**\n * Javascript events for STACK metadata.\n *\n * @module qtype_stack/metadata\n * @copyright 2025 University of Edinburgh\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.\n */\n\n/**\n * Events for STACK metadata\n *\n * @constant\n * @property {String} qtypeStackStateUpdated See {@link event:qtypeStackStateUpdated}\n */\nexport const eventTypes = {\n /**\n * Event triggered when the activity reactive state is updated.\n *\n * @event qtypeStackStateUpdated\n * @type {CustomEvent}\n * @property {Array} nodes The list of parent nodes which were updated\n */\n qtypeStackStateUpdated: 'qtype_stack/stateUpdated',\n};\n\n/**\n * Trigger an event to indicate that the activity state is updated.\n *\n * @method qtypeStackStateUpdated\n * @param {object} detail the full state\n * @param {HTMLElement} container the custom event target (document if none provided)\n * @returns {CustomEvent}\n * @fires qtypeStackStateUpdated\n */\nexport const notifyQtypeStackStateUpdated = (detail, container) => {\n return dispatchEvent(eventTypes.qtypeStackStateUpdated, detail, container);\n};"],"names":["eventTypes","qtypeStackStateUpdated","detail","container"],"mappings":";;;;;;;;MA+BaA,WAAa,CAQtBC,uBAAwB,iGAYgB,CAACC,OAAQC,aAC1C,mCAAcH,WAAWC,uBAAwBC,OAAQC"}
\ No newline at end of file
diff --git a/amd/build/metadata/metadata.min.js b/amd/build/metadata/metadata.min.js
new file mode 100644
index 00000000000..95cec59f5fa
--- /dev/null
+++ b/amd/build/metadata/metadata.min.js
@@ -0,0 +1,3 @@
+define("qtype_stack/metadata/metadata",["exports","core/reactive","qtype_stack/metadata/mutations","qtype_stack/metadata/events"],(function(_exports,_reactive,_mutations,_events){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.metadata=void 0;class StackMetadata extends _reactive.Reactive{constructor(){var obj,key,value;super(...arguments),value={languages:["en"],user:{firstname:"",lastname:"",institution:"",year:""},licenses:[{value:"unknown",text:"unknown"}],placeholder:""},(key="lib")in(obj=this)?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value}loadState(){var _metadata$value;let metadata=document.querySelector('input[name="metadata"]');const metadataJSON=null!==(_metadata$value=metadata.value)&&void 0!==_metadata$value?_metadata$value:null;try{this.lib=JSON.parse(metadata.dataset.lib),this.lib.user.year=(new Date).getFullYear();let languages=new Set(this.lib.languages);this.lib.languages=languages.difference(new Set([null,void 0,""])),this.lib.languages=Array.from(this.lib.languages)}catch(e){}try{metadata=this.jsonToState(metadataJSON)}catch(e){this.lib.brokenMetadata=e.message,metadata=this.jsonToState("{}")}metadata.metadataTicker={value:1},this.setInitialState(metadata)}replacer(key,value){const languages=[],additional={};switch(key){case"metadataTicker":case"id":return;case"language":for(const lang of value)languages.push(lang.value);return languages;case"license":case"isPartOf":return value.value;case"freeform":if(!value.value)return"{}";try{return value.value}catch(e){return}case"additional":for(const item of value){item.scope in additional==!1&&(additional[item.scope]={}),item.property in additional[item.scope]==!1&&item.qualifier&&(additional[item.scope][item.property]={});let currentValue=null;currentValue=""===item.qualifier?additional[item.scope][item.property]:additional[item.scope][item.property][item.qualifier];let value=null;value=currentValue?Array.isArray(currentValue)?currentValue.concat([item.value]):[currentValue,item.value]:item.value,""===item.qualifier?additional[item.scope][item.property]=value:additional[item.scope][item.property][item.qualifier]=value}return JSON.stringify(additional);default:return value}}jsonStringify(state,spacing){let output=JSON.stringify(state,this.replacer);return output=JSON.parse(output),output.freeform=JSON.parse(output.freeform),output.additional=JSON.parse(output.additional),output=JSON.stringify(output,null,spacing),output}reviver(key,value){const holder=[];let id=1;switch(key){case"author":for(const current of value)current.id=id,holder.push(current),id++;return holder;case"language":for(const lang of value)holder.push({id:id,value:lang}),id++;return holder;case"license":case"isPartOf":return{value:value};case"freeform":return{value:JSON.stringify(value)};default:return value}}jsonToState(data){data=JSON.parse(data);for(let property in data)data[property]=this.reviver(property,data[property]);const authorFields=["id","firstName","lastName","institution","year"],standardFields=["id","value"];(data=this.stripFields(data,["author","language","license","isPartOf","additional","freeform"])).author=Array.isArray(data.author)?data.author:[];const authorHolder=[];for(let author of data.author)author=this.tidyObject(author,authorFields),authorHolder.push(author);data.author=authorHolder,data.language=Array.isArray(data.language)?data.language:[];const langHolder=[];for(let lang of data.language)lang=this.tidyObject(lang,standardFields),langHolder.push(lang);data.language=langHolder,data.isPartOf=this.tidyObject(data.isPartOf,standardFields),data.license=this.tidyObject(data.license,standardFields),data.additional=data.additional&&"object"==typeof data.additional?data.additional:{};const addHolder=[];let addId=1;for(const addScope in data.additional)for(const addProperty in data.additional[addScope])if(data.additional[addScope][addProperty]&&"object"==typeof data.additional[addScope][addProperty]&&!Array.isArray(data.additional[addScope][addProperty]))for(const addQualifier in data.additional[addScope][addProperty]){let values=data.additional[addScope][addProperty][addQualifier];values=Array.isArray(values)?values:[values];for(const value of values){const add={id:addId,scope:addScope,property:addProperty,qualifier:addQualifier,value:value};addHolder.push(add),addId++}}else{let values=data.additional[addScope][addProperty];values=Array.isArray(values)?values:[values];for(const value of values){const add={id:addId,scope:addScope,property:addProperty,qualifier:"",value:value};addHolder.push(add),addId++}}return data.additional=addHolder,data.freeform=this.tidyObject(data.freeform,standardFields),data}stripFields(obj,fields){const result={};for(const suppliedField in obj)fields.includes(suppliedField)&&(result[suppliedField]=obj[suppliedField]);return result}addFields(obj,fields){for(const field of fields)Object.hasOwn(obj,field)?obj[field]=String(obj[field]):obj[field]="";return obj}tidyObject(obj,fields){return obj=obj&&"object"==typeof obj?obj:{},obj=this.stripFields(obj,fields),obj=this.addFields(obj,fields)}}const metadata=new StackMetadata({name:"qtype_stack_metadata",eventName:_events.eventTypes.qtypeStackStateUpdated,eventDispatch:_events.notifyQtypeStackStateUpdated,mutations:_mutations.mutations});_exports.metadata=metadata}));
+
+//# sourceMappingURL=metadata.min.js.map
\ No newline at end of file
diff --git a/amd/build/metadata/metadata.min.js.map b/amd/build/metadata/metadata.min.js.map
new file mode 100644
index 00000000000..336037aa448
--- /dev/null
+++ b/amd/build/metadata/metadata.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"metadata.min.js","sources":["../../src/metadata/metadata.js"],"sourcesContent":["// This file is part of Stack - http://stack.maths.ed.ac.uk/\n//\n// Stack 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// Stack 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 Stack. If not, see .\n\n/**\n * Metadata entry reactive component\n *\n * @module qtype_stack/metadata\n * @copyright 2025 University of Edinburgh\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.\n */\n\nimport {Reactive} from 'core/reactive';\nimport {mutations} from 'qtype_stack/metadata/mutations';\nimport {eventTypes, notifyQtypeStackStateUpdated} from 'qtype_stack/metadata/events';\n\nclass StackMetadata extends Reactive {\n // Default and config data passed through from Moodle.\n lib = {\n languages: ['en'],\n user: {\n firstname: '',\n lastname: '',\n institution: '',\n year: ''\n },\n licenses: [{value: 'unknown', text: 'unknown'}],\n placeholder: ''\n };\n\n /**\n * Load initial value of state from value on form.\n */\n loadState() {\n let metadata = document.querySelector('input[name=\"metadata\"]');\n const metadataJSON = metadata.value ?? null;\n try {\n this.lib = JSON.parse(metadata.dataset.lib);\n this.lib.user.year = new Date().getFullYear();\n // Weed out duplicates and falsy values.\n let languages = new Set(this.lib.languages);\n this.lib.languages = languages.difference(new Set([null, undefined, \"\"]));\n this.lib.languages = Array.from(this.lib.languages);\n } catch (e) {\n // Lib will be set to defaults.\n }\n try {\n metadata = this.jsonToState(metadataJSON);\n } catch (e) {\n // If the saved data is broken, show empty inputs and save error message for display in modal.\n this.lib.brokenMetadata = e.message;\n metadata = this.jsonToState('{}');\n }\n metadata.metadataTicker = {value: 1};\n this.setInitialState(metadata);\n }\n\n /**\n * Replacer function for JSON stringify of state.\n * Removed unwanted properties and converts some objects to plain values.\n *\n * @param {*} key\n * @param {*} value\n * @returns\n */\n replacer(key, value) {\n const languages = [];\n const additional = {};\n switch(key) {\n case 'metadataTicker':\n return undefined;\n case 'id':\n return undefined;\n case 'language':\n for (const lang of value) {\n languages.push(lang.value);\n }\n return languages;\n case 'license':\n case 'isPartOf':\n return value.value;\n case 'freeform':\n if (!value.value) {\n return '{}';\n }\n try {\n // If we parse the value here then the replacer\n // works on it recursively which gets messed up\n // if someone uses property values to match our own.\n return value.value;\n } catch(e) {\n return undefined;\n }\n case 'additional':\n for (const item of value) {\n if (item.scope in additional === false) {\n additional[item.scope] = {};\n }\n if (item.property in additional[item.scope] === false && item.qualifier) {\n additional[item.scope][item.property] = {};\n }\n let currentValue = null;\n if (item.qualifier === '') {\n currentValue = additional[item.scope][item.property];\n } else {\n currentValue = additional[item.scope][item.property][item.qualifier];\n }\n let value = null;\n // If we have multiple values, we need to convert to an array.\n if (!currentValue) {\n value = item.value;\n } else if (!Array.isArray(currentValue)) {\n value = [currentValue, item.value];\n } else {\n value = currentValue.concat([item.value]);\n }\n if (item.qualifier === '') {\n additional[item.scope][item.property] = value;\n } else {\n additional[item.scope][item.property][item.qualifier] = value;\n }\n }\n return JSON.stringify(additional);\n default:\n return value;\n }\n\n }\n\n /**\n * Convert state into a JSON string.\n * We can't simply stringify because of the freeform and additional properties.\n * We have to deal with them as strings before this to prevent the replacer\n * acting recursively.\n *\n * @param {*} state\n * @param {*} spacing\n * @returns\n */\n jsonStringify(state, spacing) {\n let output = JSON.stringify(state, this.replacer);\n output = JSON.parse(output);\n output.freeform = JSON.parse(output.freeform);\n output.additional = JSON.parse(output.additional);\n output = JSON.stringify(output, null, spacing);\n return output;\n }\n\n /**\n * Reviver function for JSON parsing to feed into state.\n * Adds id values and converts strings to obj.value.\n *\n * @param {*} key\n * @param {*} value\n * @returns\n */\n reviver(key, value) {\n const holder = [];\n let id = 1;\n switch(key) {\n case 'author':\n for (const current of value) {\n current.id = id;\n holder.push(current);\n id++;\n }\n return holder;\n case 'language':\n for (const lang of value) {\n holder.push({id: id, value: lang});\n id++;\n }\n return holder;\n case 'license':\n case 'isPartOf':\n return {value: value};\n case 'freeform':\n return {value: JSON.stringify(value)};\n default:\n return value;\n }\n }\n\n /**\n * Convert JSON to state format ready for updateFromJson mutation.\n * Strips out extraneous fields; adds in missing fields with blank values.\n *\n * @param {*} data\n * @returns\n */\n jsonToState(data) {\n data = JSON.parse(data);\n for (let property in data) {\n // We use the reviver once rather than recursively as it works from the bottom\n // up and that causes issues if someone has re-used one of our property names.\n data[property] = this.reviver(property, data[property]);\n }\n const fields = ['author', 'language', 'license', 'isPartOf', 'additional', 'freeform'];\n data = this.stripFields(data, fields);\n const authorFields = ['id', 'firstName', 'lastName', 'institution', 'year'];\n const standardFields = ['id', 'value'];\n\n data.author = (Array.isArray(data.author)) ? data.author : [];\n const authorHolder = [];\n for (let author of data.author) {\n author = this.tidyObject(author, authorFields);\n authorHolder.push(author);\n }\n data.author = authorHolder;\n data.language = (Array.isArray(data.language)) ? data.language : [];\n const langHolder = [];\n for (let lang of data.language) {\n lang = this.tidyObject(lang, standardFields);\n langHolder.push(lang);\n }\n data.language = langHolder;\n data.isPartOf = this.tidyObject(data.isPartOf, standardFields);\n data.license = this.tidyObject(data.license, standardFields);\n data.additional = (data.additional && typeof data.additional === 'object') ? data.additional : {};\n const addHolder = [];\n let addId = 1;\n for (const addScope in data.additional) {\n for (const addProperty in data.additional[addScope]) {\n if (\n data.additional[addScope][addProperty] &&\n typeof data.additional[addScope][addProperty] === 'object' &&\n !Array.isArray(data.additional[addScope][addProperty])\n ) {\n for (const addQualifier in data.additional[addScope][addProperty]) {\n let values = data.additional[addScope][addProperty][addQualifier];\n values = (Array.isArray(values)) ? values : [values];\n for (const value of values) {\n const add = {\n id: addId,\n scope: addScope,\n property: addProperty,\n qualifier: addQualifier,\n value: value\n };\n addHolder.push(add);\n addId++;\n }\n }\n } else {\n let values = data.additional[addScope][addProperty];\n values = (Array.isArray(values)) ? values : [values];\n for (const value of values) {\n const add = {\n id: addId,\n scope: addScope,\n property: addProperty,\n qualifier: '',\n value: value\n };\n addHolder.push(add);\n addId++;\n }\n }\n }\n }\n data.additional = addHolder;\n data.freeform = this.tidyObject(data.freeform, standardFields);\n\n return data;\n }\n\n /**\n * Remove any properties from an object that are not in a supplied array of property names.\n *\n * @param {object} obj\n * @param {array} fields\n * @returns {object}\n */\n stripFields(obj, fields) {\n const result = {};\n for (const suppliedField in obj) {\n if (fields.includes(suppliedField)) {\n result[suppliedField] = obj[suppliedField];\n }\n }\n return result;\n }\n\n /**\n * Add any missing properties to an object from a supplied array of field names and set to ''.\n *\n * @param {object} obj\n * @param {array} fields\n * @returns\n */\n addFields(obj, fields) {\n for (const field of fields) {\n if (!Object.hasOwn(obj, field)) {\n obj[field] = '';\n } else {\n obj[field] = String(obj[field]);\n }\n }\n return obj;\n }\n\n /**\n * Set properties of an object to those from a supplied array of field names.\n *\n * @param {object} obj\n * @param {array} fields\n * @returns\n */\n tidyObject(obj, fields) {\n obj = (obj && typeof obj === 'object') ? obj : {};\n obj = this.stripFields(obj, fields);\n obj = this.addFields(obj, fields);\n return obj;\n }\n}\n\n/**\n * The metadata state instance.\n */\nexport const metadata = new StackMetadata({\n name: 'qtype_stack_metadata',\n eventName: eventTypes.qtypeStackStateUpdated,\n eventDispatch: notifyQtypeStackStateUpdated,\n mutations,\n});\n"],"names":["StackMetadata","Reactive","languages","user","firstname","lastname","institution","year","licenses","value","text","placeholder","loadState","metadata","document","querySelector","metadataJSON","lib","JSON","parse","dataset","Date","getFullYear","Set","this","difference","undefined","Array","from","e","jsonToState","brokenMetadata","message","metadataTicker","setInitialState","replacer","key","additional","lang","push","item","scope","property","qualifier","currentValue","isArray","concat","stringify","jsonStringify","state","spacing","output","freeform","reviver","holder","id","current","data","authorFields","standardFields","stripFields","author","authorHolder","tidyObject","language","langHolder","isPartOf","license","addHolder","addId","addScope","addProperty","addQualifier","values","add","obj","fields","result","suppliedField","includes","addFields","field","Object","hasOwn","String","name","eventName","eventTypes","qtypeStackStateUpdated","eventDispatch","notifyQtypeStackStateUpdated","mutations"],"mappings":"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"}
\ No newline at end of file
diff --git a/amd/build/metadata/metadatacontent.min.js b/amd/build/metadata/metadatacontent.min.js
new file mode 100644
index 00000000000..e22cdaefce3
--- /dev/null
+++ b/amd/build/metadata/metadatacontent.min.js
@@ -0,0 +1,11 @@
+define("qtype_stack/metadata/metadatacontent",["exports","core/reactive","qtype_stack/metadata/metadata"],(function(_exports,_reactive,_metadata){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;
+/**
+ * Main STACK metadata component
+ *
+ * @module qtype_stack/metadata
+ * @copyright 2025 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+class _default extends _reactive.BaseComponent{create(){this.name="stack-metadata-content",this.selectors={METADATACONTAINER:"[data-for='qtype-stack-metadata']",SUBMIT:"#stack-metadata-update"}}static init(target,selectors){return new this({element:document.querySelector(target),reactive:_metadata.metadata,selectors:selectors})}}return _exports.default=_default,_exports.default}));
+
+//# sourceMappingURL=metadatacontent.min.js.map
\ No newline at end of file
diff --git a/amd/build/metadata/metadatacontent.min.js.map b/amd/build/metadata/metadatacontent.min.js.map
new file mode 100644
index 00000000000..9097b1acb22
--- /dev/null
+++ b/amd/build/metadata/metadatacontent.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"metadatacontent.min.js","sources":["../../src/metadata/metadatacontent.js"],"sourcesContent":["// This file is part of Stack - http://stack.maths.ed.ac.uk/\n//\n// Stack 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// Stack 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 Stack. If not, see .\n\n/**\n * Main STACK metadata component\n *\n * @module qtype_stack/metadata\n * @copyright 2025 University of Edinburgh\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.\n */\n\nimport {BaseComponent} from 'core/reactive';\nimport {metadata} from 'qtype_stack/metadata/metadata';\n\nexport default class extends BaseComponent {\n create() {\n this.name = 'stack-metadata-content';\n this.selectors = {\n METADATACONTAINER: `[data-for='qtype-stack-metadata']`,\n SUBMIT: `#stack-metadata-update`,\n };\n }\n\n /**\n * Static method to create a component instance form the mustache template.\n *\n * @param {string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @return {Component}\n */\n static init(target, selectors) {\n return new this({\n element: document.querySelector(target),\n reactive: metadata,\n selectors,\n });\n }\n}"],"names":["BaseComponent","create","name","selectors","METADATACONTAINER","SUBMIT","target","this","element","document","querySelector","reactive","metadata"],"mappings":";;;;;;;;uBA0B6BA,wBACzBC,cACSC,KAAO,8BACPC,UAAY,CACbC,sDACAC,6CAWIC,OAAQH,kBACT,IAAII,KAAK,CACZC,QAASC,SAASC,cAAcJ,QAChCK,SAAUC,mBACVT,UAAAA"}
\ No newline at end of file
diff --git a/amd/build/metadata/metadatamodal.min.js b/amd/build/metadata/metadatamodal.min.js
new file mode 100644
index 00000000000..bbd71bc38ff
--- /dev/null
+++ b/amd/build/metadata/metadatamodal.min.js
@@ -0,0 +1,3 @@
+define("qtype_stack/metadata/metadatamodal",["exports","core/modal","qtype_stack/metadata/metadata","qtype_stack/metadata/container"],(function(_exports,_modal,_metadata,_container){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.setup=_exports.MetadataModal=void 0,_modal=_interopRequireDefault(_modal),_container=_interopRequireDefault(_container);var _systemImportTransformerGlobalIdentifier="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}class MetadataModal extends _modal.default{async hide(){if(await _metadata.metadata.container.update(!0)){const current=document.querySelector('input[name="metadata"]'),newValue=_metadata.metadata.jsonStringify(_metadata.metadata.state,0);if(current.value!==newValue){document.querySelector('input[name="metadata"]').value=newValue;try{document.querySelector('[data-name="metadata_text"]').textContent=document.querySelector("#id_stack_metadata").getAttribute("data-change")}catch(e){}}super.hide()}}cancel(){_metadata.metadata.container.revert(),super.hide()}}_exports.MetadataModal=MetadataModal,_defineProperty(MetadataModal,"TYPE","qtype_stack/metadatamodal"),_defineProperty(MetadataModal,"TEMPLATE","qtype_stack/metadata/metadatamodal");let registered=!1;registered||(!async function(){if("function"!=typeof MetadataModal.create){(await("function"==typeof _systemImportTransformerGlobalIdentifier.define&&_systemImportTransformerGlobalIdentifier.define.amd?new Promise((function(resolve,reject){_systemImportTransformerGlobalIdentifier.require(["core/modal_registry"],resolve,reject)})):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&_systemImportTransformerGlobalIdentifier.require&&"component"===_systemImportTransformerGlobalIdentifier.require.loader?Promise.resolve(require("core/modal_registry")):Promise.resolve(_systemImportTransformerGlobalIdentifier["core/modal_registry"]))).register(MetadataModal.TYPE,MetadataModal,MetadataModal.TEMPLATE)}}(),registered=!0);let modal=null;function closeModal(){modal.cancel.call(modal)}async function openModal(){let addListener=!1;if(modal)_metadata.metadata.container&&(_metadata.metadata.container.isEditing=!1,document.querySelector(_metadata.metadata.container.selectors.EDITTOGGLE).checked=!1,await _metadata.metadata.container.reloadContainerComponent({state:_metadata.metadata.state})),modal.show();else{if("function"==typeof MetadataModal.create)modal=await MetadataModal.create(),modal.show();else{const ModalFactory=await("function"==typeof _systemImportTransformerGlobalIdentifier.define&&_systemImportTransformerGlobalIdentifier.define.amd?new Promise((function(resolve,reject){_systemImportTransformerGlobalIdentifier.require(["core/modal_factory"],resolve,reject)})):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&_systemImportTransformerGlobalIdentifier.require&&"component"===_systemImportTransformerGlobalIdentifier.require.loader?Promise.resolve(require("core/modal_factory")):Promise.resolve(_systemImportTransformerGlobalIdentifier["core/modal_factory"]));modal=await ModalFactory.create({type:MetadataModal.TYPE}),modal.show(),_container.default.init("#qtype-stack-metadata-main")}addListener=!0}addListener&&(document.querySelector("#stackmetadata_cancel").addEventListener("click",closeModal),document.querySelector("#stackmetadata_save").addEventListener("click",(()=>modal.hide())))}_exports.setup=()=>{var _document$querySelect;null===(_document$querySelect=document.querySelector("#id_metadatamodal"))||void 0===_document$querySelect||_document$querySelect.addEventListener("click",openModal),_metadata.metadata.loadState()}}));
+
+//# sourceMappingURL=metadatamodal.min.js.map
\ No newline at end of file
diff --git a/amd/build/metadata/metadatamodal.min.js.map b/amd/build/metadata/metadatamodal.min.js.map
new file mode 100644
index 00000000000..c8b18af5cfe
--- /dev/null
+++ b/amd/build/metadata/metadatamodal.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"metadatamodal.min.js","sources":["../../src/metadata/metadatamodal.js"],"sourcesContent":["// This file is part of Stack - http://stack.maths.ed.ac.uk/\n//\n// Stack 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// Stack 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 Stack. If not, see .\n\n/**\n * STACK metadata modal setup\n *\n * @module qtype_stack/metadata\n * @copyright 2025 University of Edinburgh\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.\n */\n\nimport Modal from 'core/modal';\nimport {metadata} from 'qtype_stack/metadata/metadata';\nimport container from 'qtype_stack/metadata/container';\n\nexport class MetadataModal extends Modal {\n static TYPE = \"qtype_stack/metadatamodal\";\n static TEMPLATE = \"qtype_stack/metadata/metadatamodal\";\n\n /**\n * Override the default hide function to validate and update metadata JSON.\n * On success, stores new JSON to hidden edit form field and closed modal.\n */\n async hide() {\n const result = await metadata.container.update(true);\n if (result) {\n const current = document.querySelector('input[name=\"metadata\"]');\n const newValue = metadata.jsonStringify(metadata.state, 0);\n if (current.value !== newValue) {\n document.querySelector('input[name=\"metadata\"]').value = newValue;\n try {\n document.querySelector('[data-name=\"metadata_text\"]').textContent =\n document.querySelector('#id_stack_metadata').getAttribute('data-change');\n } catch (e) {\n // Don't update in Moodle 4.2.\n }\n }\n super.hide();\n }\n }\n\n /**\n * Cancel button needs to close the modal without updating form.\n */\n cancel() {\n metadata.container.revert();\n super.hide();\n }\n}\n\nlet registered = false;\nif (!registered) {\n registerModal();\n registered = true;\n}\n\nlet modal = null;\n\n// Prepare for modal creation.\nexport const setup = () => {\n document.querySelector('#id_metadatamodal')?.addEventListener('click', openModal);\n metadata.loadState();\n};\n\n/**\n * Need to pass appropriate 'this' to cancel function.\n */\nfunction closeModal() {\n modal.cancel.call(modal);\n}\n\n/**\n * Register the modal in old versions of Moodle.\n */\nasync function registerModal() {\n if (typeof MetadataModal.create !== \"function\") {\n const ModalRegistry = await import('core/modal_registry');\n ModalRegistry.register(MetadataModal.TYPE, MetadataModal, MetadataModal.TEMPLATE);\n }\n}\n\n/**\n * Open the metadata modal.\n * Only create modal and add listener once.\n */\nasync function openModal() {\n let addListener = false;\n if (!modal) {\n if (typeof MetadataModal.create === \"function\") {\n modal = await MetadataModal.create();\n modal.show();\n } else {\n // Pre Moodle 4.3 code.\n const ModalFactory = await import ('core/modal_factory');\n modal = await ModalFactory.create({\n type: MetadataModal.TYPE,\n });\n modal.show();\n // Why is this necessary? Mustache should do this anyway. Moodle bug?\n container.init('#qtype-stack-metadata-main');\n }\n addListener = true;\n } else {\n if (metadata.container) {\n metadata.container.isEditing = false;\n document.querySelector(metadata.container.selectors.EDITTOGGLE).checked = false;\n await metadata.container.reloadContainerComponent({state: metadata.state});\n }\n modal.show();\n }\n if (addListener) {\n document.querySelector('#stackmetadata_cancel').addEventListener('click', closeModal);\n document.querySelector('#stackmetadata_save').addEventListener('click', () => modal.hide());\n }\n}\n"],"names":["MetadataModal","Modal","metadata","container","update","current","document","querySelector","newValue","jsonStringify","state","value","textContent","getAttribute","e","hide","cancel","revert","registered","create","register","TYPE","TEMPLATE","registerModal","modal","closeModal","call","openModal","addListener","isEditing","selectors","EDITTOGGLE","checked","reloadContainerComponent","show","ModalFactory","type","init","addEventListener","loadState"],"mappings":"6vBA2BaA,sBAAsBC,qCASNC,mBAASC,UAAUC,QAAO,GACnC,OACFC,QAAUC,SAASC,cAAc,0BACjCC,SAAWN,mBAASO,cAAcP,mBAASQ,MAAO,MACpDL,QAAQM,QAAUH,SAAU,CAC5BF,SAASC,cAAc,0BAA0BI,MAAQH,aAErDF,SAASC,cAAc,+BAA+BK,YAClDN,SAASC,cAAc,sBAAsBM,aAAa,eAChE,MAAOC,WAIPC,QAOdC,4BACab,UAAUc,eACbF,6DA/BDf,qBACK,6CADLA,yBAES,0CAiClBkB,YAAa,EACZA,kCAwBmC,mBAAzBlB,cAAcmB,OAAuB,onBAE9BC,SAASpB,cAAcqB,KAAMrB,cAAeA,cAAcsB,WAzB5EC,GACAL,YAAa,OAGbM,MAAQ,cAWHC,aACLD,MAAMR,OAAOU,KAAKF,sBAiBPG,gBACPC,aAAc,KACbJ,MAgBGtB,mBAASC,+BACAA,UAAU0B,WAAY,EAC/BvB,SAASC,cAAcL,mBAASC,UAAU2B,UAAUC,YAAYC,SAAU,QACpE9B,mBAASC,UAAU8B,yBAAyB,CAACvB,MAAOR,mBAASQ,SAEvEc,MAAMU,WArBE,IAC4B,mBAAzBlC,cAAcmB,OACrBK,YAAcxB,cAAcmB,SAC5BK,MAAMU,WACH,OAEGC,2nBACNX,YAAcW,aAAahB,OAAO,CAC9BiB,KAAMpC,cAAcqB,OAExBG,MAAMU,0BAEIG,KAAK,8BAEnBT,aAAc,EASdA,cACAtB,SAASC,cAAc,yBAAyB+B,iBAAiB,QAASb,YAC1EnB,SAASC,cAAc,uBAAuB+B,iBAAiB,SAAS,IAAMd,MAAMT,yBArDvE,6DAClBT,SAASC,cAAc,6EAAsB+B,iBAAiB,QAASX,8BAC9DY"}
\ No newline at end of file
diff --git a/amd/build/metadata/mutations.min.js b/amd/build/metadata/mutations.min.js
new file mode 100644
index 00000000000..c25c2fa23b2
--- /dev/null
+++ b/amd/build/metadata/mutations.min.js
@@ -0,0 +1,11 @@
+define("qtype_stack/metadata/mutations",["exports","qtype_stack/metadata/metadata"],(function(_exports,_metadata){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.mutations=void 0;const mutations=new
+/**
+ * Default mutation manager
+ *
+ * @module qtype_stack/metadata
+ * @copyright 2025 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+class{isBlankAuthor(author){return["firstName","lastName","institution"].every((field=>{var _author$field;return""===String(null!==(_author$field=author[field])&&void 0!==_author$field?_author$field:"").trim()}))}getBlankAuthor(authors){let blankAuthor=null;return authors.forEach((author=>{!blankAuthor&&this.isBlankAuthor(author)&&(blankAuthor=author)})),blankAuthor}async updateAll(stateManager,inputArray){let state=stateManager.state;const additionalCopy={};state.additional&&state.additional.forEach((addInfo=>{const rowCopy={scope:addInfo.scope,property:addInfo.property,qualifier:addInfo.qualifier,value:addInfo.value};additionalCopy[addInfo.id]=rowCopy}));for(const field of inputArray){const parts=field[0].split("_"),id=parts[1],property=parts[2],subproperty=parts[3];if("additional"===property)if("scope"===subproperty){const existingScope=additionalCopy[id].scope;if(existingScope!==field[1])for(const key in additionalCopy){const addInfo=additionalCopy[key];addInfo.scope===existingScope&&(addInfo.scope=field[1])}}else{const existing=additionalCopy[id];existing&&(existing[subproperty]=field[1])}}let problems=new Set;for(const key1 in additionalCopy){const addInfo=additionalCopy[key1];if(""===addInfo.qualifier)for(const key2 in additionalCopy){const addInfo2=additionalCopy[key2];addInfo.scope===addInfo2.scope&&addInfo.property===addInfo2.property&&""!==addInfo2.qualifier&&problems.add(key1)}}if(problems=Array.from(problems),problems.length){const output=problems.join(",");return Promise.reject(output)}stateManager.setReadOnly(!1);for(const field of inputArray){const parts=field[0].split("_"),id=parts[1],property=parts[2],subproperty=parts[3];if("scope"===subproperty){const existingScope=state.additional.get(id).scope;existingScope!==field[1]&&state.additional.forEach((addInfo=>{addInfo.scope===existingScope&&(addInfo.scope=field[1])}))}else if(0!=id){const existing=state[property].get(id);existing&&(existing[subproperty]=field[1])}else state[property][subproperty]=field[1]}return state.metadataTicker.value+=1,stateManager.setReadOnly(!0),Promise.resolve("Success")}deleteRow(stateManager,property,id){const state=stateManager.state;if(stateManager.setReadOnly(!1),"scope"===property){const matchingAddInfo=[],scope=state.additional.get(id).scope;state.additional.forEach((addInfo=>{addInfo.scope===scope&&matchingAddInfo.push(addInfo.id)}));for(const current of matchingAddInfo)state.additional.delete(current)}else state[property].delete(id);stateManager.setReadOnly(!0)}addItem(stateManager,category,id){const state=stateManager.state;let addCategory=category,newItem=null,existingProperty=null,blankAuthor=null;switch(category){case"language":newItem={value:""};break;case"author":if(newItem={firstName:"user"===id?_metadata.metadata.lib.user.firstname:"",lastName:"user"===id?_metadata.metadata.lib.user.lastname:"",institution:"user"===id?_metadata.metadata.lib.user.institution:"",year:String((new Date).getFullYear())},blankAuthor=this.getBlankAuthor(state.author),blankAuthor)return void("user"===id&&(stateManager.setReadOnly(!1),Object.assign(blankAuthor,newItem),stateManager.setReadOnly(!0)));break;case"scope":newItem={scope:"",property:"",qualifier:"",value:""},addCategory="additional";break;case"property":existingProperty=state.additional.get(id),newItem={scope:existingProperty.scope,property:"",qualifier:"",value:""},addCategory="additional"}const keys=Array.from(state[addCategory]);0===keys.length?newItem.id=1:(keys.sort(((a,b)=>b[0]-a[0])),newItem.id=1+parseInt(keys[0][0])),stateManager.setReadOnly(!1),state[addCategory].add(newItem),stateManager.setReadOnly(!0)}updateFromJson(stateManager,data){const state=stateManager.state;stateManager.setReadOnly(!1);for(const prop in data)state[prop]=data[prop];state.metadataTicker.value+=1,stateManager.setReadOnly(!0)}};_exports.mutations=mutations}));
+
+//# sourceMappingURL=mutations.min.js.map
\ No newline at end of file
diff --git a/amd/build/metadata/mutations.min.js.map b/amd/build/metadata/mutations.min.js.map
new file mode 100644
index 00000000000..037586e160f
--- /dev/null
+++ b/amd/build/metadata/mutations.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"mutations.min.js","sources":["../../src/metadata/mutations.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.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/**\n * Default mutation manager\n *\n * @module qtype_stack/metadata\n * @copyright 2025 University of Edinburgh\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.\n */\nimport {metadata} from 'qtype_stack/metadata/metadata';\n\nclass Mutations {\n /**\n * Check whether an author row is blank across the identity fields.\n *\n * @param {object} author\n * @returns {boolean}\n */\n isBlankAuthor(author) {\n return ['firstName', 'lastName', 'institution'].every((field) => String(author[field] ?? '').trim() === '');\n }\n\n /**\n * Find the first blank author row, if present.\n *\n * @param {*} authors\n * @returns {object|null}\n */\n getBlankAuthor(authors) {\n let blankAuthor = null;\n authors.forEach((author) => {\n if (!blankAuthor && this.isBlankAuthor(author)) {\n blankAuthor = author;\n }\n });\n return blankAuthor;\n }\n\n /**\n * Update state from array of input field information.\n *\n * Inputs have ids in form smdi-id-category-field e.g. smdi-1-author-year.\n * id is row entry id in state. 0 is used for single elements e.g. license.\n * Multi-elements begin counting from 1.\n * For scope, row id is for one of the matching additional info rows.\n * @param {*} stateManager\n * @param {*} inputArray [['smdi-1-author-year', 2025], ...]\n * @returns\n */\n async updateAll(stateManager, inputArray) {\n let state = stateManager.state;\n const additionalCopy = {};\n if (state.additional) {\n state.additional.forEach((addInfo) => {\n const rowCopy = {\n scope: addInfo.scope,\n property: addInfo.property,\n qualifier: addInfo.qualifier,\n value: addInfo.value\n };\n additionalCopy[addInfo.id] = rowCopy;\n });\n }\n for (const field of inputArray) {\n const parts = field[0].split('_');\n const id = parts[1];\n const property = parts[2];\n const subproperty = parts[3];\n if (property !== 'additional') {\n continue;\n }\n if (subproperty === 'scope') {\n // Scope input updates multiple rows.\n // We find all entries for that scope and update.\n const existingScope = additionalCopy[id].scope;\n if (existingScope !== field[1]) {\n for (const key in additionalCopy) {\n const addInfo = additionalCopy[key];\n if (addInfo.scope === existingScope) {\n addInfo.scope = field[1];\n }\n }\n }\n } else {\n const existing = additionalCopy[id];\n if (existing) {\n existing[subproperty] = field[1];\n }\n }\n }\n\n let problems = new Set();\n for (const key1 in additionalCopy) {\n const addInfo = additionalCopy[key1];\n if (addInfo.qualifier === '') {\n for (const key2 in additionalCopy) {\n const addInfo2 = additionalCopy[key2];\n if (\n addInfo.scope === addInfo2.scope && addInfo.property === addInfo2.property && addInfo2.qualifier !== ''\n ) {\n problems.add(key1);\n }\n }\n }\n }\n problems = Array.from(problems);\n if (problems.length) {\n const output = problems.join(',');\n return Promise.reject(output);\n }\n stateManager.setReadOnly(false);\n for (const field of inputArray) {\n const parts = field[0].split('_');\n const id = parts[1];\n const property = parts[2];\n const subproperty = parts[3];\n if (subproperty === 'scope') {\n // Scope input updates multiple rows.\n // We find all entries for that scope and update.\n const existingScope = state.additional.get(id).scope;\n if (existingScope !== field[1]) {\n state.additional.forEach((addInfo) => {\n if (addInfo.scope === existingScope) {\n addInfo.scope = field[1];\n }\n });\n }\n } else if (id != 0) {\n const existing = state[property].get(id);\n if (existing) {\n existing[subproperty] = field[1];\n }\n } else {\n state[property][subproperty] = field[1];\n }\n }\n\n // Force display refresh in odd circumstances where state has not changed\n // but JSON needs to be updated.\n state.metadataTicker.value += 1;\n stateManager.setReadOnly(true);\n return Promise.resolve('Success');\n }\n\n /**\n * Delete a row from the metadata form.\n *\n * @param {*} stateManager\n * @param {*} property type to be deleted\n * @param {*} id of instance to be deleted. Form will be refreshed and ids reset.\n */\n deleteRow(stateManager, property, id) {\n const state = stateManager.state;\n stateManager.setReadOnly(false);\n if (property === 'scope') {\n const matchingAddInfo = [];\n const scope = state.additional.get(id).scope;\n // Need to delete ALL entries with the same scope\n // as the supplied additional info.\n state.additional.forEach((addInfo) => {\n if (addInfo.scope === scope) {\n matchingAddInfo.push(addInfo.id);\n }\n });\n for (const current of matchingAddInfo) {\n state.additional.delete(current);\n }\n } else {\n state[property].delete(id);\n }\n stateManager.setReadOnly(true);\n }\n\n /**\n * Add a row\n *\n * @param {*} stateManager\n * @param {*} category\n * @param {*} id Only required for additional info. Allows us to get relevant scope.\n */\n addItem(stateManager, category, id) {\n const state = stateManager.state;\n let addCategory = category;\n let newItem = null;\n let existingProperty = null;\n let blankAuthor = null;\n switch (category) {\n case 'language':\n newItem = {\n value: \"\"\n };\n break;\n case 'author':\n newItem = {\n firstName: (id === 'user') ? metadata.lib.user.firstname : \"\",\n lastName: (id === 'user') ? metadata.lib.user.lastname : \"\",\n institution: (id === 'user') ? metadata.lib.user.institution : \"\",\n year: String(new Date().getFullYear())\n };\n // Don't add an author entry if we already have a blank one.\n blankAuthor = this.getBlankAuthor(state.author);\n if (blankAuthor) {\n if (id === 'user') {\n stateManager.setReadOnly(false);\n Object.assign(blankAuthor, newItem);\n stateManager.setReadOnly(true);\n }\n return;\n }\n break;\n case 'scope':\n newItem = {\n scope: '',\n property: '',\n qualifier: '',\n value: ''\n };\n addCategory = 'additional';\n break;\n case 'property':\n existingProperty = state.additional.get(id);\n newItem = {\n scope: existingProperty.scope,\n property: '',\n qualifier: '',\n value: ''\n };\n addCategory = 'additional';\n break;\n default:\n }\n\n // Ids are required for all objects. We add one to highest existing id.\n const keys = Array.from(state[addCategory]);\n if (keys.length === 0) {\n newItem.id = 1;\n } else {\n keys.sort((a, b) => b[0] - a[0]);\n newItem.id = 1 + parseInt(keys[0][0]);\n }\n stateManager.setReadOnly(false);\n state[addCategory].add(newItem);\n stateManager.setReadOnly(true);\n }\n\n /**\n * Straight update of state.\n *\n * @param {*} stateManager\n * @param {object} data Output of metadata.jsonToState\n */\n updateFromJson(stateManager, data) {\n const state = stateManager.state;\n stateManager.setReadOnly(false);\n for (const prop in data) {\n state[prop] = data[prop];\n }\n // Force display refresh in case inputs have been altered but JSON not changed.\n // Inputs will be matched to JSON.\n state.metadataTicker.value += 1;\n stateManager.setReadOnly(true);\n }\n}\n\nexport const mutations = new Mutations();\n"],"names":["mutations","isBlankAuthor","author","every","field","String","trim","getBlankAuthor","authors","blankAuthor","forEach","this","stateManager","inputArray","state","additionalCopy","additional","addInfo","rowCopy","scope","property","qualifier","value","id","parts","split","subproperty","existingScope","key","existing","problems","Set","key1","key2","addInfo2","add","Array","from","length","output","join","Promise","reject","setReadOnly","get","metadataTicker","resolve","deleteRow","matchingAddInfo","push","current","delete","addItem","category","addCategory","newItem","existingProperty","firstName","metadata","lib","user","firstname","lastName","lastname","institution","year","Date","getFullYear","Object","assign","keys","sort","a","b","parseInt","updateFromJson","data","prop"],"mappings":"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"}
\ No newline at end of file
diff --git a/amd/src/metadata/container.js b/amd/src/metadata/container.js
new file mode 100644
index 00000000000..4cb0aa7e6ef
--- /dev/null
+++ b/amd/src/metadata/container.js
@@ -0,0 +1,574 @@
+// This file is part of Stack - http://stack.maths.ed.ac.uk/
+//
+// Stack is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Stack is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Stack. If not, see .
+
+/**
+ * Main STACK metadata component
+ *
+ * @module qtype_stack/metadata
+ * @copyright 2025 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+
+import {BaseComponent} from 'core/reactive';
+import {metadata} from 'qtype_stack/metadata/metadata';
+import {notifyFieldValidationFailure} from 'core_form/events';
+
+export default class extends BaseComponent {
+ create() {
+ this.name = 'stack-metadata-container';
+ this.selectors = {
+ METADATACONTAINER: `[data-for='qtype-stack-metadata']`,
+ UPDATEJSON: `#stack-metadata-update`,
+ UPDATEINPUTS: `#stack-metadata-update-inputs`,
+ EDITTOGGLE: `#stack-metadata-edit-switch`,
+ ADDITEM: `[name="smd_add"]`,
+ DELETEITEM: `[name="smd_delete"]`,
+ MAKEAUTHOR: `#stack-metadata-make-author`,
+ REVERT: `#stack-metadata-revert`,
+ FORMJSON: 'input[name="metadata"]',
+ FORM: '#qtype-stack-metadata-content',
+ JSONINPUT: '#id_metadata_json',
+ REQUIREDINPUTS: '#qtype-stack-metadata-content input[aria-required="true"]',
+ VALIDATIONERRORS: '#qtype-stack-metadata-content .is-invalid',
+ ALLINPUTS: '#qtype-stack-metadata-content [id^="smdi"]',
+ DETAILS: '#qtype-stack-metadata-content details[id]',
+ ADDAUTHOR: '[id^="smdi_"][id$="_author_firstName"]',
+ ADDLANGUAGE: '[id^="smdi_"][id$="_language_value"]',
+ ADDPROPERTY: '[id^="smdi_"][id$="_additional_property"]',
+ ADDSCOPE: '[id^="smdi_"][id$="_additional_scope"]',
+ ADDBUTTONS: '[name="smd_add"], #stack-metadata-make-author',
+ SCOPECARD: '.smd-scope-card',
+ DELETEADDITIONAL: '[id^="smd_property_"][id$="_add"]',
+ DELETEAUTHOR: '#smd_author_0_add',
+ DELETELANGUAGE: '#smd_language_0_add',
+ DELETESCOPE: '#smd_scope_0_add',
+ };
+ this.isEditing = false;
+ this.detailsOpen = {};
+ this.pendingFocus = null;
+ metadata.container = this;
+ }
+
+ /**
+ * Static method to create a component instance form the mustache template.
+ *
+ * @param {string} target the DOM main element or its ID
+ * @param {object} selectors optional css selector overrides
+ * @return {Component}
+ */
+ static init(target, selectors) {
+ return new this({
+ element: document.querySelector(target),
+ reactive: metadata,
+ selectors,
+ });
+ }
+
+ /**
+ * Initial state ready method.
+ *
+ * @param {object} state the initial state
+ */
+ async stateReady(state) {
+ await this.reloadContainerComponent({state});
+ }
+
+ /**
+ * Set to refresh display on any state change.
+ *
+ * @returns {object} watchers
+ */
+ getWatchers() {
+ return [
+ {watch: `state:updated`, handler: this.reloadContainerComponent},
+ ];
+ }
+
+ /**
+ * Converts field information into element suitable for feeding into Mustache templates.
+ *
+ * @param {bool} required
+ * @param {mixed} id to link to state
+ * @param {string} tag type of field
+ * @param {mixed} value of element
+ * @returns {object}
+ */
+ createDataElement(required, id, tag, value) {
+ return {
+ required: required,
+ element: {
+ value: value,
+ wrapperid: 'fitem_smdi_' + id + '_' + tag,
+ id: 'smdi_' + id + '_' + tag,
+ name: 'smdi_' + id + '_' + tag,
+ iderror: 'smde_' + id + '_' + tag + '_error'
+ }
+ };
+ }
+
+ async reloadContainerComponent({state}) {
+ const details = this.getElements(this.selectors.DETAILS);
+ for (const detail of details) {
+ this.detailsOpen[detail.id] = detail.open;
+ }
+
+ // Mustache data is not fully compatible with state object so we need to convert it
+ // into a plain object.
+ const data = {
+ author: [],
+ language: [],
+ license: this.createDataElement(true, 0, 'license_value', state.license.value),
+ isPartOf: this.createDataElement(false, 0, 'isPartOf_value', state.isPartOf.value),
+ scope: [],
+ freeform: this.createDataElement(false, 0, 'freeform_value', state.freeform.value || '{}'),
+ isEditing: this.isEditing,
+ isJsonOpen: this.detailsOpen['qtype-stack-metadata-json-section'] ?? false,
+ };
+
+ // Need to copy licenses list as we modify to mark as selected.
+ data.license.element.options = JSON.parse(JSON.stringify(metadata.lib.licenses));
+ const selectedLicense = state.license.value;
+ let selectedOption = data.license.element.options.find((op) => op.value === selectedLicense);
+ if (selectedOption) {
+ selectedOption.selected = true;
+ } else {
+ data.license.element.options.push({value: state.license.value, text: state.license.value, selected: true});
+ }
+ data.license.element.tags = '[]';
+ data.license.element.ajax = '';
+ data.license.element.placeholder = metadata.lib.placeholder;
+ data.license.element.noselectionstring = '';
+ data.license.element.showsuggestions = 'true';
+ data.license.element.casesensitive = 'false';
+
+ state.language.forEach(language => {
+ const element = { id: language.id, lang: this.createDataElement(true, language.id, 'language_value', language.value) };
+ data.language.push({...element});
+ });
+
+ state.author.forEach(author => {
+ const element = {
+ firstname: this.createDataElement(false, author.id, 'author_firstName', author.firstName),
+ lastname: this.createDataElement(false, author.id, 'author_lastName', author.lastName),
+ institution: this.createDataElement(false, author.id, 'author_institution', author.institution),
+ year: this.createDataElement(false, author.id, 'author_year', author.year),
+ id: author.id,
+ };
+ data.author.push({...element});
+ });
+
+ const scopeHolder = {};
+ // Rearrange additional metadata by scope.
+ state.additional.forEach(additional => {
+ const element = {
+ property: this.createDataElement(true, additional.id, 'additional_property', additional.property),
+ qualifier: this.createDataElement(false, additional.id, 'additional_qualifier', additional.qualifier),
+ value: this.createDataElement(false, additional.id, 'additional_value', additional.value),
+ id: additional.id,
+ };
+ if (!scopeHolder[additional.scope]) {
+ scopeHolder[additional.scope] = [];
+ }
+ scopeHolder[additional.scope].push(element);
+ });
+ for (const scope in scopeHolder) {
+ const scopeId = 'qtype-stack-metadata-scope-' + scopeHolder[scope][0].id;
+ const current = {
+ id: scopeId,
+ name: scope,
+ firstProp: scopeHolder[scope][0].id,
+ properties: scopeHolder[scope],
+ input: this.createDataElement(true, scopeHolder[scope][0].id, 'additional_scope', scope),
+ isOpen: this.detailsOpen[scopeId] ?? true,
+ };
+ data.scope.push(current);
+ }
+
+ data.json = {
+ required: true,
+ element: {
+ value: metadata.jsonStringify(state, 4),
+ attributes: 'rows="10"',
+ wrapperid: 'fitem_metadata_json',
+ id: 'id_metadata_json',
+ name: 'metadata_json',
+ }
+ };
+
+ // To render a child component we need a container.
+ const metadataContainer = this.getElement(this.selectors.METADATACONTAINER);
+ if (!metadataContainer) {
+ throw new Error('Missing metadata container.');
+ }
+
+ await this.renderComponent(metadataContainer, 'qtype_stack/metadata/metadatacontent', data);
+
+ // Add all the event listeners as all elements have been destroyed and rebuilt.
+ const detailSections = this.getElements(this.selectors.DETAILS);
+ for (const detailSection of detailSections) {
+ this.addEventListener(
+ detailSection,
+ 'toggle',
+ this.setDetailsState
+ );
+ }
+ this.addEventListener(
+ document.querySelector(this.selectors.EDITTOGGLE),
+ 'change',
+ this.toggleEditing
+ );
+ this.addEventListener(
+ this.getElement(this.selectors.UPDATEJSON),
+ 'click',
+ async() => {
+ this.pendingFocus = {selector: this.selectors.UPDATEJSON};
+ await this.update();
+ }
+ );
+ const addButtons = this.getElements(this.selectors.ADDITEM);
+ for (const addButton of addButtons) {
+ this.addEventListener(
+ addButton,
+ 'click',
+ this.addItem
+ );
+ }
+ const deleteButtons = this.getElements(this.selectors.DELETEITEM);
+ for (const deleteButton of deleteButtons) {
+ this.addEventListener(
+ deleteButton,
+ 'click',
+ this.deleteItem
+ );
+ }
+ this.addEventListener(
+ this.getElement(this.selectors.UPDATEINPUTS),
+ 'click',
+ async() => {
+ this.pendingFocus = {selector: this.selectors.UPDATEINPUTS};
+ await this.updateInputs();
+ }
+ );
+ this.addEventListener(
+ this.getElement(this.selectors.MAKEAUTHOR),
+ 'click',
+ this.makeAuthor
+ );
+ this.addEventListener(
+ this.getElement(this.selectors.REVERT),
+ 'click',
+ async() => {
+ this.pendingFocus = {selector: this.selectors.REVERT};
+ await this.revert();
+ }
+ );
+
+ // Deal with case of brkon JSON in saved question. The errormessage is saved on initial setup.
+ // We load in the original un-prettified JSON and display error message, giving user chance to edit.
+ // After this, though, they'll need to sort it out - if we're back here again then we'll use
+ // JSON created from current content of state.
+ if (metadata.lib.brokenMetadata) {
+ const jsonElement = this.getElement(this.selectors.JSONINPUT);
+ jsonElement.value = document.querySelector(this.selectors.FORMJSON).value ?? '';
+ notifyFieldValidationFailure(jsonElement, metadata.lib.brokenMetadata);
+ delete metadata.lib.brokenMetadata;
+ }
+
+ this.restorePendingFocus();
+ }
+
+ /**
+ * Toggle edit mode. Turning edit mode off validates and updates the state first.
+ *
+ * @param {Event} event
+ */
+ async toggleEditing(event) {
+ if (event.currentTarget.checked) {
+ this.isEditing = true;
+ await this.reloadContainerComponent({state: this.reactive.state});
+ return;
+ }
+ if (!this.validateInputs()) {
+ event.currentTarget.checked = true;
+ return;
+ }
+ this.isEditing = false;
+ const result = await this.update(false);
+ if (!result) {
+ this.isEditing = true;
+ event.currentTarget.checked = true;
+ await this.reloadContainerComponent({state: this.reactive.state});
+ }
+ }
+
+ /**
+ * Validate visible metadata inputs without dispatching state changes.
+ *
+ * @returns {bool} Returns false on validation error.
+ */
+ validateInputs() {
+ const requiredElements = this.getElements(this.selectors.REQUIREDINPUTS);
+ let isError = false;
+ for (const element of requiredElements) {
+ if (element.value === '') {
+ isError = true;
+ notifyFieldValidationFailure(element, 'Required');
+ } else if (element.classList.contains('is-invalid')) {
+ // Reset warning as field no longer empty.
+ notifyFieldValidationFailure(element, '');
+ }
+ }
+ // Validate freeform JSON if non-empty.
+ const freeformElement = this.getElement('#smdi_0_freeform_value');
+ if (freeformElement && freeformElement.value.trim() !== '') {
+ try {
+ JSON.parse(freeformElement.value);
+ notifyFieldValidationFailure(freeformElement, '');
+ } catch(e) {
+ notifyFieldValidationFailure(freeformElement, e.message);
+ isError = true;
+ }
+ }
+ return !isError;
+ }
+
+ /**
+ * Updates state based on contents of inputs.
+ *
+ * @param {bool} mustValidate Do we want validation to occur?
+ * We check when explicitly asked for and when attempting to close the modal other than by cancel.
+ * We don't check when e.g. adding an author. This means state can be invalid but we only
+ * update the edit form entry after successful validation on modal close.
+ * @returns {bool} Returns false on validation error.
+ */
+ async update(mustValidate = true) {
+ if (mustValidate && !this.validateInputs()) {
+ return false;
+ }
+ // Elements have ids in form smdi_id_category_field e.g. smdi_1_author_year.
+ // id is category entry id in state. 0 is used for single elements e.g. license.
+ // Multi-elements begin counting from 1.
+ let inputElements = this.getElements(this.selectors.ALLINPUTS);
+ inputElements = Array.from(inputElements).map((el) => [el.id, el.value]);
+ try {
+ await this.reactive.dispatch('updateAll', inputElements);
+ } catch (e) {
+ const addIds = e.split(',');
+ for (const id of addIds) {
+ const element = this.getElement('#qtype-stack-metadata-content [id="smdi_' + id + '_additional_qualifier"]');
+ notifyFieldValidationFailure(element, 'Required');
+ }
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Add a new row to modal form.
+ * We have to update state from the input fields first or any changes will
+ * be wiped when we refresh the display to show the new row.
+ *
+ * @param {*} event
+ */
+ async addItem(event) {
+ const result = await this.update(true);
+ if (result) {
+ const parts = event.currentTarget.id.split('_');
+ this.queueFocus('add', parts[1], event.currentTarget);
+ await this.reactive.dispatch('addItem', parts[1], parts[2]);
+ }
+ }
+ /**
+ * Delete a row from modal form.
+ * We have to update state from the input fields first or any changes will
+ * be wiped when we refresh the display toremove the row
+ *
+ * @param {*} event
+ */
+ async deleteItem(event) {
+ const result = await this.update(false);
+ if (result) {
+ const parts = event.currentTarget.id.split('_');
+ this.queueFocus('delete', parts[1], event.currentTarget);
+ await this.reactive.dispatch('deleteRow', parts[1], parts[2]);
+ }
+ }
+
+ /**
+ * Update state from the currently entered JSON if JSON is valid.
+ */
+ async updateInputs() {
+ const jsonElement = this.getElement(this.selectors.JSONINPUT);
+ let data = null;
+ try {
+ data = metadata.jsonToState(jsonElement.value);
+ notifyFieldValidationFailure(jsonElement, '');
+ } catch (e) {
+ notifyFieldValidationFailure(jsonElement, e.message);
+ return;
+ }
+ jsonElement.value = metadata.jsonStringify(data, 4);
+ await this.reactive.dispatch('updateFromJson', data);
+ }
+
+ /**
+ * Add the current user as an author.
+ */
+ async makeAuthor() {
+ const result = await this.update(false);
+ if (result) {
+ this.queueFocus('add', 'author', this.getElement(this.selectors.MAKEAUTHOR));
+ await this.reactive.dispatch('addItem', 'author', 'user');
+ }
+ }
+
+ /**
+ * Return JSON to the current version on the edit form. This will be either the saved
+ * version from the question or the update from a previous close and validate of the metadata modal.
+ * If the JSON is valid, update the state so the inputs match. If invalid, setup as on initial failure
+ * in metadata.js.
+ */
+ async revert() {
+ const jsonElement = this.getElement(this.selectors.JSONINPUT);
+ let previousdataJSON = document.querySelector(this.selectors.FORMJSON).value ?? null;
+ let previousdata = null;
+ try {
+ previousdata = metadata.jsonToState(previousdataJSON);
+ notifyFieldValidationFailure(jsonElement, '');
+ } catch (e) {
+ notifyFieldValidationFailure(jsonElement, e.message);
+ jsonElement.value = previousdataJSON;
+ metadata.lib.brokenMetadata = e.message;
+ return;
+ }
+ jsonElement.value = metadata.jsonStringify(previousdata, 4);
+ await this.reactive.dispatch('updateFromJson', previousdata);
+ }
+
+ /**
+ * Update stored state when a collapsible section is toggled.
+ *
+ * @param {Event} event
+ */
+ setDetailsState(event) {
+ const detail = event.currentTarget;
+ this.detailsOpen[detail.id] = detail.open;
+ }
+
+ /**
+ * Remember where focus should move after adding or deleting an item.
+ *
+ * @param {string} action
+ * @param {string} category
+ * @param {HTMLElement} target
+ */
+ queueFocus(action, category, target) {
+ this.pendingFocus = null;
+ const selectorName = (action + category).toUpperCase();
+ const selector = this.selectors[selectorName];
+ if (!selector) {
+ return;
+ }
+
+ switch (action) {
+ case 'add':
+ this.pendingFocus = {selector, selection: 'last'};
+ break;
+ case 'delete':
+ this.pendingFocus = {selector, fallbackSelector: selectorName === 'DELETEADDITIONAL' ?
+ this.selectors.DELETESCOPE : null, selection: 'scope'};
+ break;
+ }
+ if (selectorName === 'ADDPROPERTY' || selectorName === 'DELETEADDITIONAL') {
+ const scopeCard = target?.closest?.(this.selectors.SCOPECARD);
+ this.pendingFocus.scopeName = scopeCard?.querySelector?.(this.selectors.ADDSCOPE)?.value ?? null;
+ }
+ }
+
+ /**
+ * Restore focus after reactive re-rendering.
+ */
+ restorePendingFocus() {
+ if (!this.pendingFocus) {
+ return;
+ }
+
+ const pendingFocus = this.pendingFocus;
+ this.pendingFocus = null;
+ if (this.getElements(this.selectors.VALIDATIONERRORS).length) {
+ return;
+ }
+
+ // Defer in the browser to deal with Moodle 4.2 issue.
+ if (typeof window !== 'undefined' && window.setTimeout) {
+ window.setTimeout(() => {
+ this.focusPendingTarget(pendingFocus);
+ }, 0);
+ return;
+ }
+
+ // JEST test ends up here.
+ this.focusPendingTarget(pendingFocus);
+ }
+
+ /**
+ * Find and focus the element requested by a stored focus instruction.
+ *
+ * @param {object} pendingFocus
+ */
+ focusPendingTarget(pendingFocus) {
+ let focusTarget = null;
+
+ const scopeCard = pendingFocus.scopeName ? this.getScopeCard(pendingFocus.scopeName) : null;
+ if (pendingFocus.selection === 'last') {
+ const elements = Array.from(
+ scopeCard?.querySelectorAll?.(pendingFocus.selector) || this.getElements(pendingFocus.selector)
+ );
+ focusTarget = elements[elements.length - 1] || null;
+ } else if (pendingFocus.selection === 'scope') {
+ focusTarget = scopeCard?.querySelector?.(pendingFocus.selector) ||
+ this.getElement(pendingFocus.fallbackSelector || pendingFocus.selector);
+ } else {
+ focusTarget = this.getElements(pendingFocus.selector)[0];
+ }
+
+ if (focusTarget?.focus) {
+ if (!focusTarget.matches?.(this.selectors.ADDBUTTONS)) {
+ focusTarget.focus();
+ } else {
+ try {
+ focusTarget.focus({focusVisible: true});
+ } catch {
+ focusTarget.focus();
+ }
+ }
+ }
+ if (focusTarget?.select) {
+ focusTarget.select();
+ }
+ }
+
+ /**
+ * Get a scope card from its scope name.
+ *
+ * @param {string} scopeName
+ * @returns {HTMLElement|null}
+ */
+ getScopeCard(scopeName) {
+ return Array.from(this.getElements(this.selectors.SCOPECARD)).find((scopeCard) => {
+ return scopeCard.querySelector?.(this.selectors.ADDSCOPE)?.value === scopeName;
+ }) ?? null;
+ }
+}
diff --git a/amd/src/metadata/events.js b/amd/src/metadata/events.js
new file mode 100644
index 00000000000..3c238381861
--- /dev/null
+++ b/amd/src/metadata/events.js
@@ -0,0 +1,54 @@
+// This file is part of Stack - http://stack.maths.ed.ac.uk/
+//
+// Stack is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Stack is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Stack. If not, see .
+
+import {dispatchEvent} from 'core/event_dispatcher';
+
+/**
+ * Javascript events for STACK metadata.
+ *
+ * @module qtype_stack/metadata
+ * @copyright 2025 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+
+/**
+ * Events for STACK metadata
+ *
+ * @constant
+ * @property {String} qtypeStackStateUpdated See {@link event:qtypeStackStateUpdated}
+ */
+export const eventTypes = {
+ /**
+ * Event triggered when the activity reactive state is updated.
+ *
+ * @event qtypeStackStateUpdated
+ * @type {CustomEvent}
+ * @property {Array} nodes The list of parent nodes which were updated
+ */
+ qtypeStackStateUpdated: 'qtype_stack/stateUpdated',
+};
+
+/**
+ * Trigger an event to indicate that the activity state is updated.
+ *
+ * @method qtypeStackStateUpdated
+ * @param {object} detail the full state
+ * @param {HTMLElement} container the custom event target (document if none provided)
+ * @returns {CustomEvent}
+ * @fires qtypeStackStateUpdated
+ */
+export const notifyQtypeStackStateUpdated = (detail, container) => {
+ return dispatchEvent(eventTypes.qtypeStackStateUpdated, detail, container);
+};
\ No newline at end of file
diff --git a/amd/src/metadata/metadata.js b/amd/src/metadata/metadata.js
new file mode 100644
index 00000000000..f6cf6aa7ec7
--- /dev/null
+++ b/amd/src/metadata/metadata.js
@@ -0,0 +1,336 @@
+// This file is part of Stack - http://stack.maths.ed.ac.uk/
+//
+// Stack is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Stack is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Stack. If not, see .
+
+/**
+ * Metadata entry reactive component
+ *
+ * @module qtype_stack/metadata
+ * @copyright 2025 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+
+import {Reactive} from 'core/reactive';
+import {mutations} from 'qtype_stack/metadata/mutations';
+import {eventTypes, notifyQtypeStackStateUpdated} from 'qtype_stack/metadata/events';
+
+class StackMetadata extends Reactive {
+ // Default and config data passed through from Moodle.
+ lib = {
+ languages: ['en'],
+ user: {
+ firstname: '',
+ lastname: '',
+ institution: '',
+ year: ''
+ },
+ licenses: [{value: 'unknown', text: 'unknown'}],
+ placeholder: ''
+ };
+
+ /**
+ * Load initial value of state from value on form.
+ */
+ loadState() {
+ let metadata = document.querySelector('input[name="metadata"]');
+ const metadataJSON = metadata.value ?? null;
+ try {
+ this.lib = JSON.parse(metadata.dataset.lib);
+ this.lib.user.year = new Date().getFullYear();
+ // Weed out duplicates and falsy values.
+ let languages = new Set(this.lib.languages);
+ this.lib.languages = languages.difference(new Set([null, undefined, ""]));
+ this.lib.languages = Array.from(this.lib.languages);
+ } catch (e) {
+ // Lib will be set to defaults.
+ }
+ try {
+ metadata = this.jsonToState(metadataJSON);
+ } catch (e) {
+ // If the saved data is broken, show empty inputs and save error message for display in modal.
+ this.lib.brokenMetadata = e.message;
+ metadata = this.jsonToState('{}');
+ }
+ metadata.metadataTicker = {value: 1};
+ this.setInitialState(metadata);
+ }
+
+ /**
+ * Replacer function for JSON stringify of state.
+ * Removed unwanted properties and converts some objects to plain values.
+ *
+ * @param {*} key
+ * @param {*} value
+ * @returns
+ */
+ replacer(key, value) {
+ const languages = [];
+ const additional = {};
+ switch(key) {
+ case 'metadataTicker':
+ return undefined;
+ case 'id':
+ return undefined;
+ case 'language':
+ for (const lang of value) {
+ languages.push(lang.value);
+ }
+ return languages;
+ case 'license':
+ case 'isPartOf':
+ return value.value;
+ case 'freeform':
+ if (!value.value) {
+ return '{}';
+ }
+ try {
+ // If we parse the value here then the replacer
+ // works on it recursively which gets messed up
+ // if someone uses property values to match our own.
+ return value.value;
+ } catch(e) {
+ return undefined;
+ }
+ case 'additional':
+ for (const item of value) {
+ if (item.scope in additional === false) {
+ additional[item.scope] = {};
+ }
+ if (item.property in additional[item.scope] === false && item.qualifier) {
+ additional[item.scope][item.property] = {};
+ }
+ let currentValue = null;
+ if (item.qualifier === '') {
+ currentValue = additional[item.scope][item.property];
+ } else {
+ currentValue = additional[item.scope][item.property][item.qualifier];
+ }
+ let value = null;
+ // If we have multiple values, we need to convert to an array.
+ if (!currentValue) {
+ value = item.value;
+ } else if (!Array.isArray(currentValue)) {
+ value = [currentValue, item.value];
+ } else {
+ value = currentValue.concat([item.value]);
+ }
+ if (item.qualifier === '') {
+ additional[item.scope][item.property] = value;
+ } else {
+ additional[item.scope][item.property][item.qualifier] = value;
+ }
+ }
+ return JSON.stringify(additional);
+ default:
+ return value;
+ }
+
+ }
+
+ /**
+ * Convert state into a JSON string.
+ * We can't simply stringify because of the freeform and additional properties.
+ * We have to deal with them as strings before this to prevent the replacer
+ * acting recursively.
+ *
+ * @param {*} state
+ * @param {*} spacing
+ * @returns
+ */
+ jsonStringify(state, spacing) {
+ let output = JSON.stringify(state, this.replacer);
+ output = JSON.parse(output);
+ output.freeform = JSON.parse(output.freeform);
+ output.additional = JSON.parse(output.additional);
+ output = JSON.stringify(output, null, spacing);
+ return output;
+ }
+
+ /**
+ * Reviver function for JSON parsing to feed into state.
+ * Adds id values and converts strings to obj.value.
+ *
+ * @param {*} key
+ * @param {*} value
+ * @returns
+ */
+ reviver(key, value) {
+ const holder = [];
+ let id = 1;
+ switch(key) {
+ case 'author':
+ for (const current of value) {
+ current.id = id;
+ holder.push(current);
+ id++;
+ }
+ return holder;
+ case 'language':
+ for (const lang of value) {
+ holder.push({id: id, value: lang});
+ id++;
+ }
+ return holder;
+ case 'license':
+ case 'isPartOf':
+ return {value: value};
+ case 'freeform':
+ return {value: JSON.stringify(value)};
+ default:
+ return value;
+ }
+ }
+
+ /**
+ * Convert JSON to state format ready for updateFromJson mutation.
+ * Strips out extraneous fields; adds in missing fields with blank values.
+ *
+ * @param {*} data
+ * @returns
+ */
+ jsonToState(data) {
+ data = JSON.parse(data);
+ for (let property in data) {
+ // We use the reviver once rather than recursively as it works from the bottom
+ // up and that causes issues if someone has re-used one of our property names.
+ data[property] = this.reviver(property, data[property]);
+ }
+ const fields = ['author', 'language', 'license', 'isPartOf', 'additional', 'freeform'];
+ data = this.stripFields(data, fields);
+ const authorFields = ['id', 'firstName', 'lastName', 'institution', 'year'];
+ const standardFields = ['id', 'value'];
+
+ data.author = (Array.isArray(data.author)) ? data.author : [];
+ const authorHolder = [];
+ for (let author of data.author) {
+ author = this.tidyObject(author, authorFields);
+ authorHolder.push(author);
+ }
+ data.author = authorHolder;
+ data.language = (Array.isArray(data.language)) ? data.language : [];
+ const langHolder = [];
+ for (let lang of data.language) {
+ lang = this.tidyObject(lang, standardFields);
+ langHolder.push(lang);
+ }
+ data.language = langHolder;
+ data.isPartOf = this.tidyObject(data.isPartOf, standardFields);
+ data.license = this.tidyObject(data.license, standardFields);
+ data.additional = (data.additional && typeof data.additional === 'object') ? data.additional : {};
+ const addHolder = [];
+ let addId = 1;
+ for (const addScope in data.additional) {
+ for (const addProperty in data.additional[addScope]) {
+ if (
+ data.additional[addScope][addProperty] &&
+ typeof data.additional[addScope][addProperty] === 'object' &&
+ !Array.isArray(data.additional[addScope][addProperty])
+ ) {
+ for (const addQualifier in data.additional[addScope][addProperty]) {
+ let values = data.additional[addScope][addProperty][addQualifier];
+ values = (Array.isArray(values)) ? values : [values];
+ for (const value of values) {
+ const add = {
+ id: addId,
+ scope: addScope,
+ property: addProperty,
+ qualifier: addQualifier,
+ value: value
+ };
+ addHolder.push(add);
+ addId++;
+ }
+ }
+ } else {
+ let values = data.additional[addScope][addProperty];
+ values = (Array.isArray(values)) ? values : [values];
+ for (const value of values) {
+ const add = {
+ id: addId,
+ scope: addScope,
+ property: addProperty,
+ qualifier: '',
+ value: value
+ };
+ addHolder.push(add);
+ addId++;
+ }
+ }
+ }
+ }
+ data.additional = addHolder;
+ data.freeform = this.tidyObject(data.freeform, standardFields);
+
+ return data;
+ }
+
+ /**
+ * Remove any properties from an object that are not in a supplied array of property names.
+ *
+ * @param {object} obj
+ * @param {array} fields
+ * @returns {object}
+ */
+ stripFields(obj, fields) {
+ const result = {};
+ for (const suppliedField in obj) {
+ if (fields.includes(suppliedField)) {
+ result[suppliedField] = obj[suppliedField];
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Add any missing properties to an object from a supplied array of field names and set to ''.
+ *
+ * @param {object} obj
+ * @param {array} fields
+ * @returns
+ */
+ addFields(obj, fields) {
+ for (const field of fields) {
+ if (!Object.hasOwn(obj, field)) {
+ obj[field] = '';
+ } else {
+ obj[field] = String(obj[field]);
+ }
+ }
+ return obj;
+ }
+
+ /**
+ * Set properties of an object to those from a supplied array of field names.
+ *
+ * @param {object} obj
+ * @param {array} fields
+ * @returns
+ */
+ tidyObject(obj, fields) {
+ obj = (obj && typeof obj === 'object') ? obj : {};
+ obj = this.stripFields(obj, fields);
+ obj = this.addFields(obj, fields);
+ return obj;
+ }
+}
+
+/**
+ * The metadata state instance.
+ */
+export const metadata = new StackMetadata({
+ name: 'qtype_stack_metadata',
+ eventName: eventTypes.qtypeStackStateUpdated,
+ eventDispatch: notifyQtypeStackStateUpdated,
+ mutations,
+});
diff --git a/amd/src/metadata/metadatacontent.js b/amd/src/metadata/metadatacontent.js
new file mode 100644
index 00000000000..1f457a48a3c
--- /dev/null
+++ b/amd/src/metadata/metadatacontent.js
@@ -0,0 +1,50 @@
+// This file is part of Stack - http://stack.maths.ed.ac.uk/
+//
+// Stack is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Stack is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Stack. If not, see .
+
+/**
+ * Main STACK metadata component
+ *
+ * @module qtype_stack/metadata
+ * @copyright 2025 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+
+import {BaseComponent} from 'core/reactive';
+import {metadata} from 'qtype_stack/metadata/metadata';
+
+export default class extends BaseComponent {
+ create() {
+ this.name = 'stack-metadata-content';
+ this.selectors = {
+ METADATACONTAINER: `[data-for='qtype-stack-metadata']`,
+ SUBMIT: `#stack-metadata-update`,
+ };
+ }
+
+ /**
+ * Static method to create a component instance form the mustache template.
+ *
+ * @param {string} target the DOM main element or its ID
+ * @param {object} selectors optional css selector overrides
+ * @return {Component}
+ */
+ static init(target, selectors) {
+ return new this({
+ element: document.querySelector(target),
+ reactive: metadata,
+ selectors,
+ });
+ }
+}
\ No newline at end of file
diff --git a/amd/src/metadata/metadatamodal.js b/amd/src/metadata/metadatamodal.js
new file mode 100644
index 00000000000..c51a2ca2222
--- /dev/null
+++ b/amd/src/metadata/metadatamodal.js
@@ -0,0 +1,127 @@
+// This file is part of Stack - http://stack.maths.ed.ac.uk/
+//
+// Stack is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Stack is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Stack. If not, see .
+
+/**
+ * STACK metadata modal setup
+ *
+ * @module qtype_stack/metadata
+ * @copyright 2025 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+
+import Modal from 'core/modal';
+import {metadata} from 'qtype_stack/metadata/metadata';
+import container from 'qtype_stack/metadata/container';
+
+export class MetadataModal extends Modal {
+ static TYPE = "qtype_stack/metadatamodal";
+ static TEMPLATE = "qtype_stack/metadata/metadatamodal";
+
+ /**
+ * Override the default hide function to validate and update metadata JSON.
+ * On success, stores new JSON to hidden edit form field and closed modal.
+ */
+ async hide() {
+ const result = await metadata.container.update(true);
+ if (result) {
+ const current = document.querySelector('input[name="metadata"]');
+ const newValue = metadata.jsonStringify(metadata.state, 0);
+ if (current.value !== newValue) {
+ document.querySelector('input[name="metadata"]').value = newValue;
+ try {
+ document.querySelector('[data-name="metadata_text"]').textContent =
+ document.querySelector('#id_stack_metadata').getAttribute('data-change');
+ } catch (e) {
+ // Don't update in Moodle 4.2.
+ }
+ }
+ super.hide();
+ }
+ }
+
+ /**
+ * Cancel button needs to close the modal without updating form.
+ */
+ cancel() {
+ metadata.container.revert();
+ super.hide();
+ }
+}
+
+let registered = false;
+if (!registered) {
+ registerModal();
+ registered = true;
+}
+
+let modal = null;
+
+// Prepare for modal creation.
+export const setup = () => {
+ document.querySelector('#id_metadatamodal')?.addEventListener('click', openModal);
+ metadata.loadState();
+};
+
+/**
+ * Need to pass appropriate 'this' to cancel function.
+ */
+function closeModal() {
+ modal.cancel.call(modal);
+}
+
+/**
+ * Register the modal in old versions of Moodle.
+ */
+async function registerModal() {
+ if (typeof MetadataModal.create !== "function") {
+ const ModalRegistry = await import('core/modal_registry');
+ ModalRegistry.register(MetadataModal.TYPE, MetadataModal, MetadataModal.TEMPLATE);
+ }
+}
+
+/**
+ * Open the metadata modal.
+ * Only create modal and add listener once.
+ */
+async function openModal() {
+ let addListener = false;
+ if (!modal) {
+ if (typeof MetadataModal.create === "function") {
+ modal = await MetadataModal.create();
+ modal.show();
+ } else {
+ // Pre Moodle 4.3 code.
+ const ModalFactory = await import ('core/modal_factory');
+ modal = await ModalFactory.create({
+ type: MetadataModal.TYPE,
+ });
+ modal.show();
+ // Why is this necessary? Mustache should do this anyway. Moodle bug?
+ container.init('#qtype-stack-metadata-main');
+ }
+ addListener = true;
+ } else {
+ if (metadata.container) {
+ metadata.container.isEditing = false;
+ document.querySelector(metadata.container.selectors.EDITTOGGLE).checked = false;
+ await metadata.container.reloadContainerComponent({state: metadata.state});
+ }
+ modal.show();
+ }
+ if (addListener) {
+ document.querySelector('#stackmetadata_cancel').addEventListener('click', closeModal);
+ document.querySelector('#stackmetadata_save').addEventListener('click', () => modal.hide());
+ }
+}
diff --git a/amd/src/metadata/mutations.js b/amd/src/metadata/mutations.js
new file mode 100644
index 00000000000..190b98774f1
--- /dev/null
+++ b/amd/src/metadata/mutations.js
@@ -0,0 +1,278 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Default mutation manager
+ *
+ * @module qtype_stack/metadata
+ * @copyright 2025 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+import {metadata} from 'qtype_stack/metadata/metadata';
+
+class Mutations {
+ /**
+ * Check whether an author row is blank across the identity fields.
+ *
+ * @param {object} author
+ * @returns {boolean}
+ */
+ isBlankAuthor(author) {
+ return ['firstName', 'lastName', 'institution'].every((field) => String(author[field] ?? '').trim() === '');
+ }
+
+ /**
+ * Find the first blank author row, if present.
+ *
+ * @param {*} authors
+ * @returns {object|null}
+ */
+ getBlankAuthor(authors) {
+ let blankAuthor = null;
+ authors.forEach((author) => {
+ if (!blankAuthor && this.isBlankAuthor(author)) {
+ blankAuthor = author;
+ }
+ });
+ return blankAuthor;
+ }
+
+ /**
+ * Update state from array of input field information.
+ *
+ * Inputs have ids in form smdi-id-category-field e.g. smdi-1-author-year.
+ * id is row entry id in state. 0 is used for single elements e.g. license.
+ * Multi-elements begin counting from 1.
+ * For scope, row id is for one of the matching additional info rows.
+ * @param {*} stateManager
+ * @param {*} inputArray [['smdi-1-author-year', 2025], ...]
+ * @returns
+ */
+ async updateAll(stateManager, inputArray) {
+ let state = stateManager.state;
+ const additionalCopy = {};
+ if (state.additional) {
+ state.additional.forEach((addInfo) => {
+ const rowCopy = {
+ scope: addInfo.scope,
+ property: addInfo.property,
+ qualifier: addInfo.qualifier,
+ value: addInfo.value
+ };
+ additionalCopy[addInfo.id] = rowCopy;
+ });
+ }
+ for (const field of inputArray) {
+ const parts = field[0].split('_');
+ const id = parts[1];
+ const property = parts[2];
+ const subproperty = parts[3];
+ if (property !== 'additional') {
+ continue;
+ }
+ if (subproperty === 'scope') {
+ // Scope input updates multiple rows.
+ // We find all entries for that scope and update.
+ const existingScope = additionalCopy[id].scope;
+ if (existingScope !== field[1]) {
+ for (const key in additionalCopy) {
+ const addInfo = additionalCopy[key];
+ if (addInfo.scope === existingScope) {
+ addInfo.scope = field[1];
+ }
+ }
+ }
+ } else {
+ const existing = additionalCopy[id];
+ if (existing) {
+ existing[subproperty] = field[1];
+ }
+ }
+ }
+
+ let problems = new Set();
+ for (const key1 in additionalCopy) {
+ const addInfo = additionalCopy[key1];
+ if (addInfo.qualifier === '') {
+ for (const key2 in additionalCopy) {
+ const addInfo2 = additionalCopy[key2];
+ if (
+ addInfo.scope === addInfo2.scope && addInfo.property === addInfo2.property && addInfo2.qualifier !== ''
+ ) {
+ problems.add(key1);
+ }
+ }
+ }
+ }
+ problems = Array.from(problems);
+ if (problems.length) {
+ const output = problems.join(',');
+ return Promise.reject(output);
+ }
+ stateManager.setReadOnly(false);
+ for (const field of inputArray) {
+ const parts = field[0].split('_');
+ const id = parts[1];
+ const property = parts[2];
+ const subproperty = parts[3];
+ if (subproperty === 'scope') {
+ // Scope input updates multiple rows.
+ // We find all entries for that scope and update.
+ const existingScope = state.additional.get(id).scope;
+ if (existingScope !== field[1]) {
+ state.additional.forEach((addInfo) => {
+ if (addInfo.scope === existingScope) {
+ addInfo.scope = field[1];
+ }
+ });
+ }
+ } else if (id != 0) {
+ const existing = state[property].get(id);
+ if (existing) {
+ existing[subproperty] = field[1];
+ }
+ } else {
+ state[property][subproperty] = field[1];
+ }
+ }
+
+ // Force display refresh in odd circumstances where state has not changed
+ // but JSON needs to be updated.
+ state.metadataTicker.value += 1;
+ stateManager.setReadOnly(true);
+ return Promise.resolve('Success');
+ }
+
+ /**
+ * Delete a row from the metadata form.
+ *
+ * @param {*} stateManager
+ * @param {*} property type to be deleted
+ * @param {*} id of instance to be deleted. Form will be refreshed and ids reset.
+ */
+ deleteRow(stateManager, property, id) {
+ const state = stateManager.state;
+ stateManager.setReadOnly(false);
+ if (property === 'scope') {
+ const matchingAddInfo = [];
+ const scope = state.additional.get(id).scope;
+ // Need to delete ALL entries with the same scope
+ // as the supplied additional info.
+ state.additional.forEach((addInfo) => {
+ if (addInfo.scope === scope) {
+ matchingAddInfo.push(addInfo.id);
+ }
+ });
+ for (const current of matchingAddInfo) {
+ state.additional.delete(current);
+ }
+ } else {
+ state[property].delete(id);
+ }
+ stateManager.setReadOnly(true);
+ }
+
+ /**
+ * Add a row
+ *
+ * @param {*} stateManager
+ * @param {*} category
+ * @param {*} id Only required for additional info. Allows us to get relevant scope.
+ */
+ addItem(stateManager, category, id) {
+ const state = stateManager.state;
+ let addCategory = category;
+ let newItem = null;
+ let existingProperty = null;
+ let blankAuthor = null;
+ switch (category) {
+ case 'language':
+ newItem = {
+ value: ""
+ };
+ break;
+ case 'author':
+ newItem = {
+ firstName: (id === 'user') ? metadata.lib.user.firstname : "",
+ lastName: (id === 'user') ? metadata.lib.user.lastname : "",
+ institution: (id === 'user') ? metadata.lib.user.institution : "",
+ year: String(new Date().getFullYear())
+ };
+ // Don't add an author entry if we already have a blank one.
+ blankAuthor = this.getBlankAuthor(state.author);
+ if (blankAuthor) {
+ if (id === 'user') {
+ stateManager.setReadOnly(false);
+ Object.assign(blankAuthor, newItem);
+ stateManager.setReadOnly(true);
+ }
+ return;
+ }
+ break;
+ case 'scope':
+ newItem = {
+ scope: '',
+ property: '',
+ qualifier: '',
+ value: ''
+ };
+ addCategory = 'additional';
+ break;
+ case 'property':
+ existingProperty = state.additional.get(id);
+ newItem = {
+ scope: existingProperty.scope,
+ property: '',
+ qualifier: '',
+ value: ''
+ };
+ addCategory = 'additional';
+ break;
+ default:
+ }
+
+ // Ids are required for all objects. We add one to highest existing id.
+ const keys = Array.from(state[addCategory]);
+ if (keys.length === 0) {
+ newItem.id = 1;
+ } else {
+ keys.sort((a, b) => b[0] - a[0]);
+ newItem.id = 1 + parseInt(keys[0][0]);
+ }
+ stateManager.setReadOnly(false);
+ state[addCategory].add(newItem);
+ stateManager.setReadOnly(true);
+ }
+
+ /**
+ * Straight update of state.
+ *
+ * @param {*} stateManager
+ * @param {object} data Output of metadata.jsonToState
+ */
+ updateFromJson(stateManager, data) {
+ const state = stateManager.state;
+ stateManager.setReadOnly(false);
+ for (const prop in data) {
+ state[prop] = data[prop];
+ }
+ // Force display refresh in case inputs have been altered but JSON not changed.
+ // Inputs will be matched to JSON.
+ state.metadataTicker.value += 1;
+ stateManager.setReadOnly(true);
+ }
+}
+
+export const mutations = new Mutations();
diff --git a/api/util/StackQuestionLoader.php b/api/util/StackQuestionLoader.php
index c548aea7868..159e2886f8f 100644
--- a/api/util/StackQuestionLoader.php
+++ b/api/util/StackQuestionLoader.php
@@ -215,6 +215,10 @@ public static function loadxml($xml, $includetests = false) {
$question->compiledcache = [];
$question->isbroken = (array) $xmldata->question->isbroken ? self::parseboolean($xmldata->question->isbroken) :
self::get_default('question', 'isbroken', 0);
+ $question->metadata =
+ (string) $xmldata->question->metadata ? (string) $xmldata->question->metadata : '';
+ $question->prescribedmetadata =
+ (string) $xmldata->question->prescribedmetadata ? (string) $xmldata->question->prescribedmetadata : '';
$question->options = new \stack_options();
$question->options->set_option(
'multiplicationsign',
diff --git a/backup/moodle2/backup_qtype_stack_plugin.class.php b/backup/moodle2/backup_qtype_stack_plugin.class.php
index 6079164650a..591d15bf8f8 100644
--- a/backup/moodle2/backup_qtype_stack_plugin.class.php
+++ b/backup/moodle2/backup_qtype_stack_plugin.class.php
@@ -58,6 +58,7 @@ protected function define_question_plugin_structure() {
'scientificnotation',
'multiplicationsign', 'sqrtsign',
'complexno', 'inversetrig', 'logicsymbol', 'matrixparens', 'variantsselectionseed', 'isbroken',
+ 'metadata', 'prescribedmetadata',
]
);
diff --git a/backup/moodle2/restore_qtype_stack_plugin.class.php b/backup/moodle2/restore_qtype_stack_plugin.class.php
index 3bdc4fc6aa4..9de7c267fd2 100644
--- a/backup/moodle2/restore_qtype_stack_plugin.class.php
+++ b/backup/moodle2/restore_qtype_stack_plugin.class.php
@@ -88,6 +88,12 @@ public static function convert_backup_to_questiondata(array $backupdata): stdCla
if (!property_exists($questiondata->options, 'isbroken')) {
$questiondata->options->isbroken = 0;
}
+ if (!property_exists($questiondata->options, 'metadata')) {
+ $questiondata->options->metadata = '';
+ }
+ if (!property_exists($questiondata->options, 'prescribedmetadata')) {
+ $questiondata->options->prescribedmetadata = '';
+ }
if (!property_exists($questiondata->options, 'inversetrig')) {
$questiondata->options->inversetrig = 'cos-1';
@@ -208,6 +214,14 @@ public function process_qtype_stack_options($data) {
$data->isbroken = 0;
}
+ if (!property_exists($data, 'metadata')) {
+ $data->metadata = '';
+ }
+
+ if (!property_exists($data, 'prescribedmetadata')) {
+ $data->prescribedmetadata = '';
+ }
+
if (!property_exists($data, 'inversetrig')) {
$data->inversetrig = 'cos-1';
}
diff --git a/classes/output/metadatamodal.php b/classes/output/metadatamodal.php
new file mode 100644
index 00000000000..3d584cd34d3
--- /dev/null
+++ b/classes/output/metadatamodal.php
@@ -0,0 +1,38 @@
+.
+
+namespace qtype_stack\output;
+
+use renderable;
+use templatable;
+
+/**
+ * Render metadata modal
+ *
+ * @package qtype_stack
+ * @copyright 2026 The University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class metadatamodal implements renderable, templatable {
+ /**
+ * Constructor.
+ *
+ */
+ public function __construct() {
+ global $PAGE;
+ $PAGE->requires->js_call_amd('qtype_stack/metadata/metadatamodal', 'init');
+ }
+}
diff --git a/db/install.xml b/db/install.xml
index 0c9bdbdee2a..82eb5197237 100644
--- a/db/install.xml
+++ b/db/install.xml
@@ -1,7 +1,7 @@
-
@@ -10,6 +10,8 @@
+
+
diff --git a/db/upgrade.php b/db/upgrade.php
index 28c225e3e8a..fe9802e3128 100644
--- a/db/upgrade.php
+++ b/db/upgrade.php
@@ -1045,6 +1045,34 @@ function xmldb_qtype_stack_upgrade($oldversion) {
// STACK savepoint reached.
upgrade_plugin_savepoint(true, 2026042402, 'qtype', 'stack');
}
+
+ if ($oldversion < 2026071700) {
+ // Define field metadata to be added to qtype_stack_options.
+ $table = new xmldb_table('qtype_stack_options');
+ $field = new xmldb_field('metadata', XMLDB_TYPE_TEXT, null, null, null, null, null, 'isbroken');
+
+ // Conditionally launch add field metadata.
+ if (!$dbman->field_exists($table, $field)) {
+ $dbman->add_field($table, $field);
+ }
+
+ // Stack savepoint reached.
+ upgrade_plugin_savepoint(true, 2026071700, 'qtype', 'stack');
+ }
+
+ if ($oldversion < 2026071701) {
+ // Define field prescribedmetadata to be added to qtype_stack_options.
+ $table = new xmldb_table('qtype_stack_options');
+ $field = new xmldb_field('prescribedmetadata', XMLDB_TYPE_TEXT, null, null, null, null, null, 'isbroken');
+
+ // Conditionally launch add field prescribedmetadata.
+ if (!$dbman->field_exists($table, $field)) {
+ $dbman->add_field($table, $field);
+ }
+
+ // Stack savepoint reached.
+ upgrade_plugin_savepoint(true, 2026071701, 'qtype', 'stack');
+ }
// Add new upgrade blocks just above here.
// Check the version of the Maxima library code that comes with this version
diff --git a/doc/content/metadata.png b/doc/content/metadata.png
new file mode 100644
index 00000000000..5c62503f560
Binary files /dev/null and b/doc/content/metadata.png differ
diff --git a/doc/en/Authoring/Metadata.md b/doc/en/Authoring/Metadata.md
new file mode 100644
index 00000000000..0678b6fd17b
--- /dev/null
+++ b/doc/en/Authoring/Metadata.md
@@ -0,0 +1,154 @@
+# Question metadata
+
+STACK questions support flexible metadata. Metadata is recorded in JSON format in two database fields - `metadata` and `prescribedmetadata`. The `metadata` field holds user-editable metadata. `prescribedmetadata` is currently empty and is reserved for storage of automatically generated and maintained metadata in the future, including a universal question id and some form of question ancestory.
+
+New questions will be created with editable `metadata` similar to the following:
+```
+{
+ "author":[
+ {
+ "firstName": "Current", //$USER->firstname
+ "lastName": "User", // $USER->lastname
+ "institution": "", // $USER->institution
+ "year": "2026" // Current year
+ }
+ ],
+ "language": [
+ "en" // Current language
+ ],
+ "license": "unknown", // $CFG->sitedefaultlicense
+ "isPartOf": "",
+ "additional": {},
+ "freeform" {}
+}
+```
+A question can have multiple authors.
+
+Additional metadata is stored in Scope->Property->Qualifier->Value or Scope->Property->Value format. Scope identifies the metadata scheme being used. For instance, two institutions might have the property `Level` that has different meanings. Scope allows differentiation between the two:
+```
+"additional": {
+ "Edinburgh": {
+ "Level": "Undergraduate"
+ },
+ "Glasgow": {
+ "Level": "Year 2"
+ }
+}
+```
+A property within a scope can have qualifiers:
+```
+"additional": {
+ "Edinburgh": {
+ "Course": {
+ "Name": "Introductory Maths",
+ "Week": "3"
+ }
+ }
+}
+```
+or simply a value:
+```
+"additional": {
+ "Edinburgh": {
+ "Course": "Introductory Maths"
+ }
+}
+```
+If any entry for a property has a qualifier, then all entries must do so in order for valid JSON to be created:
+```
+"additional": {
+ "Scope1": {
+ "Property1": {
+ "Qualifier1": "Value1",
+ "Qualifier2": "Value2",
+ "Qualifier3": [
+ "Value3",
+ "Value4"
+ ]
+ },
+ "Property2": [
+ "Value5",
+ "Value6",
+ "Value7"
+ ],
+ "Property3": "Value8"
+ },
+ "Scope2": {
+ "Property1": "Value2",
+ "Property4": "Value4"
+ }
+}
+```
+The freeform metadata input allows entry of further metadata in whatever format the user requires. It simply has to be valid JSON. This can also be added in the main JSON metadata input field but (unlike `additional` entries) does not create additional input fields and buttons. This field is for power users who require greater nesting depth in their metadata.
+
+All information in the question `metadata` field can be updated via the button 'View and edit full metadata' in the question edit form. This launches a pop-up where the metadata information can be updated via input boxes or the JSON can be manually amended and validated.
+
+
+
+Switch the modal to edit, update the input boxes and click 'Validate inputs and update JSON' to display the JSON output. Update the JSON and click 'Update inputs from JSON' to fill in the input boxes from the JSON. 'Validate and close' will take the contents of the input boxes, validate them and then create and store JSON ready to be saved as part of the STACK question. The question edit form must still be saved normally once the pop-up has closed in order to save this JSON to the question.
+
+Please note that although you can update either the JSON or the input boxes, the form saves the contents of the input boxes. If you update the JSON you must click 'Update inputs from JSON' without a JSON error being displayed before clicking 'Validate and close'.
+
+Example metadata:
+```
+{
+ "author": [
+ {
+ "firstName": "Dave",
+ "lastName": "Summers",
+ "institution": "Edinburgh",
+ "year": "2025"
+ },
+ {
+ "firstName": "Bob",
+ "lastName": "Smith",
+ "institution": "Open University",
+ "year": "2026"
+ },
+ {
+ "firstName": "Tim",
+ "lastName": "Jones",
+ "institution": "",
+ "year": "2026"
+ }
+ ],
+ "language": [
+ "en",
+ "de"
+ ],
+ "isPartOf": "HELM",
+ "license": "cc-4.0",
+ "additional": {
+ "Edinburgh": {
+ "Course": {
+ "Name": "Introductory Maths",
+ "Week": "3",
+ "Code": [
+ "AA",
+ "BB"
+ ]
+ },
+ "Topic": [
+ "Calculus",
+ "Geometry",
+ "Statistics"
+ ],
+ "Level": "Undergraduate"
+ },
+ "HELM": {
+ "Workbook": "10",
+ "Level": "Basic"
+ }
+ },
+ "freeform": {}
+}
+```
+Both metadata fields are exported and imported normally as part of Moodle XML, allowing automated addition of metadata to large question banks with the aid of Gitsync.
+```
+
+ {"id": XXXX-XXX-XXXX_XXXX}
+
+
+ {"author":[{"firstName":"Dave","lastName":"Summers","institution":"Edinburgh","year":"2025"},{"firstName":"Bob","lastName":"Smith","institution":"Open University","year":"2026"}],"language":["en"],"isPartOf":"HELM","license":"cc-4.0","additional":{"UoE":{"Course":{"Name":"Introductory Maths","Week":"3"},"Topic":"Calculus"}},"freeform":{}}
+
+```
\ No newline at end of file
diff --git a/doc/en/Authoring/index.md b/doc/en/Authoring/index.md
index 37185c4221b..e6cb334bbe1 100644
--- a/doc/en/Authoring/index.md
+++ b/doc/en/Authoring/index.md
@@ -17,6 +17,7 @@ A "question" is the basic object in the system. The following table shows the f
| [Inputs](Inputs/index.md) | The inputs are the things, such as form boxes, with which the student actually interacts.
| [Potential response trees](Potential_response_trees.md) | These are the algorithms which establish the mathematical properties of the students' answers and generate feedback.
| [Options](Question_options.md) | Many behaviours can be changed with the options.
+| [Metadata](Metadata.md) | STACK questions support flexible metadata.
The authoring documentation also covers topics on:
diff --git a/doc/en/Developer/Unit_tests.md b/doc/en/Developer/Unit_tests.md
index 8335cf1fb00..c3476810a83 100644
--- a/doc/en/Developer/Unit_tests.md
+++ b/doc/en/Developer/Unit_tests.md
@@ -10,6 +10,8 @@ These three mechanisms aim to provide comprehensive testing of STACK. The last
STACK uses the moodle-ci continuous integration mechanism via github actions so that all unit tests are triggered when a commit is pushed to github.
+JavaScript unit tests added May 2026.
+
# PHP Unit tests
Moodle uses PHPUnit for its unit tests. Setting this up and getting it working is a bit of a pain, but you only have to follow the instructions in [the Moodle PHPUnit documentation](http://docs.moodle.org/dev/PHPUnit) once to get it working.
@@ -187,3 +189,11 @@ You need to output values to the file system, as the display can't manage this.
In the STACK directory
php cli/casstringtester.php --string="0..1"
+
+# Testing JavaScript
+
+In the tests/jest directory as a user with write permissions:
+```
+npm install
+npm test
+```
diff --git a/doc/en/STACK_question_admin/Authoring_workflow.md b/doc/en/STACK_question_admin/Authoring_workflow.md
index 69f510c023d..9edcec6dd46 100644
--- a/doc/en/STACK_question_admin/Authoring_workflow.md
+++ b/doc/en/STACK_question_admin/Authoring_workflow.md
@@ -69,7 +69,11 @@ In theory one test case should be created for each anticipated response which ge
When updating a PRT at this stage we would _expect_ test cases added in step 2 to fail. This is reassuring as it indicates something significant has changed! You can easily confirm the new behaviour of the testcase is now what is intended.
-### 6. Use data obtained from one cycle of attempts by students.
+### 6. Add accurate metadata.
+
+Check and add accurate [question metadata](../Authoring/Metadata.md).
+
+### 7. Use data obtained from one cycle of attempts by students.
Rather than second-guess what students _might_ get wrong it is more effective to look at what they _do_. See the section on [reporting](Reporting.md) for documentation on how to review students' answers. When feedback/marks are delayed (e.g. online exam) this can be done between students taking the assessment and results being released. If feedback/marks are immediate then better quality feedback can still be usefully added later.
diff --git a/doc/meta_en.json b/doc/meta_en.json
index f3a5ecf00b0..aa980e1ef40 100644
--- a/doc/meta_en.json
+++ b/doc/meta_en.json
@@ -463,6 +463,11 @@
"description":"Information on writing STACK questions for several languages."
},
{
+ "file":"Metadata.md",
+ "title":"Question Metadata - STACK Documentation",
+ "description":"Information on STACK question metadata."
+ },
+ {
"file":"Question_options.md",
"title":"Options - STACK Documentation",
"description":"Documentation for the Option section in a STACK question."
diff --git a/edit_stack_form.php b/edit_stack_form.php
index 383374db9ee..22896701764 100644
--- a/edit_stack_form.php
+++ b/edit_stack_form.php
@@ -227,7 +227,7 @@ protected function definition() {
// phpcs:ignore moodle.Commenting.MissingDocblock.Function
protected function definition_inner(/* MoodleQuickForm */ $mform) {
- global $OUTPUT;
+ global $OUTPUT, $PAGE, $CFG, $SESSION, $USER;
// Load the configuration.
$this->stackconfig = stack_utils::get_config();
@@ -277,6 +277,64 @@ protected function definition_inner(/* MoodleQuickForm */ $mform) {
$warnings = ($warnings) ? $warnings . ' ' : $warnings;
$warnings .= '' . stack_string('usetextarea');
}
+ $PAGE->requires->js_call_amd('qtype_stack/metadata/metadatamodal', 'setup');
+ $mform->addElement('button', 'metadatamodal', stack_string('editmetadata'));
+ $datalib = new \stdClass();
+ $datalib->licenses = explode(',', $CFG->licenses ?? '');
+ $datalib->licenses = array_map(function ($license) {
+ return ['value' => $license, 'text' => get_string($license, 'license')];
+ }, $datalib->licenses);
+ $datalib->languages = [
+ $PAGE->cm->lang ?? '',
+ $PAGE->course->lang ?? '',
+ $SESSION->lang ?? '',
+ $USER->lang ?? '',
+ $CFG->lang ?? '',
+ 'en',
+ ];
+ $datalib->user = new stdClass();
+ $datalib->user->firstname = $USER->firstname ?? '';
+ $datalib->user->lastname = $USER->lastname ?? '';
+ $datalib->user->institution = $USER->institution ?? '';
+ $datalib->placeholder = stack_string('licenseselect');
+ $datalib = json_encode($datalib);
+ if (!isset($this->question->id)) {
+ $data = '{"author":[{"firstName":"' . ($USER->firstname ?? '') . '","lastName":"' . ($USER->lastname ?? '') . '",' .
+ '"institution":"' . ($USER->institution ?? '') . '","year":"' . date('Y') . '"}],' .
+ '"language":["' . current_language() . '"],"license":"' . $CFG->sitedefaultlicense . '"}';
+ } else {
+ $data = ($this->question->options->metadata) ? $this->question->options->metadata : '{}';
+ }
+ $md = $mform->createElement(
+ 'hidden',
+ 'metadata',
+ $data,
+ ['data-lib' => $datalib, 'id' => 'id_stack_metadata', 'data-change' => stack_string('metadatachange')]
+ );
+ $mform->insertElementBefore($md, 'metadatamodal');
+ $mform->setType('metadata', PARAM_RAW);
+
+ $metadataobj = json_decode($data ?? '');
+ if ($metadataobj && $data !== '{}') {
+ $authors = [];
+ if (isset($metadataobj->author) && is_array($metadataobj->author)) {
+ $authors = $metadataobj->author;
+ }
+ $authorsummary = '';
+ foreach ($authors as $author) {
+ $authorsummary .= ($authorsummary) ? ', ' : '';
+ $currentsummary = ($author->firstName ?? '') . ' ' . ($author->lastName ?? '');
+ if (!trim($currentsummary)) {
+ $currentsummary = $author->institution ?? '';
+ }
+ $authorsummary .= $currentsummary;
+ }
+ $metadatasummary = stack_string('author') . ': ' . trim($authorsummary);
+ } else {
+ $metadatasummary = stack_string('novalidmetadata');
+ }
+ $metadatatext = $mform->createElement('static', 'metadata_text', stack_string('metadatahighlights'), $metadatasummary);
+ $mform->insertElementBefore($metadatatext, 'metadatamodal');
// Note that for the editor elements, we are using $mform->getElement('prtincorrect')->setValue(...); instead
// of setDefault, because setDefault does not work for editors.
@@ -339,8 +397,8 @@ protected function definition_inner(/* MoodleQuickForm */ $mform) {
if ($courseid = optional_param('courseid', 0, PARAM_INT)) {
$liburlparams['courseid'] = $courseid;
}
- if ($cmid = optional_param('returnurl', null, PARAM_LOCALURL)) {
- $liburlparams['returnurl'] = $cmid;
+ if ($returnurl = optional_param('returnurl', null, PARAM_LOCALURL)) {
+ $liburlparams['returnurl'] = $returnurl;
}
$qlibrarylink = html_writer::link(
new moodle_url('/question/type/stack/questionlibrary.php', $liburlparams),
@@ -1133,6 +1191,7 @@ protected function data_preprocessing_options($question) {
$question->assumepositive = $opt->assumepositive;
$question->assumereal = $opt->assumereal;
$question->isbroken = $opt->isbroken;
+ $question->metadata = $opt->metadata;
return $question;
}
diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php
index c9d3112761c..5537cbfc6cb 100644
--- a/lang/en/qtype_stack.php
+++ b/lang/en/qtype_stack.php
@@ -419,6 +419,38 @@
$string['moodleerrors'] = 'You have errors related to Moodle\'s basic question setup.';
$string['stackerrors'] = 'You have errors in your question.';
$string['markedasbroken'] = 'The question has been marked as broken and will not be accessible to students.';
+$string['editmetadata'] = 'View and edit full metadata';
+$string['additionalmetadata'] = 'Additional metadata';
+$string['novalidmetadata'] = 'No valid metadata';
+$string['metadata'] = 'Metadata';
+$string['metadatahighlights'] = 'Metadata';
+$string['metadatachange'] = 'Please save your changes.';
+$string['firstname'] = 'First name';
+$string['lastname'] = 'Last name';
+$string['institution'] = 'Institution';
+$string['year'] = 'Year';
+$string['author'] = 'Author';
+$string['addauthor'] = 'Add author';
+$string['addscope'] = 'Add scope';
+$string['addproperty'] = 'Add property';
+$string['addlanguage'] = 'Add language';
+$string['updateJSON'] = 'Validate inputs and update JSON';
+$string['updateinputs'] = 'Update inputs from JSON';
+$string['makemeauthor'] = 'Add me as an author';
+$string['validateandclose'] = 'Validate and close';
+$string['JSONmetadata'] = 'JSON metadata';
+$string['JSONtoolong'] = 'There is too much metadata. Metadata should be under 32000 bytes.';
+$string['JSONbroken'] = 'Metadata is invalid JSON.';
+$string['metadataexplanation'] = 'STACK metadata is stored as a JSON object. You can edit metadata in the inputs above or directly edit the JSON below and then click \'Update inputs from JSON\'. On closing this metadata window, the inputs will be validated and, if this is successful, you will be returned to the main question edit form. You will still need to save the question as normal to save the metadata.';
+$string['scope'] = 'Scope';
+$string['scopename'] = 'Scope name';
+$string['property'] = 'Property';
+$string['qualifier'] = 'Qualifier';
+$string['value'] = 'Value';
+$string['ispartof'] = 'isPartOf';
+$string['freeformmetadata'] = 'Freeform metadata';
+$string['reverttosaved'] = 'Revert current changes';
+$string['licenseselect'] = 'Type or select license';
$string['alwaysmanuallygrade'] = 'This question is always manually graded.';
// Strings used by input elements.
diff --git a/question.php b/question.php
index 3fbdba7e164..d7546f1844f 100644
--- a/question.php
+++ b/question.php
@@ -256,6 +256,14 @@ class qtype_stack_question extends question_graded_automatically_with_countback
* @var bool is this question marked as broken.
*/
public $isbroken = false;
+ /**
+ * @var string The question metadata in JSON form. Can be set by users.
+ */
+ public $metadata = '';
+ /**
+ * @var string The metadata created by STACK itself in JSON form.
+ */
+ public $prescribedmetadata = '';
/**
* Make sure the cache is valid for the current response. If not, clear it.
diff --git a/questiontype.php b/questiontype.php
index 6d9e8449df0..0dfbade1244 100644
--- a/questiontype.php
+++ b/questiontype.php
@@ -266,6 +266,8 @@ public function save_question_options($fromform) {
$options->matrixparens = $fromform->matrixparens;
$options->variantsselectionseed = $fromform->variantsselectionseed;
$options->isbroken = !empty($fromform->isbroken) ? 1 : 0;
+ $options->metadata = $fromform->metadata;
+ $options->prescribedmetadata = "";
// We will not have the values for this.
$options->compiledcache = '{}';
@@ -638,6 +640,8 @@ protected function initialise_question_instance(question_definition $question, $
$question->variantsselectionseed = $questiondata->options->variantsselectionseed;
$question->compiledcache = $questiondata->options->compiledcache;
$question->isbroken = $questiondata->options->isbroken;
+ $question->metadata = $questiondata->options->metadata;
+ $question->prescribedmetadata = $questiondata->options->prescribedmetadata;
// Parse the cache in advance.
if (is_string($question->compiledcache)) {
@@ -1657,6 +1661,8 @@ public function export_to_xml($questiondata, qformat_xml $format, $notused = nul
$output .= " {$options->logicsymbol}\n";
$output .= " {$options->matrixparens}\n";
$output .= " {$options->isbroken}\n";
+ $output .= " {$options->prescribedmetadata}\n";
+ $output .= " {$options->metadata}\n";
$output .= " {$format->xml_escape($options->variantsselectionseed)}\n";
foreach ($questiondata->inputs as $input) {
@@ -1825,6 +1831,8 @@ public function import_from_xml($xml, $fromform, qformat_xml $format, $notused =
$fromform->assumereal
= $format->getpath($xml, ['#', 'assumereal', 0, '#'], get_config('qtype_stack', 'assumereal'));
$fromform->isbroken = $format->getpath($xml, ['#', 'isbroken', 0, '#'], 0);
+ $fromform->metadata = $format->getpath($xml, ['#', 'metadata', 0, '#'], '');
+ $fromform->prescribedmetadata = $format->getpath($xml, ['#', 'prescribedmetadata', 0, '#'], '');
$fformat = FORMAT_HTML;
if (isset($fromform->prtcorrectformat)) {
$fformat = $fromform->prtcorrectformat;
@@ -2404,6 +2412,14 @@ public function validate_fromform($fromform, $errors, $question = null) {
$fromform['questiondescription']['text']
);
+ $errors['metadata_text'] = [];
+ if (mb_strlen($fromform['metadata']) > 32000) {
+ $errors['metadata_text'][] = stack_string('JSONtoolong');
+ }
+ if ($fromform['metadata'] && !json_decode($fromform['metadata'])) {
+ $errors['metadata_text'][] = stack_string('JSONbroken');
+ }
+
// 2) Validate all inputs.
$stackinputfactory = new stack_input_factory();
foreach ($inputs as $inputname => $counts) {
diff --git a/stack/maxima/stackmaxima.mac b/stack/maxima/stackmaxima.mac
index c134a896a38..76353334f8e 100644
--- a/stack/maxima/stackmaxima.mac
+++ b/stack/maxima/stackmaxima.mac
@@ -1045,7 +1045,7 @@ plot(ex, [ra]) := /*stack_web_plot*/
/* Check for box. */
plotbox:sublist(ra, lambda([ex], if listp(ex) then is(first(ex) = box) else false)),
if emptyp(plotbox) then plotbox:true else block(
- ra:delete(first(plotbox), ra),
+ ra:delete(first(plotbox), ra),
if not(second(first(plotbox))) then plotbox:false
),
/* For new versions. */
@@ -1140,7 +1140,7 @@ set output ", afn),
if plotgrid2d and MAXIMA_VERSION_NUM>34 then
ral: append(ral, [grid2d]),
/* Add in the command for the nobox. */
- if not(plotbox) then if MAXIMA_VERSION_NUM>47 then
+ if not(plotbox) then if MAXIMA_VERSION_NUM>47 then
ral:append(ral, [nobox]) else ral:append(ral, [[box,false]]),
if plotdebug then print(preamble),
if PLOT_TERMINAL="svg" then set_plot_option([svg_file, afn]),
@@ -3564,4 +3564,4 @@ is_lang(code):=ev(is(%_STACK_LANG=code),simp=true)$
/* Stack expects some output with the version number the output happens at */
/* maximalocal.mac after additional library loading */
-stackmaximaversion:2026063000$
+stackmaximaversion:2026071701$
diff --git a/styles.css b/styles.css
index 112897da403..6893c1bbc9f 100644
--- a/styles.css
+++ b/styles.css
@@ -1363,3 +1363,202 @@ USE OR OTHER DEALINGS IN THE SOFTWARE.
.stacktestsuite .results-symbol {
font-size: 1.5em;
}
+.stack-metadata {
+ --smd-primary: #0f6cbf;
+ --smd-primary-bg: rgba(15, 108, 191, 0.03);
+ --smd-border: #dee2e6;
+ --smd-text: #343a40;
+ --smd-muted: #6c757d;
+ --smd-radius: 4px;
+}
+
+.stack-metadata :is(input:not([type="checkbox"]):not([type="radio"]), textarea) {
+ width: 100%;
+}
+
+.stack-metadata .smd-header-edit {
+ margin-left: auto;
+ margin-right: 1rem;
+}
+
+.stack-metadata .modal-header .btn-close {
+ margin-left: 0;
+}
+
+.stack-metadata .smd-section {
+ border-left: 3px solid var(--smd-primary);
+ background: var(--smd-primary-bg);
+ border-radius: 0 var(--smd-radius) var(--smd-radius) 0;
+ padding: 0.75rem 1rem 0.75rem 1.25rem;
+ margin-bottom: 1rem;
+}
+
+.stack-metadata :is(.smd-section-label, .smd-col-label) {
+ color: var(--smd-text);
+ font-size: 0.9rem;
+ font-weight: 700;
+}
+
+.stack-metadata .smd-section-label {
+ text-transform: uppercase;
+ letter-spacing: 0;
+ margin-bottom: 0.6rem;
+}
+
+.stack-metadata .smd-col-label {
+ display: block;
+ margin-bottom: 0.25rem;
+}
+
+.stack-metadata [name="smd_delete"] i,
+.stack-metadata [name="smd_add"] i {
+ pointer-events: none;
+}
+
+.stack-metadata #qtype-stack-metadata-content:not(.smd-editing) .fitem {
+ display: none;
+}
+
+.stack-metadata :is(.smd-edit-block, .smd-edit-flex, .smd-edit-inline-flex) {
+ display: none;
+}
+
+.stack-metadata .smd-editing .smd-edit-block {
+ display: block;
+}
+
+.stack-metadata .smd-editing .smd-edit-flex {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.stack-metadata .smd-editing .smd-edit-inline-flex {
+ display: inline-flex;
+}
+
+.stack-metadata .smd-editing .smd-read-mode {
+ display: none;
+}
+
+.stack-metadata :is(details.smd-section, .smd-scope-card) > summary {
+ list-style: none;
+ cursor: pointer;
+ user-select: none;
+}
+
+.stack-metadata :is(details.smd-section, .smd-scope-card) > summary::marker,
+.stack-metadata :is(details.smd-section, .smd-scope-card) > summary::-webkit-details-marker {
+ display: none;
+}
+
+.stack-metadata .smd-scope-name {
+ min-width: 0;
+}
+
+.stack-metadata .smd-scope-name-text {
+ font-size: 0.95rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.stack-metadata .smd-scope-header {
+ border-bottom: 1px solid var(--smd-border);
+}
+
+.stack-metadata .smd-scope-header:is(:focus, :focus-visible) {
+ border: 3px solid var(--smd-primary);
+ outline: 0;
+}
+
+.stack-metadata .smd-field-header {
+ margin-bottom: 0.3rem;
+}
+
+.stack-metadata .smd-field-header .smd-col-label {
+ margin-bottom: 0;
+}
+
+.stack-metadata .smd-scope-chevron {
+ flex-shrink: 0;
+ color: var(--smd-muted);
+ pointer-events: none;
+ transition: transform 0.2s ease;
+ margin-right: 0.25rem;
+}
+
+.stack-metadata .smd-scope-card:not([open]) .smd-scope-chevron {
+ transform: rotate(-90deg);
+}
+
+.stack-metadata :is(.row, .smd-scope-body) :is(.fitem, .felement) {
+ margin: 0;
+ padding: 0;
+}
+
+.stack-metadata :is(.fitem, .felement) {
+ border: 0;
+ box-shadow: none;
+ outline: 0;
+}
+
+.stack-metadata .smd-read-mode {
+ font-size: 0.9rem;
+}
+
+.stack-metadata .smd-cell-text {
+ display: block;
+ min-height: 1.6rem;
+ line-height: 1.6rem;
+ padding: 0;
+}
+
+.stack-metadata .smd-editing .fitem {
+ min-height: 0;
+}
+
+.stack-metadata .smd-editing .felement {
+ align-items: flex-start;
+}
+
+.stack-metadata .smd-editing :is(.invalid-feedback, .form-control-feedback) {
+ margin-top: 0.2rem;
+ min-height: 0;
+}
+
+.stack-metadata .smd-row-delete {
+ width: 2.5rem;
+ height: 2.5rem;
+}
+
+.stack-metadata .smd-row-delete i {
+ font-size: 1.25rem;
+}
+
+.stack-metadata :is([name="smd_add"], #stack-metadata-make-author) {
+ color: var(--smd-text);
+ border-color: var(--smd-text);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ padding: 0;
+ border-radius: 50%;
+}
+
+.stack-metadata :is(.smd-license-wrap, .smd-ispartof-wrap) .fitem {
+ width: 100%;
+}
+
+.stack-metadata .smd-col-divider {
+ border-left: 1px solid var(--smd-border);
+ padding-left: 1.25rem;
+}
+
+.stack-metadata .smd-freeform-display-text {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: #495057;
+}
diff --git a/templates/metadata/metadataadditional.mustache b/templates/metadata/metadataadditional.mustache
new file mode 100644
index 00000000000..249059b2dc4
--- /dev/null
+++ b/templates/metadata/metadataadditional.mustache
@@ -0,0 +1,87 @@
+{{!
+ This file is part of Stack - http://stack.maths.ed.ac.uk/
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template qtype_stack/metadata/metadataadditional
+ Example context (json):
+ {
+ "property": {
+ "required": true,
+ "element": {
+ "value": "h",
+ "wrapperid": "fitem_smdi_2_additional_property",
+ "id": "smdi_2_additional_property",
+ "name": "smdi_2_additional_property",
+ "iderror": "smde_2_additional_property_error"
+ }
+ },
+ "qualifier": {
+ "required": false,
+ "element": {
+ "value": "i",
+ "wrapperid": "fitem_smdi_2_additional_qualifier",
+ "id": "smdi_2_additional_qualifier",
+ "name": "smdi_2_additional_qualifier",
+ "iderror": "smde_2_additional_qualifier_error"
+ }
+ },
+ "value": {
+ "required": false,
+ "element": {
+ "value": "j",
+ "wrapperid": "fitem_smdi_2_additional_value",
+ "id": "smdi_2_additional_value",
+ "name": "smdi_2_additional_value",
+ "iderror": "smde_2_additional_value_error"
+ }
+ },
+ "id": "2"
+ }
+}}
+
diff --git a/templates/metadata/metadataauthor.mustache b/templates/metadata/metadataauthor.mustache
new file mode 100644
index 00000000000..e9b1fd379a4
--- /dev/null
+++ b/templates/metadata/metadataauthor.mustache
@@ -0,0 +1,71 @@
+{{!
+ This file is part of Stack - http://stack.maths.ed.ac.uk/
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template qtype_stack/metadata/metadataauthor
+ Example context (json):
+ {
+ "firstname": { "required": false, "element": { "value": "a", "wrapperid": "fitem_smdi_1_author_firstName", "id": "smdi_1_author_firstName", "name": "smdi_1_author_firstName", "iderror": "smde_1_author_firstName_error" } },
+ "lastname": { "required": true, "element": { "value": "b", "wrapperid": "fitem_smdi_1_author_lastName", "id": "smdi_1_author_lastName", "name": "smdi_1_author_lastName", "iderror": "smde_1_author_lastName_error" } },
+ "institution": { "required": false, "element": { "value": "c", "wrapperid": "fitem_smdi_1_author_institution", "id": "smdi_1_author_institution", "name": "smdi_1_author_institution", "iderror": "smde_1_author_institution_error" } },
+ "year": { "required": false, "element": { "value": "2025", "wrapperid": "fitem_smdi_1_author_year", "id": "smdi_1_author_year", "name": "smdi_1_author_year", "iderror": "smde_1_author_year_error" } },
+ "id": "1"
+ }
+}}
+
diff --git a/templates/metadata/metadatamodal.mustache b/templates/metadata/metadatamodal.mustache
new file mode 100644
index 00000000000..b791e9dea91
--- /dev/null
+++ b/templates/metadata/metadatamodal.mustache
@@ -0,0 +1,51 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template qtype_stack/metadata/metadatamodal
+
+ Example context (json): {}
+}}
+{{< core/modal }}
+ {{$header}}
+
+
diff --git a/templates/metadata/metadatayear.mustache b/templates/metadata/metadatayear.mustache
new file mode 100644
index 00000000000..3ea810f03d2
--- /dev/null
+++ b/templates/metadata/metadatayear.mustache
@@ -0,0 +1,48 @@
+{{!
+ This file is part of Stack - http://stack.maths.ed.ac.uk/
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template qtype_stack/metadata/metadatayear
+ Example context (json):
+ {
+ "required": false,
+ "element": {
+ "value": "2025",
+ "wrapperid": "fitem_smdi_1_author_year",
+ "id": "smdi_1_author_year",
+ "name": "smdi_1_author_year",
+ "iderror": "smde_1_author_year_error"
+ }
+ }
+}}
+{{< core_form/element-template-inline }}
+ {{$element}}
+
+ {{/element}}
+{{/ core_form/element-template-inline }}
diff --git a/tests/behat/behat_qtype_stack.php b/tests/behat/behat_qtype_stack.php
index fde625cb2fa..2e8051f4266 100644
--- a/tests/behat/behat_qtype_stack.php
+++ b/tests/behat/behat_qtype_stack.php
@@ -28,6 +28,7 @@
require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php');
use Moodle\BehatExtension\Exception\SkippedException;
+use PHPUnit\Framework\Assert;
/**
* Steps definitions related with the question bank management.
@@ -237,6 +238,50 @@ public function i_check_element_value_contains_one_of($id, $value1, $value2, $va
}
}
+ /**
+ * Check a hidden value
+ *
+ * @param string $name name of hidden field.
+ * @param string $value the expected value with current year replaced with XXXX.
+ *
+ * @Given /^I check the hidden input "(?P[^"]*)" is '(?P[^']*)'$/
+ */
+ public function i_check_hidden_value($name, $value) {
+ $year = date('Y');
+ $value = str_replace('XXXX', $year, $value);
+ $js = <<evaluate_script($js);
+ Assert::assertEquals($value, $formvalue);
+ }
+
+ /**
+ * Check that the active element matches a CSS selector.
+ *
+ * @Then /^the focused element should be "(?P[^"]*)" "css_element"$/
+ */
+ public function the_focused_element_should_be(string $selector): void {
+ $selectorjson = json_encode($selector);
+ $matches = $this->spin(function($context, $selectorjson) {
+ $js = <<evaluate_script($js);
+ }, $selectorjson, 5, new \Exception('Expected focused element to match "' . $selector . '".'));
+ Assert::assertTrue((bool) $matches);
+ }
+
/**
* Set the response for a given input in the Moodle app.
*
diff --git a/tests/behat/metadata.feature b/tests/behat/metadata.feature
new file mode 100644
index 00000000000..5f6230dab10
--- /dev/null
+++ b/tests/behat/metadata.feature
@@ -0,0 +1,390 @@
+@qtype @qtype_stack
+Feature: Create and edit STACK metadata
+ In order catalogue questions effectively
+ As an teacher
+ I need to create and edit metadata for STACK questions
+
+ Background:
+ Given I set up STACK using the PHPUnit configuration
+ And the following "courses" exist:
+ | fullname | shortname | format |
+ | Course 1 | C1 | topics |
+ And the following "users" exist:
+ | username | firstname |
+ | teacher | Teacher |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher | C1 | editingteacher |
+ And the following "question categories" exist:
+ | contextlevel | reference | name |
+ | Course | C1 | Test questions |
+ And the following "questions" exist:
+ | questioncategory | qtype | name | template |
+ | Test questions | stack | Algebraic input | algebraic_input |
+
+ @javascript
+ Scenario: Create and edit STACK metadata
+ When I am on the "Algebraic input" "core_question > edit" page logged in as teacher
+ And I click on "View and edit full metadata" "button"
+ And I should see "Freeform metadata"
+ And I click on "stack-metadata-edit-switch" "checkbox"
+ And I click on "smd_author_0_add" "button"
+ And I wait until "smdi_1_author_firstName" "field" exists
+ And the focused element should be "#smdi_1_author_firstName" "css_element"
+ And I set the field "smdi_1_author_firstName" in the "#qtype-stack-metadata-content" "css_element" to "Edmund"
+ And I set the field "smdi_1_author_lastName" in the "#qtype-stack-metadata-content" "css_element" to "Farrow"
+ And I set the field "smdi_1_author_institution" in the "#qtype-stack-metadata-content" "css_element" to "UoE"
+ And I set the field "smdi_1_author_year" in the "#qtype-stack-metadata-content" "css_element" to "2025"
+ And I click on "smd_author_0_add" "button"
+ And I wait until "smdi_2_author_firstName" "field" exists
+ And the focused element should be "#smdi_2_author_firstName" "css_element"
+ And I set the field "smdi_2_author_firstName" in the "#qtype-stack-metadata-content" "css_element" to "Bob"
+ And I set the field "smdi_2_author_lastName" in the "#qtype-stack-metadata-content" "css_element" to "Smith"
+ And I set the field "smdi_2_author_institution" in the "#qtype-stack-metadata-content" "css_element" to "MIT"
+ And I set the field "smdi_2_author_year" in the "#qtype-stack-metadata-content" "css_element" to "2026"
+ And I click on "smd_language_0_add" "button"
+ And I wait until "smdi_1_language_value" "field" exists
+ And the focused element should be "#smdi_1_language_value" "css_element"
+ And I set the field "smdi_1_language_value" in the "#qtype-stack-metadata-content" "css_element" to "en"
+ And I set the field "smdi_0_isPartOf_value" in the "#qtype-stack-metadata-content" "css_element" to "HELM"
+ And I open the autocomplete suggestions list in the "#qtype-stack-metadata-content" "css_element"
+ Then "[data-value='cc-nc-4.0']" "css_element" should be visible
+ And I click on "[data-value='cc-nc-4.0']" "css_element"
+ And I click on "smd_scope_0_add" "button"
+ And I wait until "smdi_1_additional_scope" "field" exists
+ And the focused element should be "#smdi_1_additional_scope" "css_element"
+ And I set the field "smdi_1_additional_scope" in the "#qtype-stack-metadata-content" "css_element" to "Added data"
+ And I set the field "smdi_1_additional_property" in the "#qtype-stack-metadata-content" "css_element" to "Dog info"
+ And I set the field "smdi_1_additional_qualifier" in the "#qtype-stack-metadata-content" "css_element" to "Breed"
+ And I set the field "smdi_1_additional_value" in the "#qtype-stack-metadata-content" "css_element" to "Al$%&^"
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"author":[{"firstName":"Edmund","lastName":"Farrow","institution":"UoE","year":"2025"},{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2026"}],"language":["en"],"isPartOf":"HELM","license":"cc-nc-4.0","additional":{"Added data":{"Dog info":{"Breed":"Al$%&^"}}},"freeform":{}}'
+ And I click on "View and edit full metadata" "button"
+ And I should see "Freeform metadata"
+ And I click on "stack-metadata-edit-switch" "checkbox"
+ And I click on "stack-metadata-make-author" "button"
+ And I wait until "smdi_3_author_firstName" "field" exists
+ And the focused element should be "#smdi_3_author_firstName" "css_element"
+ And I click on "smd_language_0_add" "button"
+ And I wait until "smdi_2_language_value" "field" exists
+ And the focused element should be "#smdi_2_language_value" "css_element"
+ And I set the field "smdi_2_language_value" in the "#qtype-stack-metadata-content" "css_element" to "fr"
+ And I click on "smd_property_1_add" "button"
+ And I wait until "smdi_2_additional_property" "field" exists
+ And the focused element should be "#smdi_2_additional_property" "css_element"
+ And I set the field "smdi_2_additional_property" in the "#qtype-stack-metadata-content" "css_element" to "Cat info"
+ And I set the field "smdi_2_additional_qualifier" in the "#qtype-stack-metadata-content" "css_element" to "Breed"
+ And I set the field "smdi_2_additional_value" in the "#qtype-stack-metadata-content" "css_element" to "Tabby"
+ And I click on "smd_scope_0_add" "button"
+ And I wait until "smdi_3_additional_scope" "field" exists
+ And the focused element should be "#smdi_3_additional_scope" "css_element"
+ And I set the field "smdi_3_additional_scope" in the "#qtype-stack-metadata-content" "css_element" to "More data"
+ And I set the field "smdi_3_additional_property" in the "#qtype-stack-metadata-content" "css_element" to "Question"
+ And I set the field "smdi_3_additional_qualifier" in the "#qtype-stack-metadata-content" "css_element" to "Type"
+ And I set the field "smdi_3_additional_value" in the "#qtype-stack-metadata-content" "css_element" to "MC"
+ And I click on "smd_property_3_add" "button"
+ And I wait until "smdi_4_additional_property" "field" exists
+ And I set the field "smdi_4_additional_property" in the "#qtype-stack-metadata-content" "css_element" to "More"
+ And I set the field "smdi_4_additional_qualifier" in the "#qtype-stack-metadata-content" "css_element" to "Things"
+ And I set the field "smdi_4_additional_value" in the "#qtype-stack-metadata-content" "css_element" to "AAA"
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"author":[{"firstName":"Edmund","lastName":"Farrow","institution":"UoE","year":"2025"},{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2026"},{"firstName":"Teacher","lastName":"Lastname1","institution":"","year":"XXXX"}],"language":["en","fr"],"isPartOf":"HELM","license":"cc-nc-4.0","additional":{"Added data":{"Dog info":{"Breed":"Al$%&^"},"Cat info":{"Breed":"Tabby"}},"More data":{"Question":{"Type":"MC"},"More":{"Things":"AAA"}}},"freeform":{}}'
+ And I click on "View and edit full metadata" "button"
+ And I click on "stack-metadata-edit-switch" "checkbox"
+ And I set the field "smdi_3_additional_scope" in the "#qtype-stack-metadata-content" "css_element" to "Changed"
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"author":[{"firstName":"Edmund","lastName":"Farrow","institution":"UoE","year":"2025"},{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2026"},{"firstName":"Teacher","lastName":"Lastname1","institution":"","year":"XXXX"}],"language":["en","fr"],"isPartOf":"HELM","license":"cc-nc-4.0","additional":{"Added data":{"Dog info":{"Breed":"Al$%&^"},"Cat info":{"Breed":"Tabby"}},"Changed":{"Question":{"Type":"MC"},"More":{"Things":"AAA"}}},"freeform":{}}'
+ And I click on "View and edit full metadata" "button"
+ And I should see "Freeform metadata"
+ And I click on "stack-metadata-edit-switch" "checkbox"
+ And I click on "smd_author_2_delete" "button"
+ And the focused element should be "#smd_author_0_add" "css_element"
+ And I should not see "Smith"
+ And I set the field "smdi_1_language_value" in the "#qtype-stack-metadata-content" "css_element" to "en-del"
+ And I click on "smd_language_1_delete" "button"
+ And the focused element should be "#smd_language_0_add" "css_element"
+ And I should not see "en-del"
+ And I click on "smd_additional_2_delete" "button"
+ And the focused element should be "#smd_property_1_add" "css_element"
+ And I should not see "Cat info"
+ And I click on "smd_language_0_add" "button"
+ And I wait until "smdi_3_language_value" "field" exists
+ And the focused element should be "#smdi_3_language_value" "css_element"
+ And I set the field "smdi_3_language_value" in the "#qtype-stack-metadata-content" "css_element" to "it"
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"author":[{"firstName":"Edmund","lastName":"Farrow","institution":"UoE","year":"2025"},{"firstName":"Teacher","lastName":"Lastname1","institution":"","year":"XXXX"}],"language":["fr","it"],"isPartOf":"HELM","license":"cc-nc-4.0","additional":{"Added data":{"Dog info":{"Breed":"Al$%&^"}},"Changed":{"Question":{"Type":"MC"},"More":{"Things":"AAA"}}},"freeform":{}}'
+ And I click on "View and edit full metadata" "button"
+ And I should see "Freeform metadata"
+ And I click on "stack-metadata-edit-switch" "checkbox"
+ And I click on "smd_scope_1_delete" "button"
+ And the focused element should be "#smd_scope_0_add" "css_element"
+ And I should not see "Added data"
+ And I click on "smd_property_3_add" "button"
+ And I wait until "smdi_5_additional_property" "field" exists
+ And I click on "Validate and close" "button"
+ And I should see "Required" in the "#smde_5_additional_property_error" "css_element"
+ And I click on "smd_additional_5_delete" "button"
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"author":[{"firstName":"Edmund","lastName":"Farrow","institution":"UoE","year":"2025"},{"firstName":"Teacher","lastName":"Lastname1","institution":"","year":"XXXX"}],"language":["fr","it"],"isPartOf":"HELM","license":"cc-nc-4.0","additional":{"Changed":{"Question":{"Type":"MC"},"More":{"Things":"AAA"}}},"freeform":{}}'
+ And I click on "View and edit full metadata" "button"
+ And I should see "Freeform metadata"
+ And I click on "stack-metadata-edit-switch" "checkbox"
+ And I click on "smd_scope_0_add" "button"
+ And I wait until "smdi_5_additional_scope" "field" exists
+ And I set the field "smdi_5_additional_scope" in the "#qtype-stack-metadata-content" "css_element" to "Another Scope"
+ And I set the field "smdi_5_additional_property" in the "#qtype-stack-metadata-content" "css_element" to "Question2"
+ And I set the field "smdi_5_additional_qualifier" in the "#qtype-stack-metadata-content" "css_element" to "Type2"
+ And I set the field "smdi_5_additional_value" in the "#qtype-stack-metadata-content" "css_element" to "MC2"
+ And I click on "smd_property_5_add" "button"
+ And I wait until "smdi_6_additional_property" "field" exists
+ And I set the field "smdi_6_additional_property" in the "#qtype-stack-metadata-content" "css_element" to "More data"
+ And I set the field "smdi_6_additional_qualifier" in the "#qtype-stack-metadata-content" "css_element" to "Things2"
+ And I set the field "smdi_6_additional_value" in the "#qtype-stack-metadata-content" "css_element" to "AAA2"
+ And I click on "smd_property_5_add" "button"
+ And I wait until "smdi_7_additional_property" "field" exists
+ And I set the field "smdi_7_additional_property" in the "#qtype-stack-metadata-content" "css_element" to "More data"
+ And I set the field "smdi_7_additional_qualifier" in the "#qtype-stack-metadata-content" "css_element" to "Type2"
+ And I set the field "smdi_7_additional_value" in the "#qtype-stack-metadata-content" "css_element" to "BBB2"
+ And I click on "smd_property_5_add" "button"
+ And I wait until "smdi_8_additional_property" "field" exists
+ And I set the field "smdi_8_additional_property" in the "#qtype-stack-metadata-content" "css_element" to "No obj"
+ And I set the field "smdi_8_additional_qualifier" in the "#qtype-stack-metadata-content" "css_element" to ""
+ And I set the field "smdi_8_additional_value" in the "#qtype-stack-metadata-content" "css_element" to "BBB3"
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"author":[{"firstName":"Edmund","lastName":"Farrow","institution":"UoE","year":"2025"},{"firstName":"Teacher","lastName":"Lastname1","institution":"","year":"XXXX"}],"language":["fr","it"],"isPartOf":"HELM","license":"cc-nc-4.0","additional":{"Changed":{"Question":{"Type":"MC"},"More":{"Things":"AAA"}},"Another Scope":{"Question2":{"Type2":"MC2"},"More data":{"Things2":"AAA2","Type2":"BBB2"},"No obj":"BBB3"}},"freeform":{}}'
+ And I click on "View and edit full metadata" "button"
+ And I should see "Freeform metadata"
+ And I click on "stack-metadata-edit-switch" "checkbox"
+ And I click on "#qtype-stack-metadata-json-section > summary" "css_element"
+ And I set the field "id_metadata_json" to multiline:
+ """
+ {
+ "author": [
+ {
+ "firstName": "Bob",
+ "lastName": "Smith",
+ "institution": "MIT",
+ "year": "2024"
+ },
+ {
+ "firstName": "Mike",
+ "lastName": "Jones",
+ "institution": "Bath",
+ "year": "2023"
+ }
+ ],
+ "language": [
+ "en"
+ ],
+ "isPartOf": "Everything",
+ "license": "cc-nc-4.1",
+ "additional":
+ {
+ "Added": {
+ "Cat": {
+ "Breed": "Al$%&^"
+ },
+ "Horse": "Dobbin",
+ "Dog": {
+ "Teeth": "50",
+ "Tails": "1"
+ }
+ },
+ "Added too": {
+ "Fish": {
+ "Gills": "2"
+ }
+ }
+ },
+ "freeform": {}
+ }
+ """
+ And I click on "stack-metadata-update-inputs" "button"
+ And the focused element should be "#stack-metadata-update-inputs" "css_element"
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"author":[{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2024"},{"firstName":"Mike","lastName":"Jones","institution":"Bath","year":"2023"}],"language":["en"],"isPartOf":"Everything","license":"cc-nc-4.1","additional":{"Added":{"Cat":{"Breed":"Al$%&^"},"Horse":"Dobbin","Dog":{"Teeth":"50","Tails":"1"}},"Added too":{"Fish":{"Gills":"2"}}},"freeform":{}}'
+ And I click on "View and edit full metadata" "button"
+ And I should see "Freeform metadata"
+ And I click on "stack-metadata-edit-switch" "checkbox"
+ And I click on "smd_property_5_add" "button"
+ And I wait until "smdi_6_additional_property" "field" exists
+ And I set the field "smdi_6_additional_property" in the "#qtype-stack-metadata-content" "css_element" to "Fish"
+ And I set the field "smdi_6_additional_qualifier" in the "#qtype-stack-metadata-content" "css_element" to "Gills"
+ And I set the field "smdi_6_additional_value" in the "#qtype-stack-metadata-content" "css_element" to "3"
+ And I click on "smd_property_1_add" "button"
+ And I wait until "smdi_7_additional_property" "field" exists
+ And I set the field "smdi_7_additional_property" in the "#qtype-stack-metadata-content" "css_element" to "Horse"
+ And I set the field "smdi_7_additional_qualifier" in the "#qtype-stack-metadata-content" "css_element" to ""
+ And I set the field "smdi_7_additional_value" in the "#qtype-stack-metadata-content" "css_element" to "Champion"
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"author":[{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2024"},{"firstName":"Mike","lastName":"Jones","institution":"Bath","year":"2023"}],"language":["en"],"isPartOf":"Everything","license":"cc-nc-4.1","additional":{"Added":{"Cat":{"Breed":"Al$%&^"},"Horse":["Dobbin","Champion"],"Dog":{"Teeth":"50","Tails":"1"}},"Added too":{"Fish":{"Gills":["2","3"]}}},"freeform":{}}'
+ And I click on "View and edit full metadata" "button"
+ And I should see "Freeform metadata"
+ And I click on "stack-metadata-edit-switch" "checkbox"
+ And I click on "smd_property_1_add" "button"
+ And I wait until "smdi_8_additional_property" "field" exists
+ And I set the field "smdi_8_additional_property" in the "#qtype-stack-metadata-content" "css_element" to "Horse"
+ And I set the field "smdi_8_additional_qualifier" in the "#qtype-stack-metadata-content" "css_element" to "Name"
+ And I set the field "smdi_8_additional_value" in the "#qtype-stack-metadata-content" "css_element" to "Nessie"
+ And I click on "Validate and close" "button"
+ And I should see "Required" in the "#smde_7_additional_qualifier_error" "css_element"
+ And I set the field "id_metadata_json" to multiline:
+ """
+ {
+ "author": {
+ "firstName": "Bo1b",
+ "lastName": "Smi1th",
+ "institution": "MI1T",
+ "year": "2024"
+ },
+ {
+ "firstName": "Mi1ke",
+ "lastName": "Jon1es",
+ "institution": "1ath",
+ "year": "2023"
+ },
+ {
+ "firstName": "Helen",
+ "lastName": "Lowell",
+ "institution": "Bath",
+ "year": "2023"
+ }
+ ],
+ "language": [
+ "edfsedn"
+ ],
+ "isPartOf": "Eve1rything",
+ "license": "public",
+ "additional": {
+ "Adfded": {
+ "Cfat": {
+ "Bfreed": "Al$%f&^"
+ },
+ "Dfog": {
+ "Tefeth": "5f0"
+ }
+ },
+ "Addfed too": {
+ "Fifsh": {
+ "Giflls": "2f"
+ }
+ }
+ },
+ "freeform": {}
+ }
+ """
+ And I click on "stack-metadata-update-inputs" "button"
+ And I wait until "smdi_2_author_firstName" "field" exists
+ And I click on "stack-metadata-revert" "button"
+ And the focused element should be "#stack-metadata-revert" "css_element"
+ And I should not see "Lowell"
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"author":[{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2024"},{"firstName":"Mike","lastName":"Jones","institution":"Bath","year":"2023"}],"language":["en"],"isPartOf":"Everything","license":"cc-nc-4.1","additional":{"Added":{"Cat":{"Breed":"Al$%&^"},"Horse":["Dobbin","Champion"],"Dog":{"Teeth":"50","Tails":"1"}},"Added too":{"Fish":{"Gills":["2","3"]}}},"freeform":{}}'
+ And I press "id_updatebutton"
+ Given the site is running Moodle version 4.6 or lower
+ Then I should see "Version 2"
+ And I check the hidden input "metadata" is '{"author":[{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2024"},{"firstName":"Mike","lastName":"Jones","institution":"Bath","year":"2023"}],"language":["en"],"isPartOf":"Everything","license":"cc-nc-4.1","additional":{"Added":{"Cat":{"Breed":"Al$%&^"},"Horse":["Dobbin","Champion"],"Dog":{"Teeth":"50","Tails":"1"}},"Added too":{"Fish":{"Gills":["2","3"]}}},"freeform":{}}'
+ Given the site is running Moodle version 5.0 or higher
+ Then I should see "v2 (latest)"
+ And I check the hidden input "metadata" is '{"author":[{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2024"},{"firstName":"Mike","lastName":"Jones","institution":"Bath","year":"2023"}],"language":["en"],"isPartOf":"Everything","license":"cc-nc-4.1","additional":{"Added":{"Cat":{"Breed":"Al$%&^"},"Horse":["Dobbin","Champion"],"Dog":{"Teeth":"50","Tails":"1"}},"Added too":{"Fish":{"Gills":["2","3"]}}},"freeform":{}}'
+
+ @javascript
+ Scenario: Create and edit STACK Freeform metadata
+ When I am on the "Algebraic input" "core_question > edit" page logged in as teacher
+ And I click on "View and edit full metadata" "button"
+ And I should see "Freeform metadata"
+ And I click on "stack-metadata-edit-switch" "checkbox"
+ And I click on "#qtype-stack-metadata-json-section > summary" "css_element"
+ And I set the field "id_metadata_json" to multiline:
+ """
+ {
+ "author": [
+ {
+ "firstName": "Bob",
+ "lastName": "Smith",
+ "institution": "MIT",
+ "year": "2024"
+ },
+ {
+ "firstName": "Mike",
+ "lastName": "Jones",
+ "institution": "Bath",
+ "year": "2023"
+ }
+ ],
+ "language": [
+ "en"
+ ],
+ "isPartOf": "Everything",
+ "license": "cc-nc-4.1",
+ "additional":
+ {
+ "additional": {
+ "Cat": {
+ "Breed": "Al$%&^"
+ },
+ "Horse": "Dobbin",
+ "Dog": {
+ "Teeth": "50",
+ "Tails": "1"
+ },
+ "Multi": [
+ 1,2,3
+ ],
+ "Multi1": {
+ "Multi2": [
+ 4,5,6
+ ]
+ }
+ },
+ "Added too": {
+ "Fish": {
+ "Gills": "2"
+ }
+ }
+ },
+ "freeform":
+ {
+ "license": {
+ "Cat": {
+ "Breed": "Al$%&^"
+ },
+ "Horse": "Dobbin",
+ "Dog": {
+ "Teeth": "50",
+ "Tails": "1"
+ }
+ },
+ "Freeform too": {
+ "Fish": {
+ "Gills": "2"
+ }
+ }
+ }
+ }
+ """
+ And I click on "stack-metadata-update-inputs" "button"
+ And the focused element should be "#stack-metadata-update-inputs" "css_element"
+ And I should see "{\"license\":{\"Cat\":{\"Breed\":\"Al$%&^\"},\"Horse\":\"Dobbin\",\"Dog\":{\"Teeth\":\"50\",\"Tails\":\"1\"}},\"Freeform too\":{\"Fish\":{\"Gills\":\"2\"}}}"
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"author":[{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2024"},{"firstName":"Mike","lastName":"Jones","institution":"Bath","year":"2023"}],"language":["en"],"isPartOf":"Everything","license":"cc-nc-4.1","additional":{"additional":{"Cat":{"Breed":"Al$%&^"},"Horse":"Dobbin","Dog":{"Teeth":"50","Tails":"1"},"Multi":["1","2","3"],"Multi1":{"Multi2":["4","5","6"]}},"Added too":{"Fish":{"Gills":"2"}}},"freeform":{"license":{"Cat":{"Breed":"Al$%&^"},"Horse":"Dobbin","Dog":{"Teeth":"50","Tails":"1"}},"Freeform too":{"Fish":{"Gills":"2"}}}}'
+ And I click on "View and edit full metadata" "button"
+ And I should see "Freeform metadata"
+ And I click on "stack-metadata-edit-switch" "checkbox"
+ And I set the field "smdi_0_freeform_value" to multiline:
+ """
+ {"x":[{"additional":"b"},{"license":"d"}]}
+ """
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"author":[{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2024"},{"firstName":"Mike","lastName":"Jones","institution":"Bath","year":"2023"}],"language":["en"],"isPartOf":"Everything","license":"cc-nc-4.1","additional":{"additional":{"Cat":{"Breed":"Al$%&^"},"Horse":"Dobbin","Dog":{"Teeth":"50","Tails":"1"},"Multi":["1","2","3"],"Multi1":{"Multi2":["4","5","6"]}},"Added too":{"Fish":{"Gills":"2"}}},"freeform":{"x":[{"additional":"b"},{"license":"d"}]}}'
+
+ @javascript
+ Scenario: New question metadata
+ When I am on the "Course 1" "core_question > course question bank" page logged in as "teacher"
+ And I click on "Create a new question" "button"
+ And I set the field "item_qtype_stack" to "1"
+ And I press "submitbutton"
+ And I click on "View and edit full metadata" "button"
+ And I should see "Freeform metadata"
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"author":[{"firstName":"Teacher","lastName":"Lastname1","institution":"","year":"XXXX"}],"language":["en"],"license":"unknown","isPartOf":"","additional":{},"freeform":{}}'
diff --git a/tests/helper.php b/tests/helper.php
index d425d92efd4..0fbcf224c1a 100644
--- a/tests/helper.php
+++ b/tests/helper.php
@@ -159,6 +159,8 @@ protected static function make_a_stack_question() {
$q->questionnote = '';
$q->questionnoteformat = FORMAT_HTML;
$q->isbroken = 0;
+ $q->metadata = '';
+ $q->prescribedmetadata = '';
return $q;
}
@@ -226,6 +228,9 @@ public static function make_stack_question_test0() {
$prt->nodes[] = $newnode;
$q->prts[$prt->name] = new stack_potentialresponse_tree_lite($prt, $prt->value, $q);
+ $q->isbroken = 0;
+ $q->metadata = '';
+ $q->prescribedmetadata = '';
return $q;
}
@@ -389,6 +394,9 @@ public function get_stack_question_form_data_test1() {
1 => ['text' => '