Skip to content

[QTI] Implement Associate Interaction editor #6101

Description

@Abhishek-Punhani

This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.

Description

Complete the associate interaction plugin end-to-end: XML parsing, XML assembly,
validation, a useAssociateInteraction composable built on useInteraction, and a
working AssociateInteractionEditor.vue.

This task depends on the choice, text-entry, and ordering interaction plugins
being in place (for useInteraction, generateRandomSlug, and defineInteraction).

Complexity: High
Target branch: unstable

Context

The associate interaction maps to <qti-associate-interaction> and covers a "connect
pairs" question type.

The associate interaction works with a single flat pool of choices. The learner must
draw connections between any two items within the same pool — for example, "match
each character to their rival from this list of names."

The response declaration uses cardinality="multiple" and base-type="pair", listing
unordered pairs of choice identifiers. Because it is an undirected pair,
"A B" and "B A" are considered the same pairing.

State shape

Defined via JSDoc in interactions/associate/parse.js:

/**
 * @typedef {object} AssociateChoice
 * @property {string}  id       - QTI identifier, e.g. "assoc_xlqTuVoq"
 * @property {string}  content  - HTML content of the <qti-simple-associable-choice>
 */

/**
 * @typedef {object} AssociateState
 * @property {string}               prompt             - HTML content of <qti-prompt>; default ""
 * @property {AssociateChoice[]}    distractors            - Single flat pool of distractors
 * @property {Array<[AssociateChoice, AssociateChoice]>} pairs - Array of two-item arrays representing correctly associated pairs.
 */

QTI XML reference

Official spec example (§3.2.13, IMS Global BPIG):

<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
  identifier="QTI3-associate" title="Classic Associate Interaction Example"
  time-dependent="false" xml:lang="en-US">

  <qti-response-declaration identifier="RESPONSE"
    cardinality="multiple" base-type="pair">
    <qti-correct-response>
      <qti-value>A P</qti-value>
      <qti-value>C M</qti-value>
      <qti-value>D L</qti-value>
    </qti-correct-response>
  </qti-response-declaration>

  <qti-item-body>
    <qti-associate-interaction response-identifier="RESPONSE" max-associations="3">
      <qti-prompt>
        Hidden in this list of characters from famous Shakespeare plays are three pairs
        of rivals. Can you match each character to his adversary?
      </qti-prompt>
      <qti-simple-associable-choice identifier="A" match-max="1">Antonio</qti-simple-associable-choice>
      <qti-simple-associable-choice identifier="C" match-max="1">Capulet</qti-simple-associable-choice>
      <qti-simple-associable-choice identifier="D" match-max="1">Demetrius</qti-simple-associable-choice>
      <qti-simple-associable-choice identifier="L" match-max="1">Lysander</qti-simple-associable-choice>
      <qti-simple-associable-choice identifier="M" match-max="1">Montague</qti-simple-associable-choice>
      <qti-simple-associable-choice identifier="P" match-max="1">Prospero</qti-simple-associable-choice>
    </qti-associate-interaction>
  </qti-item-body>
</qti-assessment-item>

Rules:

  1. base-type is always "pair" (not "directedPair").
  2. A pair is unordered — "A P" and "P A" are equivalent. Do not modify the saved order; otherwise, pairs in the UI will change order on reload.
  3. max-associations controls the total number of pairs a learner can make. Always set this to the number of correct pairs in qti-correct-response.
  4. match-max on each choice controls how many pairs that specific choice can be part of.
  5. Items without an identifier must be assigned generateRandomSlug('choice').
  6. shuffle controls whether the delivery engine randomizes display order. Should always be set to true.
  7. Choices not present on qti-correct-response are considered distractors.
  8. If a choice has a match-max greater than the number of appearances on qti-correct-response, then the difference is the number of distractors that choice accounts for.
  9. If two choices have the exact same content, then those should be transformed into a single choice with a proper match-max.

The Change

1. interactions/associate/parse.js

Export parseAssociateInteraction(bodyXml, responseDeclarations) → AssociateState:

  • Parse bodyXml with parseXML.
  • Extract <qti-prompt> inner HTML → prompt via the shared getPromptHTML helper.
  • Collect all <qti-simple-associable-choice> elements into the pairs array with their corresponding qti-simple-associable-choice content.
  • <qti-simple-associable-choice> not present in qti-correct-response goes into the flat distractors array.
  • If identifier is not present on <qti-simple-associable-choice>, then assign it to generateRandomSlug('choice').

Export buildAssociateInteractionXML(state, questionType, declarationSchema):

  • Serialize state.prompt as <qti-prompt>.
  • Collect all <qti-simple-associable-choice> from distractors and correct pairs and normalize choice IDs in the same pool. All choices with the same content should have the same id, and all choices with the same id should have the same content; if they don't, then generate a new id with generateRandomSlug('choice'). Try preserving original IDs as much as possible.
  • Group the pool by IDs, and set a proper match-max for each choice into the .
  • Emit max-associations always, equal to the number of pairs.
  • Build <qti-response-declaration> with cardinality="multiple", base-type="pair",
    and <qti-correct-response> listing space-separated pairs.

_defaultState() — seeds a single correct pair in pairs with two empty choices with generated IDs.

2. interactions/associate/validate.js

Export validateAssociateInteraction(state) → ValidationError[]:

Rule Condition
Question is required state.prompt is empty or whitespace-only
Choice cannot be blank Any choice has empty or whitespace-only content
1 or more valid pairs are required If the list of valid pairs (e.g. pairs where both choices are valid) is less than 1
Answers within a pair cannot be the same if a pair has the same content for both items

3. interactions/associate/AssociateInteractionDescriptor.js

Define the descriptor class:

  • type: QtiInteraction.ASSOCIATE ('qti-associate-interaction')
  • placement: 'block'
  • questionTypes: [QuestionType.ASSOCIATE]
  • matches(el): el.tagName.toLowerCase() === QtiInteraction.ASSOCIATE
  • getResponseDeclarationSchema():
    returns { baseType: BaseType.PAIR, cardinality: Cardinality.MULTIPLE }
  • Delegates parse, build, and validate methods.

4. interactions/associate/index.js & interactions/index.js

  • Export via defineInteraction and register the descriptor alongside existing ones.

5. constants.js & qtiEditorStrings.js

  • Add ASSOCIATE: 'associate' to the QuestionType freeze object.
  • Add an explicit translation key (e.g., associateLabel$: 'Connect pairs') to
    qtiEditorStrings.js. Do not dynamically concatenate translation keys.

6. composables/useAssociateInteraction.js

Build on useInteraction and expose state-mutation methods:

  • addPair() - appends a new pair of empty choices in pair with generated choice_<8chars> IDs.
  • removePair(index) — removes the pair from pairs.
  • setPair(index, newPair) — updates the pair at that index, used to update the choice's content field.
  • addDistractor() — appends a new empty choice to distractors with a generated choice_<8chars> ID.
  • removeDistractor(index) — removes a distractor at that index.
  • setDistractorContent(index, html) — updates the choice's content field.

7. interactions/associate/AssociateInteractionEditor.vue

A Vue SFC wiring the composable to the UI.

Props:

props: {
  interaction: Object,     // { bodyXml, responseDeclarations }
  questionType: String,    // 'associate'
  mode: String,            // 'edit' | 'view'
  showAnswers: Boolean,
}

Emits: 'update:interaction'

UI behaviour:

Description Design
Base state with pairs, and distractors Image
A pair opened for editing shows the TipTap editor in place Image
A new distractor opens the TipTap editor at the bottom of the pool Image
The mode="view" card pulls all options from distractors and pairs and shows them shuffled; if showAnswers is true, show the list of correct pairs and correct options with a green border/background Image
Mobile view stacks pairs Image
Validations are similar to other editors, for now show them always, not matter if the inputs have been touched or not Image

Acceptance Criteria

  • parseAssociateInteraction correctly parses a flat choice list and unordered
    pairs.
  • buildAssociateInteractionXML roundtrips: parse(buildXML(state)) produces
    an equivalent state.
  • Pairs in <qti-correct-response> are emitted as space-separated
    <qti-value> tags.
  • Descriptor is registered and validated properly.
  • useAssociateInteraction safely adds/removes.
  • A11y: keyboard-only users can add choices and pairs.
  • Existing lint and test suites pass.

Testing

  • Unit tests for parseAssociateInteraction: round-trip, pair normalization.
  • Unit tests for buildAssociateInteractionXML: pair order in declaration matches
    state; shuffle and max-associations emitted correctly.
  • Unit tests for validateAssociateInteraction: each error condition covered;
    valid state returns [].
  • Unit tests for useAssociateInteraction: each mutation method produces the
    expected state change; removePair strips orphaned pairs on removeChoice.
  • AssociateInteractionEditor.spec.js: edit mode rendering, view mode
    (showAnswers on/off), validation display, update:interaction emit on mutation.

References

  • QTI 3.0 spec §3.2.13 Associate Interaction: IMS Global BPIG.
  • Architecture: shared/views/QTIEditor/ — see interactions/choice/ as the
    nearest reference implementation.

AI usage

I used Claude (Antigravity) to draft this issue from design decisions and the QTI editor architecture.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions