Skip to content

feat: implement QTI interaction registry, descriptor validation, and XML parsing logic for the QTI Editor.#5981

Open
Abhishek-Punhani wants to merge 1 commit into
learningequality:unstablefrom
Abhishek-Punhani:Issue5964
Open

feat: implement QTI interaction registry, descriptor validation, and XML parsing logic for the QTI Editor.#5981
Abhishek-Punhani wants to merge 1 commit into
learningequality:unstablefrom
Abhishek-Punhani:Issue5964

Conversation

@Abhishek-Punhani

Copy link
Copy Markdown
Member

Summary

This PR:

  • Adds serialization/parseItem.js with parseXML (string → validated XML Document) and parseItem (splits a QTI item into interaction blocks and metadata).
  • Introduces the interactions/ plugin folder: a defineInteraction contract helper that validates descriptor shape, an aggregator index.js that builds the registry, and a choice plugin that handles both single- and multi-select interactions.
  • Adds composables/useQtiItem.js — a read-only composable that parses an item's raw_data XML and exposes a reactive model (identifier, title, language, interactions).
  • Adds composables/useInteractionDescriptor.js — resolves the correct plugin descriptor and derives questionType (singleSelect / multiSelect) by parsing the interaction's bodyXml.
  • Adds components/InteractionSection/index.vue — detects the interaction type via the registry's matches(), mounts the descriptor's editorComponent, and passes questionType as a prop.
  • Adds interactions/choice/ChoiceInteractionEditor.vue — renders KRadioButton for single-select and KCheckbox for multi-select; hides choices in view mode so only the prompt text is shown.
  • Updates QTIItemEditor/index.vue to use useQtiItem, render the first interaction via InteractionSection, and display an explicit "Single Choice" or "Multiple Choice" label in the closed-card header.
  • Extends constants.js with QTI_INTERACTION_TAGS and qtiEditorStrings.js with explicit single/multi-choice labels.
  • Updates the QTI Demo Page with a second (multi-choice) hardcoded item so the demo exercises both interaction types end-to-end.
  • Ships unit tests for all new modules using Vue Testing Library (68 tests, all passing).

image

References

Closes #5964

Reviewer guidance

  • Open a channel in the Studio channel editor and append /#/qti-demo to the URL.
  • You will see two hardcoded questions — one single-choice and one multi-choice.
  • Expand/collapse cards to verify prompt text is shown when collapsed and choices appear only when expanded.

AI usage

Used Antigravity for final review and nitpicks.

…XML parsing logic for the QTI Editor.

Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
@learning-equality-bot

Copy link
Copy Markdown

👋 Hi @Abhishek-Punhani, thanks for contributing!

For the review process to begin, please verify that the following is satisfied:

  • Contribution is aligned with our contributing guidelines

  • Pull request description has correctly filled AI usage section & follows our AI guidance:

    AI guidance

    State explicitly whether you didn't use or used AI & how.

    If you used it, ensure that the PR is aligned with Using AI as well as our DEEP framework. DEEP asks you:

    • Disclose — Be open about when you've used AI for support.
    • Engage critically — Question what is generated. Review code for correctness and unnecessary complexity.
    • Edit — Review and refine AI output. Remove unnecessary code and verify it still works after your edits.
    • Process sharing — Explain how you used the AI so others can learn.

    Examples of good disclosures:

    "I used Claude Code to implement the component, prompting it to follow the pattern in ComponentX. I reviewed the generated code, removed unnecessary error handling, and verified the tests pass."

    "I brainstormed the approach with Gemini, then had it write failing tests for the feature. After reviewing the tests, I used Claude Code to generate the implementation. I refactored the output to reduce verbosity and ran the full test suite."

Also check that issue requirements are satisfied & you ran pre-commit locally.

Pull requests that don't follow the guidelines will be closed.

Reviewer assignment can take up to 2 weeks.

@AlexVelezLl AlexVelezLl requested a review from rtibblesbot June 17, 2026 18:53
@learning-equality-bot

Copy link
Copy Markdown

📢✨ Before we assign a reviewer, we'll turn on @rtibblesbot to pre-review. Its comments are generated by an LLM, and should be evaluated accordingly.

@AlexVelezLl AlexVelezLl requested review from rtibblesbot and removed request for rtibblesbot June 17, 2026 19:20
@learning-equality-bot

Copy link
Copy Markdown

📢✨ Before we assign a reviewer, we'll turn on @rtibblesbot to pre-review. Its comments are generated by an LLM, and should be evaluated accordingly.

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  • suggestiondisplayAnswersPreview passed to InteractionSection but not declared there; falls through as an HTML attribute, never reaches ChoiceInteractionEditor (see inline)
  • suggestionuseQtiItem accepts a plain string so the parse runs once at setup; if item.raw_data changes after mount the interactions won't update (see inline)
  • nitpickchoiceEditorPlaceholder string defined but unused (see inline)
  • nitpick — duplicate radio assertion in InteractionSection.spec.js (see inline)
  • praiseuseInteractionDescriptor computed-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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 AlexVelezLl self-assigned this Jun 17, 2026

@AlexVelezLl AlexVelezLl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +36 to 63
* 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.',
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 id let's use assessment_id.
  • Let's remove the title field, 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 a QTI: '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');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also define a module-level domParser constant, similar to the XMLSerializer, to prevent instantiating it each time this function is called, right?

Comment on lines +42 to +44
const root = doc.querySelector('qti-assessment-item');
const identifier = root ? (root.getAttribute('identifier') ?? '') : '';
const title = root ? (root.getAttribute('title') ?? '') : '';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also just do
root?.getAttribute('identifier') ?? ''

Comment on lines +101 to +120
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$();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Along with the mode, we should also pass the "show answers" boolean ref as a prop to the interaction section and the interaction editor.

Comment on lines +39 to +43
/** The raw XML block representing an interaction and its response declarations */
block: {
type: Object,
required: true,
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, now that I think about it, I think we can probably call it interaction instead 😅.

Comment on lines +94 to +101
/**
* The raw interaction block — shape: { bodyXml: string, responseDeclarations: string[] }.
* Parsed here to extract the prompt and simple-choice elements.
*/
block: {
type: Object,
default: null,
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idem, lets also call it interaction

Comment on lines +17 to +20
/** Display label used by the (future) type selector UI. */
get label() {
return qtiEditorStrings.choiceInteractionLabel$();
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[QTI] Implement QTI item loading and render a first interaction through the plugin registry

3 participants