Skip to content

[QTI] Implement choice interaction editor #5978

@AlexVelezLl

Description

@AlexVelezLl

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-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 bodyXml with parseXML (from serialization/parseItem.js).
  • Read max-choices, min-choices, shuffle, orientation from the <qti-choice-interaction> element's attributes (applying defaults for absent attributes).
  • Extract <qti-prompt> inner HTML → prompt (we can declare a getPrompt reusable function in serialization/parseItem.js).
  • Collect <qti-simple-choice> elements → answers array.
  • Parse the response declaration string(s) with the QTIDeclaration method built in [QTI] Build the QTI declaration model with XML parsing and serialization #5965 to find the correct <qti-value> identifiers; mark matching answers as correct: 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 the QTIDeclaration builder 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 when runValidation is 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 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.
  • Missing optional attributes default to: minChoices = 0, shuffle = false, orientation = 'vertical', prompt = ''.
  • 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.
  • Existing lint and test suites pass.

References

  • Architecture proposal: QTI Editor — Frontend Architecture Description (Notion)
  • Existing validation logic being ported: frontend/shared/utils/validation.jsgetAssessmentItemErrors
  • Existing answer manipulation being ported: frontend/channelEdit/utils.jsupdateAnswersToQuestionType, mapCorrectAnswers
  • QTI 3.0 spec for <qti-choice-interaction>: attributes max-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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No fields configured for Task.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions