Repository navigation
[QTI] Implement QTI item loading and render a first interaction through the plugin registry #5964
Description
❌ 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.- The
interactions/plugin folder — adefineInteractioncontract helper, an aggregatorindex.jsthat builds the registry, and a first dummychoiceinteraction plugin. - A basic
useQtiItemcomposable that runsparseItemand exposes the model, consumed byQTIItemEditor. - A minimal
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, 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 (= official QTI interaction type)
* @property {'block'|'inline'} placement
* @property {string} label // for the (future) type selector
* @property {VueComponent} editorComponent // receives `questionType` prop when non-null
* @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) => string | null} getQuestionType
* // derives the UI-facing question type label (e.g. 'singleSelect' | 'multiSelect' for choice)
* @property {(bodyEl: Element, responseDeclEls: Element[]) => object} parse
* @property {(state: object, questionType: string | null) => Array} validate
*/On load, an InteractionSection looks at an interaction element and picks the first descriptor whose matches(el) returns true, then calls descriptor.getQuestionType(el) to derive the UI-facing question type (e.g. 'singleSelect' vs 'multiSelect' for choice interactions). Both descriptor and questionType are held in refs; questionType is passed as a prop to the editor component. 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 — getQuestionType reads the max-choices attribute to return 'singleSelect' or 'multiSelect', so 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 XMLDocumentand checks for parser errors (aparsererrornode), throwing a clear error when the input is malformed. Returns a validDocumenton success. - Export
parseItem(rawData)that usesparseXML, then returns theItemModel:{ identifier, title, language, interactions }.- Read
identifier/title/languagefrom the<qti-assessment-item>element. - Locate the item body, iterate its
<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). - No hints parsing in this task.
- Read
2. interactions/ plugin folder
defineInteraction.js— a contract-enforcement helper that validates a descriptor has every required key (type,placement,label,editorComponent,convertsFrom,matches,getQuestionType,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. It does not calldefineInteractionitself.choice/— the dummychoiceplugin.
3. composables/useQtiItem.js (basic)
- A composable that takes an assessment (its
raw_dataQTI string), runsparseItem, and exposes the resulting reactive model (identifier,title,language,interactions). Consumed byQTIItemEditor. - Scope is read/parse only — no dirty tracking, no
computedXML / assembly, no emit-up storage yet (those are later tasks).
4. components/QTIItemEditor/index.vue + InteractionSection.vue
QTIItemEditor/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, then callsgetQuestionTypeto derive the question type — holding both inrefs:
const descriptor = ref(descriptors.find(d => d.matches(interactionEl)) ?? registry[DEFAULT_INTERACTION]);
const questionType = ref(descriptor.value.getQuestionType(interactionEl) ?? DEFAULT_QUESTION_TYPE);It then mounts <component :is="descriptor.editorComponent" :key="descriptor.type" :questionType="questionType">, passing the interaction block and questionType 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_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
- Rendering more than the first interaction per item (multi-interaction layout).
- The save/derive path:
assembleItem,computedXML, emit-up storage inuseQtiItem. - Real choice parsing/validation (the
choiceparse/validateare stubs here) and single-vs-multi handling beyond thegetQuestionTypestub. - Type switching / the type selector /
convertsFrommechanics (the fields exist on the descriptor but aren't exercised). - Hints (
hintsXml) and theHintsSection. viewmode /showAnswers.- Any non-
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 (type,placement,label,editorComponent,convertsFrom,matches,getQuestionType,parse,validate) and throws when any is missing. -
interactions/index.jsbuilds aregistrykeyed bytype(it does not calldefineInteraction). -
interactions/choice/exports a valid descriptor (type: 'choice',placement: 'block',matchesforqti-choice-interaction,getQuestionTypethat returns'singleSelect'whenmax-choices === '1'and'multiSelect'otherwise, dummyparse/validate, a dummy editor component) viadefineInteraction; its label comes fromqtiEditorStrings. - The
choiceplugin is registered as a single plugin intended to serve both single- and multi-choice. -
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, callsgetQuestionType(el)to derive the question type, holds bothdescriptorandquestionTypeinrefs, and mounts the descriptor'seditorComponent(keyed bydescriptor.type) passingquestionTypeas a prop; empty/unknown falls back to the default type. - The demo page's hardcoded data includes an assessment whose
raw_datacontains 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 byresponse-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 +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.

Activity
AlexVelezLl commentedon Jun 16, 2026
Hi @Abhishek-Punhani! Just a heads-up that I've updated the description to include a
getQuestionTypeon the interaction descriptor. So that the choice interaction can return different question types depending on itsmax-choices:).