feat: implement QTI interaction registry, descriptor validation, and XML parsing logic for the QTI Editor.#5981
Conversation
…XML parsing logic for the QTI Editor. Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
|
👋 Hi @Abhishek-Punhani, thanks for contributing! For the review process to begin, please verify that the following is satisfied:
Also check that issue requirements are satisfied & you ran Pull requests that don't follow the guidelines will be closed. Reviewer assignment can take up to 2 weeks. |
|
📢✨ Before we assign a reviewer, we'll turn on |
|
📢✨ Before we assign a reviewer, we'll turn on |
rtibblesbot
left a comment
There was a problem hiding this comment.
Solid integration of the registry, composable, and rendering layers. CI is passing. Two dead-binding cleanups and a reactivity note are the actionable items; no blocking findings.
Manual QA did not run — no QA-capable host was available.
- suggestion —
displayAnswersPreviewpassed toInteractionSectionbut not declared there; falls through as an HTML attribute, never reachesChoiceInteractionEditor(see inline) - suggestion —
useQtiItemaccepts a plainstringso the parse runs once at setup; ifitem.raw_datachanges after mount the interactions won't update (see inline) - nitpick —
choiceEditorPlaceholderstring defined but unused (see inline) - nitpick — duplicate
radioassertion inInteractionSection.spec.js(see inline) - praise —
useInteractionDescriptorcomputed-chain design (see inline)
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Ran a phased review pipeline over the pull request diff:
- Classified the diff to select review passes (core, frontend, backend) and whether manual QA was required
- Core review pass checked correctness, design, architecture, testing, completeness, and DRY/SRP/Rule-of-Three principles
- Specialized frontend/backend review passes applied framework-specific lenses where those files changed
- For UI changes: manual QA and an accessibility audit against a live dev server, when available
- Checked CI status and linked issue acceptance criteria
- Synthesized one review from those passes and chose the verdict from the findings, CI status, and QA evidence
| v-if="interactions.length > 0" | ||
| :block="interactions[0]" | ||
| :mode="mode" | ||
| :displayAnswersPreview="displayAnswersPreview" |
There was a problem hiding this comment.
suggestion: displayAnswersPreview is not in InteractionSection's prop list (block and mode only), so Vue passes it through as a DOM attribute (displayanswerspreview="false") on the root <div>. ChoiceInteractionEditor never sees it. Since this prop is explicitly deferred in the issue's out-of-scope list, remove the binding here until InteractionSection actually declares and threads it.
| * parseError: import('vue').Ref<string | null>, | ||
| * }} | ||
| */ | ||
| export default function useQtiItem(rawData) { |
There was a problem hiding this comment.
suggestion: useQtiItem accepts a raw string and parses it once synchronously at setup time — there is no computed or watch, so interactions will never update if raw_data changes after mount. The caller passes props.item.raw_data (a snapshot), not a reactive ref.
If items are genuinely immutable after mount in this editor's lifecycle, add a JSDoc note to that effect so future callers know the assumption. If reactivity is needed, mirror useInteractionDescriptor's pattern: accept a Ref<string> and wrap the parse in computed().
| message: 'Choice', | ||
| context: 'Label for the choice interaction plugin, shown in the type selector', | ||
| }, | ||
| choiceEditorPlaceholder: { |
There was a problem hiding this comment.
nitpick: choiceEditorPlaceholder is not consumed by any component in this PR. It was presumably drafted for a placeholder editor that was never implemented. Remove it — unused translation strings still get sent to translators.
| describe('parse error handling', () => { | ||
| it('shows a parse error message and no interaction when XML is malformed', () => { | ||
| renderSection({ block: block('not-xml<{{') }); | ||
| expect(screen.queryByRole('radio')).not.toBeInTheDocument(); |
There was a problem hiding this comment.
nitpick: The radio assertion on this line is a duplicate — it also appears on line 43, just before the comment. Remove one.
| * | ||
| * @param {import('vue').Ref<string>} bodyXmlRef Ref to interaction's bodyXml string | ||
| */ | ||
| export default function useInteractionDescriptor(bodyXmlRef) { |
There was a problem hiding this comment.
praise: Wrapping all derived state in a single computed chain and returning further computed refs keeps descriptor, questionType, and parseError automatically in sync with bodyXmlRef. Cleaner and more robust than the plain ref approach the spec pseudocode suggested.
AlexVelezLl
left a comment
There was a problem hiding this comment.
Looking really great! Just some comments from a brief overview :). Let's see if we can find a good way to modularize the difference between question type vs interaction type vs assessment item type.
| * Hardcoded items covering different states: | ||
| * - item-1: has raw_data (real QTI XML) → exercises the full load path | ||
| * - item-2: no raw_data → shows placeholder (blank new item state) | ||
| * - item-3: no raw_data → shows placeholder | ||
| */ | ||
| const INITIAL_ASSESSMENTS = [ | ||
| { | ||
| id: 'demo-item-1', | ||
| type: QtiInteraction.CHOICE, | ||
| title: 'Which planet is closest to the Sun?', | ||
| raw_data: CHOICE_ITEM_XML, | ||
| }, | ||
| { | ||
| id: 'demo-item-2', | ||
| type: QtiInteraction.CHOICE, | ||
| title: 'Select all the prime numbers.', | ||
| raw_data: MULTI_CHOICE_ITEM_XML, | ||
| }, | ||
| { | ||
| id: 'demo-item-3', | ||
| type: QtiInteraction.EXTENDED_TEXT, | ||
| title: 'Describe the water cycle in your own words.', | ||
| }, | ||
| { | ||
| id: 'demo-item-3', | ||
| id: 'demo-item-4', | ||
| type: QtiInteraction.ORDER, | ||
| title: 'Arrange these events in chronological order.', | ||
| }, |
There was a problem hiding this comment.
Oh, apologies, I didn't catch this in the first PR. The assessments array here will have the same structure we get from the AssessmentItem Django model, so, it'd be better to have the same structure. So, a couple of changes:
- Instead of
idlet's useassessment_id. - Let's remove the
titlefield, as it's not defined in our assessment item model. - For
type, in this array, let's copy this AssessmentItemTypes to our constants. And let's add aQTI: 'qti'type; that will be the type of these ⬆️ questions.
We will need to be careful about the difference between "question/assessment item types" and interaction types, because assessment types are what we will save on the backend, and interaction types are the ones we will infer from the XML. We will potentially use the assessment item types for the type selector to not create a new constant.
| * @throws {Error} If the XML is malformed or contains a parsererror | ||
| */ | ||
| export function parseXML(xmlString) { | ||
| const doc = new DOMParser().parseFromString(xmlString, 'text/xml'); |
There was a problem hiding this comment.
We can also define a module-level domParser constant, similar to the XMLSerializer, to prevent instantiating it each time this function is called, right?
| const root = doc.querySelector('qti-assessment-item'); | ||
| const identifier = root ? (root.getAttribute('identifier') ?? '') : ''; | ||
| const title = root ? (root.getAttribute('title') ?? '') : ''; |
There was a problem hiding this comment.
We can also just do
root?.getAttribute('identifier') ?? ''
| const firstBlockXml = computed(() => | ||
| interactions.value.length > 0 ? interactions.value[0].bodyXml : null, | ||
| ); | ||
| const { descriptor, questionType } = useInteractionDescriptor(firstBlockXml); | ||
|
|
||
| /** | ||
| * Derives the type label for the closed-card header. | ||
| * When raw_data is present: parses the first interaction's bodyXml and uses | ||
| * the matching descriptor's label — this is the source of truth from the XML. | ||
| * When raw_data is absent (blank new items): falls back to item.type enum lookup. | ||
| */ | ||
| const interactionTypeLabel = computed(() => { | ||
| if (firstBlockXml.value) { | ||
| if (descriptor.value?.type === QtiInteraction.CHOICE) { | ||
| return questionType.value === 'singleSelect' | ||
| ? qtiEditorStrings.interactionTypeSingleChoice$() | ||
| : qtiEditorStrings.interactionTypeMultipleChoice$(); | ||
| } | ||
| return descriptor.value ? descriptor.value.label : interactionTypeUnknown$(); | ||
| } |
There was a problem hiding this comment.
So, to avoid calling useInteractionDescriptor and doing the descriptor inference at multiple levels. Let's just do it on the InteractionSection component, and let's make it emit an update:questionType to get the question type every time we have a new question type.
With this, the way QTIItemEditor has to know what question type it is rendering is by listening and storing the result of that event rather than having to give another parse that can get out of sync easily.
Translation labels should then be mapped per question type, not per interaction type.
There was a problem hiding this comment.
Along with the mode, we should also pass the "show answers" boolean ref as a prop to the interaction section and the interaction editor.
| /** The raw XML block representing an interaction and its response declarations */ | ||
| block: { | ||
| type: Object, | ||
| required: true, | ||
| }, |
There was a problem hiding this comment.
Hmm, now that I think about it, I think we can probably call it interaction instead 😅.
| /** | ||
| * The raw interaction block — shape: { bodyXml: string, responseDeclarations: string[] }. | ||
| * Parsed here to extract the prompt and simple-choice elements. | ||
| */ | ||
| block: { | ||
| type: Object, | ||
| default: null, | ||
| }, |
There was a problem hiding this comment.
Idem, lets also call it interaction
| /** Display label used by the (future) type selector UI. */ | ||
| get label() { | ||
| return qtiEditorStrings.choiceInteractionLabel$(); | ||
| }, |
There was a problem hiding this comment.
Let's remove this. I think it'd be better to have a label per question type, as question types are the ones that are going to be visible to users.
| * @returns {'singleSelect' | 'multiSelect'} | ||
| */ | ||
| getQuestionType(el) { | ||
| return el.getAttribute('max-choices') === '1' ? 'singleSelect' : 'multiSelect'; |
There was a problem hiding this comment.
Hmm, I think it'll be better to just build a different "question type" constant (apart from the assessment item type).
It's going to increase a bit the complexity but all of them serve a different purpose:
- Assessment item type -> the type stored in the database.
- Question type -> the type editors will select per assessment item. It's different from AssessmentItemType because we will extend this for all new question types (for new interactions) without confusing this with values that can be stored in the database (all of these will be assessment item type: "qti"). Value related to how Studio presents different question options to users.
- Interaction type -> the actual interactions defined by QTI, and the ones that will dictate how to parse and what descriptor we will use. Each qti interaction can have multiple related question types, but all of them will have assessment item type "qti"
It'd be great if you can add a comment on the Constants.js module explaining this ⬆️ :D. Thanks, n.n
Summary
This PR:
serialization/parseItem.jswithparseXML(string → validated XML Document) andparseItem(splits a QTI item into interaction blocks and metadata).interactions/plugin folder: adefineInteractioncontract helper that validates descriptor shape, an aggregatorindex.jsthat builds the registry, and achoiceplugin that handles both single- and multi-select interactions.composables/useQtiItem.js— a read-only composable that parses an item'sraw_dataXML and exposes a reactive model (identifier,title,language,interactions).composables/useInteractionDescriptor.js— resolves the correct plugin descriptor and derivesquestionType(singleSelect/multiSelect) by parsing the interaction'sbodyXml.components/InteractionSection/index.vue— detects the interaction type via the registry'smatches(), mounts the descriptor'seditorComponent, and passesquestionTypeas a prop.interactions/choice/ChoiceInteractionEditor.vue— rendersKRadioButtonfor single-select andKCheckboxfor multi-select; hides choices in view mode so only the prompt text is shown.QTIItemEditor/index.vueto useuseQtiItem, render the first interaction viaInteractionSection, and display an explicit "Single Choice" or "Multiple Choice" label in the closed-card header.constants.jswithQTI_INTERACTION_TAGSandqtiEditorStrings.jswith explicit single/multi-choice labels.References
Closes #5964
Reviewer guidance
/#/qti-demoto the URL.AI usage
Used Antigravity for final review and nitpicks.