diff --git a/.github/workflows/moodle-ci.yml b/.github/workflows/moodle-ci.yml
index 757c4b1d353..3d560b4f630 100644
--- a/.github/workflows/moodle-ci.yml
+++ b/.github/workflows/moodle-ci.yml
@@ -264,6 +264,13 @@ jobs:
#if: ${{ always() }}
run: moodle-plugin-ci phpunit
+ - name: JS unit tests
+ if: ${{matrix.moodle-branch == 'MOODLE_502_STABLE'}}
+ run: |
+ cd plugin/tests/jest
+ npm install
+ npm test
+
- name: Behat features
if: ${{ always() }}
run: moodle-plugin-ci behat --profile chrome --auto-rerun 12
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..c4ec87207f6
--- /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",ADDITEM:'[name="smd_add"]',DELETEITEM:'[name="smd_delete"]',MAKECONTRIBUTOR:"#stack-metadata-make-contributor",MAKECREATOR:"#stack-metadata-make-creator",REVERT:"#stack-metadata-revert",FORMJSON:'input[name="metadata"]',JSONINPUT:"#id_metadata_json",REQUIREDINPUTS:'#qtype-stack-metadata-content input[aria-required="true"]',ALLINPUTS:'#qtype-stack-metadata-content [id^="smdi"]'},_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){let{state:state}=_ref;const data={creator:{},contributor:[],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||"{}")};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.contributor.forEach((contributor=>{const element={firstname:this.createDataElement(!1,contributor.id,"contributor_firstName",contributor.firstName),lastname:this.createDataElement(!1,contributor.id,"contributor_lastName",contributor.lastName),institution:this.createDataElement(!1,contributor.id,"contributor_institution",contributor.institution),year:this.createDataElement(!1,contributor.id,"contributor_year",contributor.year),id:contributor.id};data.contributor.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){const current={name:scope,firstProp:scopeHolder[scope][0].id,properties:scopeHolder[scope],input:this.createDataElement(!0,scopeHolder[scope][0].id,"additional_scope",scope)};data.scope.push(current)}data.creator={firstname:this.createDataElement(!1,0,"creator_firstName",state.creator.firstName),lastname:this.createDataElement(!1,0,"creator_lastName",state.creator.lastName),institution:this.createDataElement(!1,0,"creator_institution",state.creator.institution),year:this.createDataElement(!1,0,"creator_year",state.creator.year)},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),this.addEventListener(this.getElement(this.selectors.UPDATEJSON),"click",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",this.updateInputs),this.addEventListener(this.getElement(this.selectors.MAKECREATOR),"click",this.makeCreator),this.addEventListener(this.getElement(this.selectors.MAKECONTRIBUTOR),"click",this.makeContributor),this.addEventListener(this.getElement(this.selectors.REVERT),"click",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}}async update(){if(!(arguments.length>0&&void 0!==arguments[0])||arguments[0]){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}if(isError)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.target.id.split("_");this.reactive.dispatch("addItem",parts[1],parts[2])}}async deleteItem(event){if(await this.update(!1)){const parts=event.target.id.split("_");this.reactive.dispatch("deleteRow",parts[1],parts[2])}}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),this.reactive.dispatch("updateFromJson",data)}async makeContributor(){await this.update(!1)&&this.reactive.dispatch("addItem","contributor","user")}makeCreator(){this.getElement("#smdi_0_creator_firstName").value=_metadata.metadata.lib.user.firstname,this.getElement("#smdi_0_creator_lastName").value=_metadata.metadata.lib.user.lastname,this.getElement("#smdi_0_creator_institution").value=_metadata.metadata.lib.user.institution,this.getElement("#smdi_0_creator_year").value=(new Date).getFullYear()}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,_metadata.metadata.lib.brokenMetadata=e.message,void this.reactive.dispatch("updateFromJson",_metadata.metadata.jsonToState("{}"))}jsonElement.value=_metadata.metadata.jsonStringify(previousdata,4),this.reactive.dispatch("updateFromJson",previousdata)}}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..34716fce986
--- /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 ADDITEM: `[name=\"smd_add\"]`,\n DELETEITEM: `[name=\"smd_delete\"]`,\n MAKECONTRIBUTOR: `#stack-metadata-make-contributor`,\n MAKECREATOR: `#stack-metadata-make-creator`,\n REVERT: `#stack-metadata-revert`,\n FORMJSON: 'input[name=\"metadata\"]',\n JSONINPUT: '#id_metadata_json',\n REQUIREDINPUTS: '#qtype-stack-metadata-content input[aria-required=\"true\"]',\n ALLINPUTS: '#qtype-stack-metadata-content [id^=\"smdi\"]',\n };\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 // Mustache data is not fully compatible with state object so we need to convert it\n // into a plain object.\n const data = {\n creator: {},\n contributor: [],\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 };\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.contributor.forEach(contributor => {\n const element = {\n firstname: this.createDataElement(false, contributor.id, 'contributor_firstName', contributor.firstName),\n lastname: this.createDataElement(false, contributor.id, 'contributor_lastName', contributor.lastName),\n institution: this.createDataElement(false, contributor.id, 'contributor_institution', contributor.institution),\n year: this.createDataElement(false, contributor.id, 'contributor_year', contributor.year),\n id: contributor.id,\n };\n data.contributor.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 current = {\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 };\n data.scope.push(current);\n }\n\n data.creator = {\n firstname: this.createDataElement(false, 0, 'creator_firstName', state.creator.firstName),\n lastname: this.createDataElement(false, 0, 'creator_lastName', state.creator.lastName),\n institution: this.createDataElement(false, 0, 'creator_institution', state.creator.institution),\n year: this.createDataElement(false, 0, 'creator_year', state.creator.year),\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 this.addEventListener(\n this.getElement(this.selectors.UPDATEJSON),\n 'click',\n this.update\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 this.updateInputs\n );\n this.addEventListener(\n this.getElement(this.selectors.MAKECREATOR),\n 'click',\n this.makeCreator\n );\n this.addEventListener(\n this.getElement(this.selectors.MAKECONTRIBUTOR),\n 'click',\n this.makeContributor\n );\n this.addEventListener(\n this.getElement(this.selectors.REVERT),\n 'click',\n this.revert\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\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 a contributor. 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) {\n // TO-DO Do we need other validation and/or different required fields.\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 if (isError) {\n return false;\n }\n }\n // Elements have ids in form smdi_id_category_field e.g. smdi_1_contributor_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.target.id.split('_');\n 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.target.id.split('_');\n 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 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 this.reactive.dispatch('updateFromJson', data);\n }\n\n /**\n * Add the current user as a contributor.\n */\n async makeContributor() {\n const result = await this.update(false);\n if (result) {\n this.reactive.dispatch('addItem', 'contributor', 'user');\n }\n }\n\n /**\n * Make current user the creator.\n */\n makeCreator() {\n this.getElement('#smdi_0_creator_firstName').value = metadata.lib.user.firstname;\n this.getElement('#smdi_0_creator_lastName').value = metadata.lib.user.lastname;\n this.getElement('#smdi_0_creator_institution').value = metadata.lib.user.institution;\n this.getElement('#smdi_0_creator_year').value = new Date().getFullYear();\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 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 this.reactive.dispatch('updateFromJson', metadata.jsonToState('{}'));\n return;\n }\n jsonElement.value = metadata.jsonStringify(previousdata, 4);\n this.reactive.dispatch('updateFromJson', previousdata);\n }\n}"],"names":["BaseComponent","create","name","selectors","METADATACONTAINER","UPDATEJSON","UPDATEINPUTS","ADDITEM","DELETEITEM","MAKECONTRIBUTOR","MAKECREATOR","REVERT","FORMJSON","JSONINPUT","REQUIREDINPUTS","ALLINPUTS","container","this","target","element","document","querySelector","reactive","metadata","state","reloadContainerComponent","getWatchers","watch","handler","createDataElement","required","id","tag","value","wrapperid","iderror","data","creator","contributor","language","license","isPartOf","scope","freeform","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","current","firstProp","properties","input","json","jsonStringify","attributes","metadataContainer","getElement","Error","renderComponent","addEventListener","update","addButtons","getElements","addButton","addItem","deleteButtons","deleteButton","deleteItem","updateInputs","makeCreator","makeContributor","revert","brokenMetadata","jsonElement","requiredElements","isError","classList","contains","freeformElement","trim","e","message","inputElements","Array","from","map","el","dispatch","addIds","split","event","parts","jsonToState","user","Date","getFullYear","previousdataJSON","previousdata"],"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..bda54ab929b
--- /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"contributor":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 contribFields=["id","firstName","lastName","institution","year"],standardFields=["id","value"];(data=this.stripFields(data,["creator","contributor","language","license","isPartOf","additional","freeform"])).creator=this.tidyObject(data.creator,["firstName","lastName","institution","year"]),data.contributor=Array.isArray(data.contributor)?data.contributor:[];const contribHolder=[];for(let contrib of data.contributor)contrib=this.tidyObject(contrib,contribFields),contribHolder.push(contrib);data.contributor=contribHolder,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);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..098ad4058a1
--- /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 'contributor':\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 = ['creator', 'contributor', 'language', 'license', 'isPartOf', 'additional', 'freeform'];\n data = this.stripFields(data, fields);\n const creatorFields = ['firstName', 'lastName', 'institution', 'year'];\n const contribFields = ['id', 'firstName', 'lastName', 'institution', 'year'];\n const standardFields = ['id', 'value'];\n\n data.creator = this.tidyObject(data.creator, creatorFields);\n data.contributor = (Array.isArray(data.contributor)) ? data.contributor : [];\n const contribHolder = [];\n for (let contrib of data.contributor) {\n contrib = this.tidyObject(contrib, contribFields);\n contribHolder.push(contrib);\n }\n data.contributor = contribHolder;\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 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\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","contribFields","standardFields","stripFields","creator","tidyObject","contributor","contribHolder","contrib","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..f77a208120c
--- /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(){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)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)}_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..846bccf90c0
--- /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 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 modal.show();\n }\n if (addListener) {\n document.querySelector('#stackmetadata_cancel').addEventListener('click', closeModal);\n }\n}\n\n"],"names":["MetadataModal","Modal","metadata","container","update","current","document","querySelector","newValue","jsonStringify","state","value","textContent","getAttribute","e","hide","cancel","registered","create","register","TYPE","TEMPLATE","registerModal","modal","closeModal","call","openModal","addListener","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,eACUD,6DA9BDf,qBACK,6CADLA,yBAES,0CAgClBiB,YAAa,EACZA,kCAwBmC,mBAAzBjB,cAAckB,OAAuB,onBAE9BC,SAASnB,cAAcoB,KAAMpB,cAAeA,cAAcqB,WAzB5EC,GACAL,YAAa,OAGbM,MAAQ,cAWHC,aACLD,MAAMP,OAAOS,KAAKF,sBAiBPG,gBACPC,aAAc,KACbJ,MAgBDA,MAAMK,WAhBE,IAC4B,mBAAzB5B,cAAckB,OACrBK,YAAcvB,cAAckB,SAC5BK,MAAMK,WACH,OAEGC,2nBACNN,YAAcM,aAAaX,OAAO,CAC9BY,KAAM9B,cAAcoB,OAExBG,MAAMK,0BAEIG,KAAK,8BAEnBJ,aAAc,EAIdA,aACArB,SAASC,cAAc,yBAAyByB,iBAAiB,QAASR,2BA/C7D,6DAClBlB,SAASC,cAAc,6EAAsByB,iBAAiB,QAASN,8BAC9DO"}
\ 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..b5a9912f676
--- /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{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;switch(category){case"language":newItem={value:""};break;case"contributor":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())};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..911be80f6b7
--- /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 * Update state from array of input field information.\n *\n * Inputs have ids in form smdi-id-category-field e.g. smdi-1-contributor-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-contributor-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 switch (category) {\n case 'language':\n newItem = {\n value: \"\"\n };\n break;\n case 'contributor':\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 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();"],"names":["mutations","stateManager","inputArray","state","additionalCopy","additional","forEach","addInfo","rowCopy","scope","property","qualifier","value","id","field","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","String","Date","getFullYear","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..59af6eb27a0
--- /dev/null
+++ b/amd/src/metadata/container.js
@@ -0,0 +1,400 @@
+// 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`,
+ ADDITEM: `[name="smd_add"]`,
+ DELETEITEM: `[name="smd_delete"]`,
+ MAKECONTRIBUTOR: `#stack-metadata-make-contributor`,
+ MAKECREATOR: `#stack-metadata-make-creator`,
+ REVERT: `#stack-metadata-revert`,
+ FORMJSON: 'input[name="metadata"]',
+ JSONINPUT: '#id_metadata_json',
+ REQUIREDINPUTS: '#qtype-stack-metadata-content input[aria-required="true"]',
+ ALLINPUTS: '#qtype-stack-metadata-content [id^="smdi"]',
+ };
+ 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}) {
+ // Mustache data is not fully compatible with state object so we need to convert it
+ // into a plain object.
+ const data = {
+ creator: {},
+ contributor: [],
+ 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 || '{}'),
+ };
+
+ // 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.contributor.forEach(contributor => {
+ const element = {
+ firstname: this.createDataElement(false, contributor.id, 'contributor_firstName', contributor.firstName),
+ lastname: this.createDataElement(false, contributor.id, 'contributor_lastName', contributor.lastName),
+ institution: this.createDataElement(false, contributor.id, 'contributor_institution', contributor.institution),
+ year: this.createDataElement(false, contributor.id, 'contributor_year', contributor.year),
+ id: contributor.id,
+ };
+ data.contributor.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 current = {
+ name: scope,
+ firstProp: scopeHolder[scope][0].id,
+ properties: scopeHolder[scope],
+ input: this.createDataElement(true, scopeHolder[scope][0].id, 'additional_scope', scope)
+ };
+ data.scope.push(current);
+ }
+
+ data.creator = {
+ firstname: this.createDataElement(false, 0, 'creator_firstName', state.creator.firstName),
+ lastname: this.createDataElement(false, 0, 'creator_lastName', state.creator.lastName),
+ institution: this.createDataElement(false, 0, 'creator_institution', state.creator.institution),
+ year: this.createDataElement(false, 0, 'creator_year', state.creator.year),
+ };
+
+ 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.
+ this.addEventListener(
+ this.getElement(this.selectors.UPDATEJSON),
+ 'click',
+ 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',
+ this.updateInputs
+ );
+ this.addEventListener(
+ this.getElement(this.selectors.MAKECREATOR),
+ 'click',
+ this.makeCreator
+ );
+ this.addEventListener(
+ this.getElement(this.selectors.MAKECONTRIBUTOR),
+ 'click',
+ this.makeContributor
+ );
+ this.addEventListener(
+ this.getElement(this.selectors.REVERT),
+ 'click',
+ 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;
+ }
+ }
+
+ /**
+ * 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 a contributor. 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) {
+ // TO-DO Do we need other validation and/or different required fields.
+ 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;
+ }
+ }
+ if (isError) {
+ return false;
+ }
+ }
+ // Elements have ids in form smdi_id_category_field e.g. smdi_1_contributor_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.target.id.split('_');
+ 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.target.id.split('_');
+ this.reactive.dispatch('deleteRow', parts[1], parts[2]);
+ }
+ }
+
+ /**
+ * Update state from the currently entered JSON if JSON is valid.
+ */
+ 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);
+ this.reactive.dispatch('updateFromJson', data);
+ }
+
+ /**
+ * Add the current user as a contributor.
+ */
+ async makeContributor() {
+ const result = await this.update(false);
+ if (result) {
+ this.reactive.dispatch('addItem', 'contributor', 'user');
+ }
+ }
+
+ /**
+ * Make current user the creator.
+ */
+ makeCreator() {
+ this.getElement('#smdi_0_creator_firstName').value = metadata.lib.user.firstname;
+ this.getElement('#smdi_0_creator_lastName').value = metadata.lib.user.lastname;
+ this.getElement('#smdi_0_creator_institution').value = metadata.lib.user.institution;
+ this.getElement('#smdi_0_creator_year').value = new Date().getFullYear();
+ }
+
+ /**
+ * 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.
+ */
+ 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;
+ this.reactive.dispatch('updateFromJson', metadata.jsonToState('{}'));
+ return;
+ }
+ jsonElement.value = metadata.jsonStringify(previousdata, 4);
+ this.reactive.dispatch('updateFromJson', previousdata);
+ }
+}
\ No newline at end of file
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..f611e3da6d9
--- /dev/null
+++ b/amd/src/metadata/metadata.js
@@ -0,0 +1,339 @@
+// 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 'contributor':
+ 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 = ['creator', 'contributor', 'language', 'license', 'isPartOf', 'additional', 'freeform'];
+ data = this.stripFields(data, fields);
+ const creatorFields = ['firstName', 'lastName', 'institution', 'year'];
+ const contribFields = ['id', 'firstName', 'lastName', 'institution', 'year'];
+ const standardFields = ['id', 'value'];
+
+ data.creator = this.tidyObject(data.creator, creatorFields);
+ data.contributor = (Array.isArray(data.contributor)) ? data.contributor : [];
+ const contribHolder = [];
+ for (let contrib of data.contributor) {
+ contrib = this.tidyObject(contrib, contribFields);
+ contribHolder.push(contrib);
+ }
+ data.contributor = contribHolder;
+ 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);
+ 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..6ef463a6eec
--- /dev/null
+++ b/amd/src/metadata/metadatamodal.js
@@ -0,0 +1,121 @@
+// 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() {
+ 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 {
+ modal.show();
+ }
+ if (addListener) {
+ document.querySelector('#stackmetadata_cancel').addEventListener('click', closeModal);
+ }
+}
+
diff --git a/amd/src/metadata/mutations.js b/amd/src/metadata/mutations.js
new file mode 100644
index 00000000000..02573a44cc6
--- /dev/null
+++ b/amd/src/metadata/mutations.js
@@ -0,0 +1,241 @@
+// 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 {
+ /**
+ * Update state from array of input field information.
+ *
+ * Inputs have ids in form smdi-id-category-field e.g. smdi-1-contributor-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-contributor-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;
+ switch (category) {
+ case 'language':
+ newItem = {
+ value: ""
+ };
+ break;
+ case 'contributor':
+ 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())
+ };
+ 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();
\ No newline at end of file
diff --git a/api/util/StackQuestionLoader.php b/api/util/StackQuestionLoader.php
index 34fb042bc4d..8acdccf10d1 100644
--- a/api/util/StackQuestionLoader.php
+++ b/api/util/StackQuestionLoader.php
@@ -211,6 +211,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..62bce52d5ef 100644
--- a/db/upgrade.php
+++ b/db/upgrade.php
@@ -1045,6 +1045,35 @@ function xmldb_qtype_stack_upgrade($oldversion) {
// STACK savepoint reached.
upgrade_plugin_savepoint(true, 2026042402, 'qtype', 'stack');
}
+
+
+ if ($oldversion < 2026042600) {
+ // 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, 2026042600, 'qtype', 'stack');
+ }
+
+ if ($oldversion < 2026042700) {
+ // 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, 2026042700, '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..e8020029aca
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..66afce4db54
--- /dev/null
+++ b/doc/en/Authoring/Metadata.md
@@ -0,0 +1,153 @@
+# 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:
+```
+{
+ "creator": {
+ "firstName": "Current", //$USER->firstname
+ "lastName": "User", // $USER->lastname
+ "institution": "", // $USER->institution
+ "year": "2026" // Current year
+ },
+ "contributor": [],
+ "language": [
+ "en" // Current language
+ ],
+ "license": "unknown", // $CFG->sitedefaultlicense
+ "isPartOf": "",
+ "additional": {},
+ "freeform" {}
+}
+```
+A question has one creator but can have multiple contributors. `Last name` is required.
+
+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.
+
+
+
+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:
+```
+{
+ "creator": {
+ "firstName": "Dave",
+ "lastName": "Summers",
+ "institution": "Edinburgh",
+ "year": "2025"
+ },
+ "contributor": [
+ {
+ "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}
+
+
+ {"creator":{"firstName":"Dave","lastName":"Summers","institution":"Edinburgh","year":"2025"},"contributor":[{"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/Development_track.md b/doc/en/Developer/Development_track.md
index 7a61b9267a7..c16ebf33403 100644
--- a/doc/en/Developer/Development_track.md
+++ b/doc/en/Developer/Development_track.md
@@ -12,6 +12,7 @@ Issues with [github milestone 4.13.0](https://github.com/maths/moodle-qtype_stac
1. Remove all "cte" code from Maxima - mostly install.
2. Support for Maxima 5.47.0, 5.48.0, and 5.49.0. This includes a fix for issue #1281 from 5.48.0.
3. Question tests can now test the whole route through a PRT, rather than just the final node. This is a significant improvement on the ability to test questions. This is back-compatible with older questions.
+4. Add in flexible [question metadata](../Authoring/Metadata.md). This fixes issue #1171.
--------------------------------------
diff --git a/doc/en/Developer/Future_plans.md b/doc/en/Developer/Future_plans.md
index 6d862f88b26..c80e832f331 100644
--- a/doc/en/Developer/Future_plans.md
+++ b/doc/en/Developer/Future_plans.md
@@ -137,7 +137,6 @@ Basic reports now work.
* Text based potential response trees (would allow for easier copying of complicated trees, etc).
* Changes to preset feedback to certain answer tests which might be more appropriate for different audiences. Could a 'simplified' English language pack allow for this (future changes might allow this to be done on a question-by-question basis).
* Check for potential issues with default correct/incorrect feedback for different languages (defaults can already be set on the server level by a Moodle administrator).
-* Metadata on language for questions.
* Tools for language integrity (e.g. making it easier to identify what languages are in each question).
* Making sure Maxima knows the intended language (will allow for Maxima code to choose from the available languages).
* May want to have further discussions on how scores and penalties are handled (there is already a new feature in the latest version of STACK so that you can include functions in the "score" field.
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 6c0005dd32e..eeda8ebfbd2 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 ca5af061159..d282322549c 100644
--- a/edit_stack_form.php
+++ b/edit_stack_form.php
@@ -221,7 +221,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();
@@ -271,6 +271,60 @@ 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 = '{"creator":{"firstName":"' . ($USER->firstname ?? '') . '","lastName":"' . ($USER->lastname ?? '') . '",' .
+ '"institution":"' . ($USER->institution ?? '') . '","year":"' . date('Y') . '"},' .
+ '"contributor":[],"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 !== '{}') {
+ $metadatasummary = stack_string('creator') . ': ' .
+ ($metadataobj->creator->firstName ?? '') . ' ' . ($metadataobj->creator->lastName ?? '');
+ if (isset($metadataobj->contributor) && count($metadataobj->contributor)) {
+ $contribsummary = '';
+ foreach ($metadataobj->contributor as $contrib) {
+ $contribsummary .= ($contribsummary) ? ', ' : '';
+ $contribsummary .= ($contrib->firstName ?? '') . ' ' . ($contrib->lastName ?? '');
+ }
+ $metadatasummary .= '; ' . stack_string('contributor') . ': ' . $contribsummary;
+ }
+ } 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.
@@ -329,8 +383,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),
@@ -1117,6 +1171,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 bbdfbdce6a2..15a28389401 100644
--- a/lang/en/qtype_stack.php
+++ b/lang/en/qtype_stack.php
@@ -418,6 +418,39 @@
$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['contributor'] = 'Contributor';
+$string['creator'] = 'Creator';
+$string['addcontributor'] = 'Add contributor';
+$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['makemecontributor'] = 'Add me as a contributor';
+$string['makemecreator'] = 'Make me the creator';
+$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['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';
// Strings used by input elements.
$string['studentinputtoolong'] = 'Your input is longer than permitted by STACK.';
diff --git a/question.php b/question.php
index 6f2a2993ba2..0259829641e 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 c829d59aafe..4299b345a2c 100644
--- a/stack/maxima/stackmaxima.mac
+++ b/stack/maxima/stackmaxima.mac
@@ -1044,7 +1044,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. */
@@ -1139,7 +1139,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]),
@@ -3563,4 +3563,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:2026042402$
+stackmaximaversion:2026042700$
diff --git a/styles.css b/styles.css
index b8a5e47fffe..23e9d5f8e38 100644
--- a/styles.css
+++ b/styles.css
@@ -1293,3 +1293,18 @@ USE OR OTHER DEALINGS IN THE SOFTWARE.
.stacktestsuite .results-symbol {
font-size: 1.5em;
}
+.stack-metadata input {
+ width: 98%;
+}
+.stack-metadata .inputcol {
+ padding-left: 0;
+ padding-right: 0;
+}
+.stack-metadata .scope {
+ padding: 20px 10px 0 10px;
+}
+.stack-metadata .btn-danger {
+ background-color: pink;
+ border-color: pink;
+ color: black;
+}
diff --git a/templates/metadata/metadataadditional.mustache b/templates/metadata/metadataadditional.mustache
new file mode 100644
index 00000000000..c858ef19099
--- /dev/null
+++ b/templates/metadata/metadataadditional.mustache
@@ -0,0 +1,81 @@
+{{!
+ 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"
+ }
+}}
+
\ No newline at end of file
diff --git a/templates/metadata/metadatalanguage.mustache b/templates/metadata/metadatalanguage.mustache
new file mode 100644
index 00000000000..6ee3dfea213
--- /dev/null
+++ b/templates/metadata/metadatalanguage.mustache
@@ -0,0 +1,47 @@
+{{!
+ 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/metadatalanguage
+
+ Example context (json):
+ {
+ "id": "1",
+ "lang": {
+ "required": true,
+ "element": {
+ "value": "en",
+ "wrapperid": "fitem_smdi_1_language_value",
+ "id": "smdi_1_language_value",
+ "name": "smdi_1_language_value",
+ "iderror": "smde_1_language_error"
+ }
+ }
+ }
+}}
+
\ No newline at end of file
diff --git a/templates/metadata/metadatamodal.mustache b/templates/metadata/metadatamodal.mustache
new file mode 100644
index 00000000000..f5276190fe6
--- /dev/null
+++ b/templates/metadata/metadatamodal.mustache
@@ -0,0 +1,39 @@
+{{!
+ 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 }}
+ {{$title}}{{#str}} metadata, qtype_stack {{/str}}{{/title}}
+ {{$classes}}modal-xl stack-metadata{{/classes}}
+ {{$body}}
+
+
+
+
+
+ {{/body}}
+ {{$footer}}
+ {{/footer}}
+{{/ core/modal }}
+{{#js}}
+require(['qtype_stack/metadata/container'], function(component) {
+ component.init('#qtype-stack-metadata-main');
+});
+{{/js}}
\ No newline at end of file
diff --git a/templates/metadata/metadatascope.mustache b/templates/metadata/metadatascope.mustache
new file mode 100644
index 00000000000..fd8fd406031
--- /dev/null
+++ b/templates/metadata/metadatascope.mustache
@@ -0,0 +1,162 @@
+{{!
+ 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/metadatascope
+ Example context (json):
+ {
+ "name": "d",
+ "firstProp": "1",
+ "properties": [
+ {
+ "property": {
+ "required": true,
+ "element": {
+ "value": "e",
+ "wrapperid": "fitem_smdi_1_additional_property",
+ "id": "smdi_1_additional_property",
+ "name": "smdi_1_additional_property",
+ "iderror": "smde_1_additional_property_error"
+ }
+ },
+ "qualifier": {
+ "required": false,
+ "element": {
+ "value": "f",
+ "wrapperid": "fitem_smdi_1_additional_qualifier",
+ "id": "smdi_1_additional_qualifier",
+ "name": "smdi_1_additional_qualifier",
+ "iderror": "smde_1_additional_qualifier_error"
+ }
+ },
+ "value": {
+ "required": false,
+ "element": {
+ "value": "g",
+ "wrapperid": "fitem_smdi_1_additional_value",
+ "id": "smdi_1_additional_value",
+ "name": "smdi_1_additional_value",
+ "iderror": "smde_1_additional_value_error"
+ }
+ },
+ "id": "1"
+ },
+ {
+ "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"
+ },
+ {
+ "property": {
+ "required": true,
+ "element": {
+ "value": "k",
+ "wrapperid": "fitem_smdi_3_additional_property",
+ "id": "smdi_3_additional_property",
+ "name": "smdi_3_additional_property",
+ "iderror": "smde_3_additional_property_error"
+ }
+ },
+ "qualifier": {
+ "required": false,
+ "element": {
+ "value": "l",
+ "wrapperid": "fitem_smdi_3_additional_qualifier",
+ "id": "smdi_3_additional_qualifier",
+ "name": "smdi_3_additional_qualifier",
+ "iderror": "smde_3_additional_qualifier_error"
+ }
+ },
+ "value": {
+ "required": false,
+ "element": {
+ "value": "m",
+ "wrapperid": "fitem_smdi_3_additional_value",
+ "id": "smdi_3_additional_value",
+ "name": "smdi_3_additional_value",
+ "iderror": "smde_3_additional_value_error"
+ }
+ },
+ "id": "3"
+ }
+ ],
+ "input": {
+ "required": true,
+ "element": {
+ "value": "d",
+ "wrapperid": "fitem_smdi_1_additional_scope",
+ "id": "smdi_1_additional_scope",
+ "name": "smdi_1_additional_scope",
+ "iderror": "smde_1_additional_scope_error"
+ }
+ }
+ }
+}}
+
\ No newline at end of file
diff --git a/templates/metadata/metadatayear.mustache b/templates/metadata/metadatayear.mustache
new file mode 100644
index 00000000000..5952d366468
--- /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_contributor_year",
+ "id": "smdi_1_contributor_year",
+ "name": "smdi_1_contributor_year",
+ "iderror": "smde_1_contributor_year_error"
+ }
+ }
+}}
+{{< core_form/element-template-inline }}
+ {{$element}}
+
+ {{/element}}
+{{/ core_form/element-template-inline }}
\ No newline at end of file
diff --git a/tests/behat/behat_qtype_stack.php b/tests/behat/behat_qtype_stack.php
index 5577d4417f9..1e7f706c580 100644
--- a/tests/behat/behat_qtype_stack.php
+++ b/tests/behat/behat_qtype_stack.php
@@ -125,6 +125,27 @@ public function i_set_the_part_to_in_the_combined_question($name, $value) {
$formscontext->i_set_the_field_with_xpath_to($this->input_xpath($name), $value);
}
+ /**
+ * 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);
+ }
+
/**
* 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..d774a10ff8e
--- /dev/null
+++ b/tests/behat/metadata.feature
@@ -0,0 +1,359 @@
+@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 "STACK metadata is stored as a JSON object."
+ And I set the field "smdi_0_creator_firstName" in the "#qtype-stack-metadata-content" "css_element" to "Edmund"
+ And I set the field "smdi_0_creator_lastName" in the "#qtype-stack-metadata-content" "css_element" to "Farrow"
+ And I set the field "smdi_0_creator_institution" in the "#qtype-stack-metadata-content" "css_element" to "UoE"
+ And I set the field "smdi_0_creator_year" in the "#qtype-stack-metadata-content" "css_element" to "2025"
+ And I click on "Add contributor" "button"
+ And I wait until "smdi_1_contributor_firstName" "field" exists
+ And I set the field "smdi_1_contributor_firstName" in the "#qtype-stack-metadata-content" "css_element" to "Bob"
+ And I set the field "smdi_1_contributor_lastName" in the "#qtype-stack-metadata-content" "css_element" to "Smith"
+ And I set the field "smdi_1_contributor_institution" in the "#qtype-stack-metadata-content" "css_element" to "MIT"
+ And I set the field "smdi_1_contributor_year" in the "#qtype-stack-metadata-content" "css_element" to "2026"
+ And I click on "Add language" "button"
+ And I wait until "smdi_1_language_value" "field" exists
+ 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 "Add scope" "button"
+ And I wait until "smdi_1_additional_scope" "field" exists
+ 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 '{"creator":{"firstName":"Edmund","lastName":"Farrow","institution":"UoE","year":"2025"},"contributor":[{"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 "STACK metadata is stored as a JSON object."
+ And I click on "Add me as a contributor" "button"
+ And I click on "Add language" "button"
+ And I wait until "smdi_2_language_value" "field" exists
+ And I set the field "smdi_2_language_value" in the "#qtype-stack-metadata-content" "css_element" to "fr"
+ And I click on "Add property" "button"
+ And I wait until "smdi_2_additional_property" "field" exists
+ 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 "Add scope" "button"
+ And I wait until "smdi_3_additional_scope" "field" exists
+ 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 '{"creator":{"firstName":"Edmund","lastName":"Farrow","institution":"UoE","year":"2025"},"contributor":[{"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 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 '{"creator":{"firstName":"Edmund","lastName":"Farrow","institution":"UoE","year":"2025"},"contributor":[{"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 "STACK metadata is stored as a JSON object."
+ And I click on "smd_contributor_1_delete" "button"
+ 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 I should not see "en-del"
+ And I click on "smd_additional_2_delete" "button"
+ And I should not see "Cat info"
+ And I click on "Add language" "button"
+ And I wait until "smdi_3_language_value" "field" exists
+ 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 '{"creator":{"firstName":"Edmund","lastName":"Farrow","institution":"UoE","year":"2025"},"contributor":[{"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 "STACK metadata is stored as a JSON object."
+ And I click on "smd_scope_1_delete" "button"
+ 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 '{"creator":{"firstName":"Edmund","lastName":"Farrow","institution":"UoE","year":"2025"},"contributor":[{"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 "STACK metadata is stored as a JSON object."
+ And I click on "Add scope" "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 '{"creator":{"firstName":"Edmund","lastName":"Farrow","institution":"UoE","year":"2025"},"contributor":[{"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 "STACK metadata is stored as a JSON object."
+ And I set the field "id_metadata_json" to multiline:
+ """
+ {
+ "creator": {
+ "firstName": "Bob",
+ "lastName": "Smith",
+ "institution": "MIT",
+ "year": "2024"
+ },
+ "contributor": [
+ {
+ "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 "Update inputs from JSON" "button"
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"creator":{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2024"},"contributor":[{"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 "STACK metadata is stored as a JSON object."
+ 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 '{"creator":{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2024"},"contributor":[{"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 "STACK metadata is stored as a JSON object."
+ 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:
+ """
+ {
+ "creator": {
+ "firstName": "Bo1b",
+ "lastName": "Smi1th",
+ "institution": "MI1T",
+ "year": "2024"
+ },
+ "contributor": [
+ {
+ "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 "Update inputs from JSON" "button"
+ And I wait until "smdi_2_contributor_firstName" "field" exists
+ And I click on "Revert current changes" "button"
+ And I should not see "Lowell"
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"creator":{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2024"},"contributor":[{"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 '{"creator":{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2024"},"contributor":[{"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 '{"creator":{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2024"},"contributor":[{"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 "STACK metadata is stored as a JSON object."
+ And I set the field "id_metadata_json" to multiline:
+ """
+ {
+ "creator": {
+ "firstName": "Bob",
+ "lastName": "Smith",
+ "institution": "MIT",
+ "year": "2024"
+ },
+ "contributor": [
+ {
+ "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 "Update inputs from JSON" "button"
+ 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 '{"creator":{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2024"},"contributor":[{"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 "STACK metadata is stored as a JSON object."
+ 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 '{"creator":{"firstName":"Bob","lastName":"Smith","institution":"MIT","year":"2024"},"contributor":[{"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 "STACK metadata is stored as a JSON object."
+ And I click on "Validate and close" "button"
+ And I check the hidden input "metadata" is '{"creator":{"firstName":"Teacher","lastName":"Lastname1","institution":"","year":"XXXX"},"contributor":[],"language":["en"],"license":"unknown","isPartOf":"","additional":{},"freeform":{}}'
diff --git a/tests/helper.php b/tests/helper.php
index 0e655cc81f4..33c5db9c881 100644
--- a/tests/helper.php
+++ b/tests/helper.php
@@ -157,6 +157,8 @@ protected static function make_a_stack_question() {
$q->questionnote = '';
$q->questionnoteformat = FORMAT_HTML;
$q->isbroken = 0;
+ $q->metadata = '';
+ $q->prescribedmetadata = '';
return $q;
}
@@ -224,6 +226,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;
}
@@ -387,6 +392,9 @@ public function get_stack_question_form_data_test1() {
1 => ['text' => '