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 2 commits 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 2 commits 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 +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

…e to showAnswers in QTIEditor

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

@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.

I didn't realize before that the question type was a computed property 😅.

Comment on lines +48 to +57
/** The raw XML block representing an interaction and its response declarations */
interaction: {
type: Object,
required: true,
},
/** View or edit mode */
mode: {
type: String,
default: 'view',
},

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.

Could we add some validators here? Both for interaction and mode. Or just mention the expected object structure/string values.

Comment on lines +91 to +100
const currentQuestionType = ref(props.item.type || AssessmentItemTypes.QTI);

const interactionTypeLabel = computed(() => {
if (currentQuestionType.value === QuestionType.SINGLE_SELECT) {
return qtiEditorStrings.interactionTypeSingleChoice$();
}
if (currentQuestionType.value === QuestionType.MULTI_SELECT) {
return qtiEditorStrings.interactionTypeMultipleChoice$();
}
return 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.

We are mixing the three different concepts here 😅: "question types", "assessment item types," and "interaction types".

The ref here should handle the question type, because that is what is going to be shown to the user. Therefore, we cannot initialize it with props.item.type nor AssessmentItemTypes.QTI because that is assessment types. I think we should rely on the update event of the interaction block to get the type, and just initialize the ref with a simple null.

Then interactionTypeLabel, interactionTypeSingleChoice, interactionTypeMultipleChoice, and interactionTypeUnknown should not reference interaction because these labels will be intended for describing question types instead.

Also, the idea of the map object was good, I think we can keep it to avoid the ever groing chain of if here 👐

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.

Okay, so. There are some drifts we need to do here. The parseXML, the descriptor inference, and the question type inference should not be handled as computed props. We should only parse the interaction when the component is mounted or externally updated (we'll handle this in the future, when we have more things set up), but at least the questionType should be a ref. Because the questionType will be changed by the editors if they change it on the question type select.

Then, the descriptor should also be changed if the user selects a different question type related to a different interaction, and this is how the new editor would be mounted.

So, for this, I think we will need a new questionTypes array in the interactions descriptors, so that we can infer the interaction based on the selected question type (and then, the descriptor can be a computed property based on the questionType, and the initial inference would be only to know which question type is selected).

I have considered some other options, like having a different object mapping the question types to the descriptors, but I think it'd be best if we keep everything inside the descriptors. At the end, these changes have arisen from the decision to keep multiple question types being rendered by the same interaction in the registry as a different map (instead of having a 1-to-1 map), but let's see if this decision is just making everything more complex, and if so, we can reconsider the tradeoffs. If you have any suggestions, please let us know!

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