learningequality / learningequality/studio
[QTI] Implement Associate Interaction editor
@rtibblesbot is already working on this.
Since Aug 31, 2026.
- Dominant language
- Python
- Stars
- 191
- Forks
- 307
- Avg merge
- 5d 6h
- Merged PRs (30d)
- 10
Description
❌ 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:
base-typeis always"pair"(not"directedPair").- A
pairis unordered —"A P"and"P A"are equivalent. Do not modify the saved order; otherwise, pairs in the UI will change order on reload. max-associationscontrols the total number of pairs a learner can make. Always set this to the number of correct pairs inqti-correct-response.match-maxon each choice controls how many pairs that specific choice can be part of.- Items without an identifier must be assigned
generateRandomSlug('choice'). shufflecontrols whether the delivery engine randomizes display order. Should always be set to true.- Choices not present on
qti-correct-responseare considered distractors. - If a choice has a
match-maxgreater than the number of appearances onqti-correct-response, then the difference is the number of distractors that choice accounts for. - 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
bodyXmlwithparseXML. - Extract
<qti-prompt>inner HTML →promptvia the sharedgetPromptHTMLhelper. - Collect all
<qti-simple-associable-choice>elements into thepairsarray with their correspondingqti-simple-associable-choicecontent. <qti-simple-associable-choice>not present inqti-correct-responsegoes into the flatdistractorsarray.- If
identifieris not present on<qti-simple-associable-choice>, then assign it togenerateRandomSlug('choice').
Export buildAssociateInteractionXML(state, questionType, declarationSchema):
- Serialize
state.promptas<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 withgenerateRandomSlug('choice'). Try preserving original IDs as much as possible. - Group the pool by IDs, and set a proper
match-maxfor each choice into the . - Emit
max-associationsalways, equal to the number ofpairs. - Build
<qti-response-declaration>withcardinality="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.ASSOCIATEgetResponseDeclarationSchema():
returns{ baseType: BaseType.PAIR, cardinality: Cardinality.MULTIPLE }- Delegates parse, build, and validate methods.
4. interactions/associate/index.js & interactions/index.js
- Export via
defineInteractionand register the descriptor alongside existing ones.
5. constants.js & qtiEditorStrings.js
- Add
ASSOCIATE: 'associate'to theQuestionTypefreeze 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 inpairwith generatedchoice_<8chars>IDs.removePair(index)— removes the pair frompairs.setPair(index, newPair)— updates the pair at that index, used to update the choice's content field.addDistractor()— appends a new empty choice todistractorswith a generatedchoice_<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 | |
| A pair opened for editing shows the TipTap editor in place | |
| A new distractor opens the TipTap editor at the bottom of the pool | |
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 |
|
| Mobile view stacks pairs | |
| Validations are similar to other editors, for now show them always, not matter if the inputs have been touched or not |
Acceptance Criteria
parseAssociateInteractioncorrectly parses a flat choice list and unordered
pairs.buildAssociateInteractionXMLroundtrips: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.
useAssociateInteractionsafely 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;shuffleandmax-associationsemitted correctly. - Unit tests for
validateAssociateInteraction: each error condition covered;
valid state returns[]. - Unit tests for
useAssociateInteraction: each mutation method produces the
expected state change;removePairstrips orphaned pairs onremoveChoice. AssociateInteractionEditor.spec.js: edit mode rendering, view mode
(showAnswerson/off), validation display,update:interactionemit on mutation.
References
- QTI 3.0 spec §3.2.13 Associate Interaction: IMS Global BPIG.
- Architecture:
shared/views/QTIEditor/— seeinteractions/choice/as the
nearest reference implementation.
AI usage
I used Claude (Antigravity) to draft this issue from design decisions and the QTI editor architecture.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.