You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.
Overview
Wire up the first real load path of the QTI editor: read an item's QTI XML into a structured model, set up the interaction plugin registry, and render the item's first interaction through it. Concretely, this task adds:
serialization/parseItem.js — a parseXML helper (string → validated XML document) and a parseItem that splits an item into its interaction blocks and meta.
The interactions/ plugin folder — a defineInteraction contract helper, an aggregator index.js that builds the registry, and a first dummy choice interaction plugin.
A basic useQtiItem composable that runs parseItem and exposes the model, consumed by QTIItemEditor.
A minimal InteractionSection that detects an interaction's type via the registry and mounts its editor — used to render the first interaction of each item for now.
This builds directly on the scaffolding from the previous task (the shared/views/QTIEditor/ folder, the QTIEditor list component, the QTIItemEditor card wrapper, and constants.js).
Complexity: Medium Target branch:unstable
Context
The QTI editor reads an item's QTI XML once on load and turns it into a small structured model that becomes the source of truth; the XML is re-derived later on save (that derivation is a future task). An item's body is a list of interaction blocks (a single item can hold more than one interaction), but for now we will just use the first one. This task establishes that load path but only renders the first interaction of each item — multi-interaction rendering and the save/derive path come later.
Two concepts are introduced here:
1. The in-memory item model.parseItem produces a plain, serializable model:
/** * ItemModel * @property {string} identifier * @property {string} title * @property {string} language * @property {InteractionBlock[]} interactions *//** * InteractionBlock — one entry per <qti-*-interaction> in the item body * @property {string} bodyXml // the whole <qti-*-interaction> element, INCLUDING its <qti-prompt> * @property {string[]} responseDeclarations // the <qti-response-declaration>(s) matched to it by response-identifier */
Each interaction block keeps the interaction as an XML string (bodyXml) plus the response declarations that belong to it. A declaration belongs to an interaction when the interaction's response-identifier attribute equals the declaration's identifier. (No hints in this task — hintsXml is deferred to a future task.)
2. The interaction plugin registry. Rather than a switch/v-if chain, each interaction type is a self-contained plugin folder whose index.js exports one descriptor. A descriptor carries the editor component plus pure logic the registry needs to route and reason about a type:
/** * InteractionDescriptor * @property {string} type // registry key * @property {string} label // for the (future) type selector * @property {VueComponent} editorComponent * @property {string[]} convertsFrom // types it can absorb on a type switch (future) * @property {(bodyEl: Element) => boolean} matches // does this descriptor own a given <qti-*-interaction>? * @property {(bodyEl: Element, responseDeclEls: Element[]) => object} parse * @property {(state: object) => Array} validate */
On load, an InteractionSection looks at an interaction element and picks the first descriptor whose matches(el) returns true to decide which editor to mount. matches is a method (not a tag→type map) because several authoring types can share one QTI tag.
This task delivers a single dummy choice plugin that owns <qti-choice-interaction>. Note: this one choice plugin will serve both single- and multi-choice interactions (told apart later by max-choices inside the plugin) — there is no separate base folder and no separate single/multi descriptors.
This builds on the previous scaffolding task: the shared/views/QTIEditor/ folder, index.vue (the QTIEditor list component), components/QTIItemEditor/index.vue (the card wrapper), constants.js, and qtiEditorStrings.js. All new code uses the Composition API and imports no Vuex.
The Change
1. serialization/parseItem.js
Export parseXML(xmlString) that parses a string into an XML Document and checks for parser errors (a parsererror node), throwing a clear error when the input is malformed. Returns a valid Document on success.
Export parseItem(rawData) that uses parseXML, then returns the ItemModel: { identifier, title, language, interactions }.
Read identifier / title / language from the <qti-assessment-item> element.
Locate the item body, iterate its <qti-*-interaction> elements, and for each produce an InteractionBlock: bodyXml (the interaction element serialized back to a string, prompt included) and responseDeclarations (the <qti-response-declaration> elements whose identifier matches the interaction's response-identifier, serialized to strings).
No hints parsing in this task.
2. interactions/ plugin folder
defineInteraction.js — a contract-enforcement helper that validates a descriptor has every required key (type, label, editorComponent, convertsFrom, matches, parse, validate) and throws if one is missing. Called by each plugin's index.js.
index.js — the aggregator: import the (already-validated) descriptors, build a registry map keyed by type, and expose blockInteractions / inlineInteractions lists. It does not call defineInteraction itself.
choice/ — the dummy choice plugin.
3. composables/useQtiItem.js (basic)
A composable that takes an assessment (its raw_data QTI string), runs parseItem, and exposes the resulting reactive model (identifier, title, language, interactions). Consumed by QTIItemEditor.
Scope is read/parse only — no dirty tracking, no computed XML / assembly, no emit-up storage yet (those are later tasks).
QTIItemEditor/index.vue (currently just a card wrapper) uses useQtiItem and renders the item's first interaction block inside the card via one InteractionSection.
InteractionSection.vue (new, minimal): receives an interaction block (bodyXml + responseDeclarations); on setup it parses bodyXml to an element, finds the descriptor via the registry's matches, holds the detected type in a ref, and mounts <component :is="descriptor.editorComponent" :key="type">, passing the block down. An empty/unknown body falls back to the default type (choice).
5. Demo data
Ensure the demo page's hardcoded assessment data includes at least one assessment whose raw_data is a QTI XML string containing a <qti-choice-interaction> (with a prompt and a matching <qti-response-declaration>), so visiting the demo route renders the dummy choice editor end-to-end.
Out of Scope
Rendering more than the first interaction per item (multi-interaction layout).
The save/derive path: assembleItem, computed XML, emit-up storage in useQtiItem.
Real choice parsing/validation (the choiceparse/validate are stubs here) and single-vs-multi handling.
Type switching / the type selector / convertsFrom mechanics (the fields exist on the descriptor but aren't exercised).
Hints (hintsXml) and the HintsSection.
view mode / showAnswers.
Any non-choice interaction plugin.
Acceptance Criteria
General
serialization/parseItem.js exports parseXML(xmlString) that returns a valid XML Document and throws a clear error when the document contains a parsererror (malformed input).
parseItem(rawData) returns { identifier, title, language, interactions }, with interactions being one { bodyXml, responseDeclarations } per <qti-*-interaction> in the item body.
bodyXml is the full interaction element serialized to a string (prompt included); responseDeclarations are the declarations whose identifier matches the interaction's response-identifier, serialized to strings.
interactions/defineInteraction.js validates a descriptor against the required keys and throws when any is missing.
interactions/index.js builds a registry keyed by type and exposes blockInteractions / inlineInteractions, importing already-validated descriptors (it does not call defineInteraction).
interactions/choice/ exports a valid descriptor (type: 'choice', matches for qti-choice-interaction, dummy parse/validate, an editor component) via defineInteraction; its label comes from qtiEditorStrings.
The choice plugin is registered as a single plugin intended to serve both single- and multi-choice (no separate base folder, no separate single/multi descriptors).
composables/useQtiItem.js runs parseItem on an assessment's raw_data and exposes the reactive model (identifier, title, language, interactions).
QTIItemEditor/index.vue uses useQtiItem and renders the first interaction block inside its card via one InteractionSection.
InteractionSection.vue detects the descriptor via the registry's matches, holds the detected type in a ref, and mounts the descriptor's editorComponent (keyed by type), passing the interaction block down; empty/unknown falls back to the default type.
The demo page's hardcoded data includes an assessment whose raw_data contains a <qti-choice-interaction>, and visiting the demo route renders the dummy choice editor.
All new code uses the Composition API and imports no Vuex.
Testing
Unit tests for parseXML: returns a document for valid XML, throws for malformed XML.
Unit tests for parseItem: meta extraction, interaction-block splitting, and response-declaration matching by response-identifier.
Unit test for defineInteraction: throws on a descriptor missing a required key.
Existing lint and test suites pass.
References
Previous scaffolding task (the shared/views/QTIEditor/ folder, QTIEditor/QTIItemEditor, constants.js, qtiEditorStrings.js).
Kolibri QTI viewer, for XML-reading patterns (kolibri/kolibri/plugins/qti_viewer/frontend/):
utils/xml.js — parseXML (DOMParser + parsererror guard) is the model for parseXML here.
components/AssessmentItem.vue — xmlDoc.querySelector('qti-item-body') for locating the body.
composables/useDeclarations.js — getQTIDeclarations (querySelectorAll('qti-response-declaration')) for collecting declarations; here they are additionally matched to interactions by response-identifier.
AI usage
I used Claude (Claude Code) to draft this issue from design decisions and the QTI editor architecture.
❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.
Overview
Wire up the first real load path of the QTI editor: read an item's QTI XML into a structured model, set up the interaction plugin registry, and render the item's first interaction through it. Concretely, this task adds:
serialization/parseItem.js— aparseXMLhelper (string → validated XML document) and aparseItemthat splits an item into its interaction blocks and meta.interactions/plugin folder — adefineInteractioncontract helper, an aggregatorindex.jsthat builds the registry, and a first dummychoiceinteraction plugin.useQtiItemcomposable that runsparseItemand exposes the model, consumed byQTIItemEditor.InteractionSectionthat detects an interaction's type via the registry and mounts its editor — used to render the first interaction of each item for now.This builds directly on the scaffolding from the previous task (the
shared/views/QTIEditor/folder, theQTIEditorlist component, theQTIItemEditorcard wrapper, andconstants.js).Complexity: Medium
Target branch:
unstableContext
The QTI editor reads an item's QTI XML once on load and turns it into a small structured model that becomes the source of truth; the XML is re-derived later on save (that derivation is a future task). An item's body is a list of interaction blocks (a single item can hold more than one interaction), but for now we will just use the first one. This task establishes that load path but only renders the first interaction of each item — multi-interaction rendering and the save/derive path come later.
Two concepts are introduced here:
1. The in-memory item model.
parseItemproduces a plain, serializable model:Each interaction block keeps the interaction as an XML string (
bodyXml) plus the response declarations that belong to it. A declaration belongs to an interaction when the interaction'sresponse-identifierattribute equals the declaration'sidentifier. (No hints in this task —hintsXmlis deferred to a future task.)2. The interaction plugin registry. Rather than a
switch/v-ifchain, each interaction type is a self-contained plugin folder whoseindex.jsexports one descriptor. A descriptor carries the editor component plus pure logic the registry needs to route and reason about a type:On load, an
InteractionSectionlooks at an interaction element and picks the first descriptor whosematches(el)returnstrueto decide which editor to mount.matchesis a method (not a tag→type map) because several authoring types can share one QTI tag.This task delivers a single dummy
choiceplugin that owns<qti-choice-interaction>. Note: this onechoiceplugin will serve both single- and multi-choice interactions (told apart later bymax-choicesinside the plugin) — there is no separate base folder and no separate single/multi descriptors.This builds on the previous scaffolding task: the
shared/views/QTIEditor/folder,index.vue(theQTIEditorlist component),components/QTIItemEditor/index.vue(the card wrapper),constants.js, andqtiEditorStrings.js. All new code uses the Composition API and imports no Vuex.The Change
1.
serialization/parseItem.jsparseXML(xmlString)that parses a string into an XMLDocumentand checks for parser errors (aparsererrornode), throwing a clear error when the input is malformed. Returns a validDocumenton success.parseItem(rawData)that usesparseXML, then returns theItemModel:{ identifier, title, language, interactions }.identifier/title/languagefrom the<qti-assessment-item>element.<qti-*-interaction>elements, and for each produce anInteractionBlock:bodyXml(the interaction element serialized back to a string, prompt included) andresponseDeclarations(the<qti-response-declaration>elements whoseidentifiermatches the interaction'sresponse-identifier, serialized to strings).2.
interactions/plugin folderdefineInteraction.js— a contract-enforcement helper that validates a descriptor has every required key (type,label,editorComponent,convertsFrom,matches,parse,validate) and throws if one is missing. Called by each plugin'sindex.js.index.js— the aggregator: import the (already-validated) descriptors, build aregistrymap keyed bytype, and exposeblockInteractions/inlineInteractionslists. It does not calldefineInteractionitself.choice/— the dummychoiceplugin.3.
composables/useQtiItem.js(basic)raw_dataQTI string), runsparseItem, and exposes the resulting reactive model (identifier,title,language,interactions). Consumed byQTIItemEditor.computedXML / assembly, no emit-up storage yet (those are later tasks).4.
components/QTIItemEditor/index.vue+InteractionSection.vueQTIItemEditor/index.vue(currently just a card wrapper) usesuseQtiItemand renders the item's first interaction block inside the card via oneInteractionSection.InteractionSection.vue(new, minimal): receives an interaction block (bodyXml+responseDeclarations); on setup it parsesbodyXmlto an element, finds the descriptor via the registry'smatches, holds the detectedtypein aref, and mounts<component :is="descriptor.editorComponent" :key="type">, passing the block down. An empty/unknown body falls back to the default type (choice).5. Demo data
raw_datais a QTI XML string containing a<qti-choice-interaction>(with a prompt and a matching<qti-response-declaration>), so visiting the demo route renders the dummy choice editor end-to-end.Out of Scope
assembleItem,computedXML, emit-up storage inuseQtiItem.choiceparse/validateare stubs here) and single-vs-multi handling.convertsFrommechanics (the fields exist on the descriptor but aren't exercised).hintsXml) and theHintsSection.viewmode /showAnswers.choiceinteraction plugin.Acceptance Criteria
General
serialization/parseItem.jsexportsparseXML(xmlString)that returns a valid XMLDocumentand throws a clear error when the document contains aparsererror(malformed input).parseItem(rawData)returns{ identifier, title, language, interactions }, withinteractionsbeing one{ bodyXml, responseDeclarations }per<qti-*-interaction>in the item body.bodyXmlis the full interaction element serialized to a string (prompt included);responseDeclarationsare the declarations whoseidentifiermatches the interaction'sresponse-identifier, serialized to strings.interactions/defineInteraction.jsvalidates a descriptor against the required keys and throws when any is missing.interactions/index.jsbuilds aregistrykeyed bytypeand exposesblockInteractions/inlineInteractions, importing already-validated descriptors (it does not calldefineInteraction).interactions/choice/exports a valid descriptor (type: 'choice',matchesforqti-choice-interaction, dummyparse/validate, an editor component) viadefineInteraction; its label comes fromqtiEditorStrings.choiceplugin is registered as a single plugin intended to serve both single- and multi-choice (no separate base folder, no separate single/multi descriptors).composables/useQtiItem.jsrunsparseItemon an assessment'sraw_dataand exposes the reactive model (identifier,title,language,interactions).QTIItemEditor/index.vueusesuseQtiItemand renders the first interaction block inside its card via oneInteractionSection.InteractionSection.vuedetects the descriptor via the registry'smatches, holds the detectedtypein aref, and mounts the descriptor'seditorComponent(keyed bytype), passing the interaction block down; empty/unknown falls back to the default type.raw_datacontains a<qti-choice-interaction>, and visiting the demo route renders the dummy choice editor.Testing
parseXML: returns a document for valid XML, throws for malformed XML.parseItem: meta extraction, interaction-block splitting, and response-declaration matching byresponse-identifier.defineInteraction: throws on a descriptor missing a required key.References
shared/views/QTIEditor/folder,QTIEditor/QTIItemEditor,constants.js,qtiEditorStrings.js).kolibri/kolibri/plugins/qti_viewer/frontend/):utils/xml.js—parseXML(DOMParser +parsererrorguard) is the model forparseXMLhere.components/AssessmentItem.vue—xmlDoc.querySelector('qti-item-body')for locating the body.composables/useDeclarations.js—getQTIDeclarations(querySelectorAll('qti-response-declaration')) for collecting declarations; here they are additionally matched to interactions byresponse-identifier.AI usage
I used Claude (Claude Code) to draft this issue from design decisions and the QTI editor architecture.