Repository navigation
[QTI] Implement choice interaction editor #5978
Description
❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.
Overview
Complete the choice interaction plugin end-to-end: real XML parsing, XML assembly, validation, a base interaction composable shared by all future plugins, and a working ChoiceEditor.vue that handles both single-select and multi-select question types.
This task picks up after the plugin registry scaffolding (where choice/parse.js and choice/validate.js were stubs) and delivers fully working code for the whole choice authoring lifecycle: load XML → edit state → validate → rebuild XML.
Complexity: Medium-High
Target branch: unstable
Context
The choice interaction maps to <qti-choice-interaction> and covers two question types:
singleSelect— exactly one correct answer (max-choices="1"); rendered with radio buttonsmultiSelect— one or more correct answers (max-choices> 1 or0for unlimited); rendered with checkboxes
Both are served by a single choice descriptor. getQuestionType(el) reads the max-choices attribute to tell them apart; the questionType value is passed as a prop to ChoiceEditor.vue, which adapts its UI accordingly.
The two pure modules (parse.js, validate.js) are the foundation for the headless validation that will be needed for integration with studio validation system.
State shape
parse produces (and buildXML consumes) a single flat state object:
/**
* ChoiceState
* @property {string} prompt — HTML content of <qti-prompt>; default ""
* @property {ChoiceAnswer[]} answers
* @property {number} maxChoices — from max-choices attribute (0 = unlimited)
* @property {number} minChoices — from min-choices attribute; default 0
* @property {boolean} shuffle — from shuffle attribute; default false
* @property {string} orientation — from orientation attribute; default "vertical"
*/
/**
* ChoiceAnswer
* @property {string} id — QTI identifier, e.g. "choice_xlqTuVoq"
* @property {string} content — HTML content of the <qti-simple-choice>
* @property {boolean} correct — whether this choice is in the correct response
* @property {boolean} fixed — whether this choice is fixed, not editable or removable by the UI for now, mostly there for round-trip compatibility.
*/QTI XML reference
Body XML (single-select example):
<qti-choice-interaction response-identifier="RESPONSE" max-choices="1" shuffle="false" orientation="vertical">
<qti-prompt><p>What is the capital of France?</p></qti-prompt>
<qti-simple-choice identifier="choice_abc12345">Paris</qti-simple-choice>
<qti-simple-choice identifier="choice_def67890">London</qti-simple-choice>
<qti-simple-choice identifier="choice_ghi11223">Berlin</qti-simple-choice>
</qti-choice-interaction>Response declaration (single-select — cardinality single):
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="identifier">
<qti-correct-response>
<qti-value>choice_abc12345</qti-value>
</qti-correct-response>
</qti-response-declaration>Response declaration (multi-select — cardinality multiple):
<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="identifier">
<qti-correct-response>
<qti-value>choice_abc12345</qti-value>
<qti-value>choice_ghi11223</qti-value>
</qti-correct-response>
</qti-response-declaration>cardinality is derived from questionType: singleSelect → "single", multiSelect → "multiple". The response-identifier on the interaction and the declaration identifier must match; use a constant "RESPONSE" for the choice interaction.
The Change
1. interactions/choice/parse.js
Export parse(bodyXml, responseDeclarations) → ChoiceState:
- Parse
bodyXmlwithparseXML(fromserialization/parseItem.js). - Read
max-choices,min-choices,shuffle,orientationfrom the<qti-choice-interaction>element's attributes (applying defaults for absent attributes). - Extract
<qti-prompt>inner HTML →prompt(we can declare agetPromptreusable function inserialization/parseItem.js). - Collect
<qti-simple-choice>elements →answersarray. - Parse the response declaration string(s) with the
QTIDeclarationmethod built in [QTI] Build the QTI declaration model with XML parsing and serialization #5965 to find the correct<qti-value>identifiers; mark matching answers ascorrect: true.
Export buildXML(state, questionType) → { bodyXml: string, declarations: string[] }:
- Serialize state back to a
<qti-choice-interaction>element with the correct attributes. - Render each answer as a
<qti-simple-choice>. - Build the
<qti-response-declaration>using theQTIDeclarationbuilder method.
2. interactions/choice/validate.js
Export validate(state, questionType) → ValidationError[]:
Port and adapt the rules from the existing getAssessmentItemErrors:
| Rule | Condition |
|---|---|
| Prompt required | state.prompt is empty or whitespace-only |
| Correct answer required (singleSelect) | exactly 0 or 2+ answers have correct: true |
| Correct answer required (multiSelect) | no answers have correct: true |
| Empty choice content | any answer has empty or whitespace-only content |
| Too few choices | fewer than 2 answers |
Extend constants.js to declare a new errors enum for the error codes.
3. interactions/defineInteraction.js — add buildXML
Add buildXML to the required-key list validated by defineInteraction. Update the typedef comment accordingly.
4. interactions/choice/index.js — wire up buildXML
Import buildXML from ./parse and add it to the descriptor.
5. composables/useInteraction.js — base composable
A general-purpose base used by every future interaction composable:
/**
* useInteraction(descriptor, interactionBlock, questionType)
*
* @param {InteractionDescriptor} descriptor
* @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock — reactive (props)
* @param {Ref<string|null>} questionType — reactive (from InteractionSection prop)
*
* @returns {{
* state: Ref<object>,
* bodyXml: ComputedRef<string>,
* declarations: ComputedRef<string[]>,
* errors: Ref<ValidationError[]>,
* runValidation: () => void,
* }}
*/
export function useInteraction(descriptor, interactionBlock, questionType) { … }Internals:
- Call
descriptor.parse(interactionBlock.bodyXml, interactionBlock.responseDeclarations)once to get the initial state. - Store as
const state = ref(initialState). const built = computed(() => descriptor.buildXML(state.value, questionType.value))— reactive XML.const bodyXml = computed(() => built.value.bodyXml).const declarations = computed(() => built.value.declarations).const errors = ref([])— starts empty; populated only whenrunValidationis called.function runValidation() { errors.value = descriptor.validate(state.value, questionType.value); }.- Return
{ state, bodyXml, declarations, errors, runValidation }.
6. composables/useChoiceInteraction.js
Builds on useInteraction and exposes all state-mutation abstract methods: addChoice, removeChoice, setShuffle, etc.
To generate new identifiers, add generateRandomSlug(prefix) to shared/utils. It returns "<prefix>_<8 random alphanumeric chars>" using Math.random().toString(36).slice(2, 10). This is the single shared utility for all QTI identifier generation — choice IDs, response identifiers, or any other slug that needs a stable, readable, collision-resistant string. Call it as generateRandomSlug('choice') → "choice_xlqTuVoq" wherever a new choice identifier is needed (both in addChoice and in parse.js when a <qti-simple-choice> element is missing its identifier attribute).
7. interactions/choice/ChoiceEditor.vue
A Vue SFC that wires the composable to the UI:
Props:
bodyXml: String,
responseDeclarations: Array, // string[]
questionType: String, // 'singleSelect' | 'multiSelect'
mode: String, // 'edit' | 'view'
displayAnswersPreview: Boolean, // Wether "show answers" is on when in view modeEmits:
'update:bodyXml' // string — new body XML whenever state changes
'update:responseDeclarations' // string[] — new declarations whenever state changesUI Code can be reused from the current assessment editor, and achieve the visual specs defined for Single Selection, and Multi Selection.
Out of Scope
- Drag-and-drop reordering (move-up/move-down buttons are sufficient here).
- The
InteractionTypeSelectorand type-switching UI. - Hints (
HintsSection). - Any non-choice interaction plugin.
Acceptance Criteria
parse.js
-
parse(bodyXml, responseDeclarations)returns aChoiceStatewith correctprompt,answers,maxChoices,minChoices,shuffle,orientationvalues read from the XML. - Each answer in the returned
answersarray has{ id, content, correct }wherecorrectistruefor identifiers listed in the response declaration's correct response. - Missing optional attributes default to:
minChoices = 0,shuffle = false,orientation = 'vertical',prompt = ''. -
buildXML(state, questionType)returns{ bodyXml, declarations }that, when re-parsed byparse, produces an equivalent state (round-trip stable). -
buildXMLsetscardinality="single"forsingleSelect,cardinality="multiple"formultiSelect.
validate.js
- Returns
PROMPT_REQUIREDwhenstate.promptis empty or whitespace-only. - Returns
NO_CORRECT_ANSWERwhen no answer is marked correct (bothquestionTypevalues). - Returns
TOO_MANY_CORRECT_ANSWERSwhenquestionType === 'singleSelect'and more than one answer is marked correct. - Returns
EMPTY_CHOICE_CONTENTfor each answer with empty or whitespace-only content. - Returns
TOO_FEW_CHOICESwhen fewer than 2 answers are present. - Returns an empty array when the state is valid.
defineInteraction + descriptor
-
defineInteractionthrows ifbuildXMLis missing from the descriptor. -
choice/index.jsexports a descriptor that includesbuildXMLimported from./parse.
useInteraction
- Accepts
(descriptor, interactionBlock, questionType)wherequestionTypeis a ref. - Parses the interaction block once on call; stores result as a reactive
stateref. -
bodyXmlanddeclarationsare computed values that update wheneverstateorquestionTypechanges. -
errorsis a plainref([])that starts empty and is populated only whenrunValidation()is called. -
runValidation()callsdescriptor.validate(state, questionType)and writes the result toerrors.
useChoiceInteraction
-
addChoice()appends a new answer with a generated"choice_<8 random chars>"identifier and empty content. -
removeChoice(id)removes the answer; at least one answer always remains (no-op if only one). -
moveChoiceUp(id)/moveChoiceDown(id)swap array positions correctly at boundaries (no-op at first/last). -
toggleCorrectChoice(id)withsingleSelect: clears all others and sets only the target tocorrect: true. -
toggleCorrectChoice(id)withmultiSelect: toggles only the target answer'scorrectfield. - All other mutation methods update the corresponding field on
state.
ChoiceEditor.vue
- Renders a prompt RTE and a list of choice rows.
- Each row shows the correct-answer control (radio for
singleSelect, checkbox formultiSelect), a content RTE, move-up/move-down buttons, and a delete button. - Emits
update:bodyXmlandupdate:responseDeclarationswhenever the computed XML changes. - Calls
runValidation()on blur of the prompt RTE and each choice content RTE. - Calls
runValidation()immediately after structural mutations (toggle correct, add/remove choice). - Displays validation errors from
errorsin the UI; errors are not shown untilrunValidationhas been called at least once for a given field. - Works end-to-end on the demo page: loading a
<qti-choice-interaction>XML renders the editor pre-filled; editing choices updates the live XML.
Testing
- Unit tests for
parse: round-trip (parse → buildXML → parse produces equivalent state), correct-answer detection, attribute defaults. - Unit tests for
buildXML: cardinality matchesquestionType, all correct identifiers appear in the declaration. - Unit tests for
validate: each error condition covered. - Unit tests for
useChoiceInteraction: each mutation method produces the expected state change. - Existing lint and test suites pass.
References
- Architecture proposal:
QTI Editor — Frontend Architecture Description(Notion) - Existing validation logic being ported:
frontend/shared/utils/validation.js→getAssessmentItemErrors - Existing answer manipulation being ported:
frontend/channelEdit/utils.js→updateAnswersToQuestionType,mapCorrectAnswers - QTI 3.0 spec for
<qti-choice-interaction>: attributesmax-choices,min-choices,shuffle,orientation; child elements<qti-prompt>,<qti-simple-choice>
AI usage
I used Claude (Claude Code) to draft this issue from design decisions and the QTI editor architecture.

Activity
[-][QTI] Implement choice interaction[/-][+][QTI] Implement choice interaction editor[/+]AlexVelezLl commentedon Jun 23, 2026
Everything should be in place to finally build the first interaction editor, @Abhishek-Punhani! 🎉. Please feel free to ask any questions as needed
.