You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
❌ 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 buttons
multiSelect — one or more correct answers (max-choices > 1 or 0 for 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-interactionresponse-identifier="RESPONSE"max-choices="1"shuffle="false"orientation="vertical">
<qti-prompt><p>What is the capital of France?</p></qti-prompt>
<qti-simple-choiceidentifier="choice_abc12345">Paris</qti-simple-choice>
<qti-simple-choiceidentifier="choice_def67890">London</qti-simple-choice>
<qti-simple-choiceidentifier="choice_ghi11223">Berlin</qti-simple-choice>
</qti-choice-interaction>
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.
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 mode
Emits:
'update:bodyXml'// string — new body XML whenever state changes'update:responseDeclarations'// string[] — new declarations whenever state changes
UI 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 InteractionTypeSelector and type-switching UI.
Hints (HintsSection).
Any non-choice interaction plugin.
Acceptance Criteria
parse.js
parse(bodyXml, responseDeclarations) returns a ChoiceState with correct prompt, answers, maxChoices, minChoices, shuffle, orientation values read from the XML.
Each answer in the returned answers array has { id, content, correct } where correct is true for identifiers listed in the response declaration's correct response.
buildXML(state, questionType) returns { bodyXml, declarations } that, when re-parsed by parse, produces an equivalent state (round-trip stable).
buildXML sets cardinality="single" for singleSelect, cardinality="multiple" for multiSelect.
validate.js
Returns PROMPT_REQUIRED when state.prompt is empty or whitespace-only.
Returns NO_CORRECT_ANSWER when no answer is marked correct (both questionType values).
Returns TOO_MANY_CORRECT_ANSWERS when questionType === 'singleSelect' and more than one answer is marked correct.
Returns EMPTY_CHOICE_CONTENT for each answer with empty or whitespace-only content.
Returns TOO_FEW_CHOICES when fewer than 2 answers are present.
Returns an empty array when the state is valid.
defineInteraction + descriptor
defineInteraction throws if buildXML is missing from the descriptor.
choice/index.js exports a descriptor that includes buildXML imported from ./parse.
useInteraction
Accepts (descriptor, interactionBlock, questionType) where questionType is a ref.
Parses the interaction block once on call; stores result as a reactive state ref.
bodyXml and declarations are computed values that update whenever state or questionType changes.
errors is a plain ref([]) that starts empty and is populated only when runValidation() is called.
runValidation() calls descriptor.validate(state, questionType) and writes the result to errors.
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) with singleSelect: clears all others and sets only the target to correct: true.
toggleCorrectChoice(id) with multiSelect: toggles only the target answer's correct field.
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 for multiSelect), a content RTE, move-up/move-down buttons, and a delete button.
Emits update:bodyXml and update:responseDeclarations whenever 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 errors in the UI; errors are not shown until runValidation has 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 matches questionType, 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.
❌ 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
choiceinteraction plugin end-to-end: real XML parsing, XML assembly, validation, a base interaction composable shared by all future plugins, and a workingChoiceEditor.vuethat handles both single-select and multi-select question types.This task picks up after the plugin registry scaffolding (where
choice/parse.jsandchoice/validate.jswere stubs) and delivers fully working code for the whole choice authoring lifecycle: load XML → edit state → validate → rebuild XML.Complexity: Medium-High
Target branch:
unstableContext
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 checkboxesBoth are served by a single
choicedescriptor.getQuestionType(el)reads themax-choicesattribute to tell them apart; thequestionTypevalue is passed as a prop toChoiceEditor.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
parseproduces (andbuildXMLconsumes) a single flat state object:QTI XML reference
Body XML (single-select example):
Response declaration (single-select — cardinality
single):Response declaration (multi-select — cardinality
multiple):cardinalityis derived fromquestionType:singleSelect→"single",multiSelect→"multiple". Theresponse-identifieron the interaction and the declarationidentifiermust match; use a constant"RESPONSE"for the choice interaction.The Change
1.
interactions/choice/parse.jsExport
parse(bodyXml, responseDeclarations)→ChoiceState:bodyXmlwithparseXML(fromserialization/parseItem.js).max-choices,min-choices,shuffle,orientationfrom the<qti-choice-interaction>element's attributes (applying defaults for absent attributes).<qti-prompt>inner HTML →prompt(we can declare agetPromptreusable function inserialization/parseItem.js).<qti-simple-choice>elements →answersarray.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[] }:<qti-choice-interaction>element with the correct attributes.<qti-simple-choice>.<qti-response-declaration>using theQTIDeclarationbuilder method.2.
interactions/choice/validate.jsExport
validate(state, questionType)→ValidationError[]:Port and adapt the rules from the existing
getAssessmentItemErrors:state.promptis empty or whitespace-onlycorrect: truecorrect: truecontentExtend
constants.jsto declare a new errors enum for the error codes.3.
interactions/defineInteraction.js— addbuildXMLAdd
buildXMLto the required-key list validated bydefineInteraction. Update the typedef comment accordingly.4.
interactions/choice/index.js— wire upbuildXMLImport
buildXMLfrom./parseand add it to the descriptor.5.
composables/useInteraction.js— base composableA general-purpose base used by every future interaction composable:
Internals:
descriptor.parse(interactionBlock.bodyXml, interactionBlock.responseDeclarations)once to get the initial state.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); }.{ state, bodyXml, declarations, errors, runValidation }.6.
composables/useChoiceInteraction.jsBuilds on
useInteractionand exposes all state-mutation abstract methods:addChoice,removeChoice,setShuffle, etc.To generate new identifiers, add
generateRandomSlug(prefix)toshared/utils. It returns"<prefix>_<8 random alphanumeric chars>"usingMath.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 asgenerateRandomSlug('choice')→"choice_xlqTuVoq"wherever a new choice identifier is needed (both inaddChoiceand inparse.jswhen a<qti-simple-choice>element is missing itsidentifierattribute).7.
interactions/choice/ChoiceEditor.vueA Vue SFC that wires the composable to the UI:
Props:
Emits:
UI Code can be reused from the current assessment editor, and achieve the visual specs defined for Single Selection, and Multi Selection.
Out of Scope
InteractionTypeSelectorand type-switching UI.HintsSection).Acceptance Criteria
parse.jsparse(bodyXml, responseDeclarations)returns aChoiceStatewith correctprompt,answers,maxChoices,minChoices,shuffle,orientationvalues read from the XML.answersarray has{ id, content, correct }wherecorrectistruefor identifiers listed in the response declaration's correct response.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.jsPROMPT_REQUIREDwhenstate.promptis empty or whitespace-only.NO_CORRECT_ANSWERwhen no answer is marked correct (bothquestionTypevalues).TOO_MANY_CORRECT_ANSWERSwhenquestionType === 'singleSelect'and more than one answer is marked correct.EMPTY_CHOICE_CONTENTfor each answer with empty or whitespace-only content.TOO_FEW_CHOICESwhen fewer than 2 answers are present.defineInteraction+ descriptordefineInteractionthrows ifbuildXMLis missing from the descriptor.choice/index.jsexports a descriptor that includesbuildXMLimported from./parse.useInteraction(descriptor, interactionBlock, questionType)wherequestionTypeis a ref.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.useChoiceInteractionaddChoice()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.state.ChoiceEditor.vuesingleSelect, checkbox formultiSelect), a content RTE, move-up/move-down buttons, and a delete button.update:bodyXmlandupdate:responseDeclarationswhenever the computed XML changes.runValidation()on blur of the prompt RTE and each choice content RTE.runValidation()immediately after structural mutations (toggle correct, add/remove choice).errorsin the UI; errors are not shown untilrunValidationhas been called at least once for a given field.<qti-choice-interaction>XML renders the editor pre-filled; editing choices updates the live XML.Testing
parse: round-trip (parse → buildXML → parse produces equivalent state), correct-answer detection, attribute defaults.buildXML: cardinality matchesquestionType, all correct identifiers appear in the declaration.validate: each error condition covered.useChoiceInteraction: each mutation method produces the expected state change.References
QTI Editor — Frontend Architecture Description(Notion)frontend/shared/utils/validation.js→getAssessmentItemErrorsfrontend/channelEdit/utils.js→updateAnswersToQuestionType,mapCorrectAnswers<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.