
❌ 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-type is always "pair" (not "directedPair").
- 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.
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.
match-max on each choice controls how many pairs that specific choice can be part of.
- Items without an identifier must be assigned
generateRandomSlug('choice').
shuffle controls whether the delivery engine randomizes display order. Should always be set to true.
- Choices not present on
qti-correct-response are considered distractors.
- 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.
- 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 |
 |
| 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
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.
❌ 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
useAssociateInteractioncomposable built onuseInteraction, and aworking
AssociateInteractionEditor.vue.This task depends on the choice, text-entry, and ordering interaction plugins
being in place (for
useInteraction,generateRandomSlug, anddefineInteraction).Complexity: High
Target branch: unstable
Context
The associate interaction maps to
<qti-associate-interaction>and covers a "connectpairs" 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"andbase-type="pair", listingunordered 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:QTI XML reference
Official spec example (§3.2.13, IMS Global BPIG):
Rules:
base-typeis always"pair"(not"directedPair").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.generateRandomSlug('choice').shufflecontrols whether the delivery engine randomizes display order. Should always be set to true.qti-correct-responseare considered distractors.match-maxgreater than the number of appearances onqti-correct-response, then the difference is the number of distractors that choice accounts for.match-max.The Change
1.
interactions/associate/parse.jsExport
parseAssociateInteraction(bodyXml, responseDeclarations) → AssociateState:bodyXmlwithparseXML.<qti-prompt>inner HTML →promptvia the sharedgetPromptHTMLhelper.<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.identifieris not present on<qti-simple-associable-choice>, then assign it togenerateRandomSlug('choice').Export
buildAssociateInteractionXML(state, questionType, declarationSchema):state.promptas<qti-prompt>.<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.match-maxfor each choice into the .max-associationsalways, equal to the number ofpairs.<qti-response-declaration>withcardinality="multiple",base-type="pair",and
<qti-correct-response>listing space-separated pairs._defaultState()— seeds a single correct pair inpairswith two empty choices with generated IDs.2.
interactions/associate/validate.jsExport
validateAssociateInteraction(state) → ValidationError[]:state.promptis empty or whitespace-only3.
interactions/associate/AssociateInteractionDescriptor.jsDefine 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 }4.
interactions/associate/index.js&interactions/index.jsdefineInteractionand register the descriptor alongside existing ones.5.
constants.js&qtiEditorStrings.jsASSOCIATE: 'associate'to theQuestionTypefreeze object.associateLabel$: 'Connect pairs') toqtiEditorStrings.js. Do not dynamically concatenate translation keys.6.
composables/useAssociateInteraction.jsBuild on
useInteractionand 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.vueA Vue SFC wiring the composable to the UI.
Props:
Emits:
'update:interaction'UI behaviour:
showAnswersis true, show the list of correct pairs and correct options with a green border/backgroundAcceptance Criteria
parseAssociateInteractioncorrectly parses a flat choice list and unorderedpairs.
buildAssociateInteractionXMLroundtrips:parse(buildXML(state))producesan equivalent state.
<qti-correct-response>are emitted as space-separated<qti-value>tags.useAssociateInteractionsafely adds/removes.Testing
parseAssociateInteraction: round-trip, pair normalization.buildAssociateInteractionXML: pair order in declaration matchesstate;
shuffleandmax-associationsemitted correctly.validateAssociateInteraction: each error condition covered;valid state returns
[].useAssociateInteraction: each mutation method produces theexpected state change;
removePairstrips orphaned pairs onremoveChoice.AssociateInteractionEditor.spec.js: edit mode rendering, view mode(
showAnswerson/off), validation display,update:interactionemit on mutation.References
shared/views/QTIEditor/— seeinteractions/choice/as thenearest reference implementation.
AI usage
I used Claude (Antigravity) to draft this issue from design decisions and the QTI editor architecture.