
❌ 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>
* @property {number} matchMax - Maximum number of associations this choice can be part of
* (from match-max attribute; default 1)
*/
/**
* @typedef {object} AssociateState
* @property {string} responseIdentifier - Response identifier attribute
* @property {string} prompt - HTML content of <qti-prompt>; default ""
* @property {AssociateChoice[]} choices - Single flat pool of choices
* @property {Array<{source: string, target: string}>} correctResponse
* - Correctly associated pairs (unordered)
* @property {number|undefined} maxAssociations - From max-associations attribute;
* undefined when absent
* @property {boolean} shuffle - From shuffle attribute; default true
*/
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 and must be
normalized on parse (canonicalize smaller lexicographic ID first).
max-associations controls the total number of pairs a learner can make.
match-max on each choice controls how many pairs that specific choice
can be part of.
- Items without an identifier must be assigned
generateRandomSlug('assoc').
shuffle controls whether the delivery engine randomizes display order.
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 a flat choices array.
Each choice gets { id, content, matchMax } (default matchMax = 1).
- Read
max-associations from the <qti-associate-interaction> element; store as
undefined when absent.
- Parse the response declaration with
QTIDeclaration.fromXML to extract
correctResponse as { source, target } pairs. Normalize each pair so the
lexicographically smaller ID is always source.
Export buildAssociateInteractionXML(state, questionType, declarationSchema):
- Serialize
state.prompt as <qti-prompt>.
- Render each choice as
<qti-simple-associable-choice> with its match-max attribute.
- Emit
max-associations only when defined.
- Build
<qti-response-declaration> with cardinality="multiple", base-type="pair",
and <qti-correct-response> listing space-separated normalized pairs.
Export _defaultState() — seeds choices with two empty choices with generated IDs
to prevent an empty <qti-correct-response/> schema error for new items.
2. interactions/associate/validate.js
Export validateAssociateInteraction(state) → ValidationError[]:
| Rule |
Condition |
| Prompt required |
state.prompt is empty or whitespace-only |
| Empty choice content |
Any choice has empty or whitespace-only content |
| Duplicate choices |
Two or more choices have identical content |
| Too few choices |
Fewer than 2 choices in state.choices |
| Unmatched choices |
Any choice ID does not appear in correctResponse |
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:
addChoice() — appends a new choice with a generated assoc_<8chars> ID.
removeChoice(choiceId) — removes a choice and strips any correctResponse pairs
that included it. No-op when only one choice remains.
setChoiceContent(choiceId, html) — updates the choice's content field.
setMatchMax(choiceId, value) — updates the matchMax for a specific choice.
addPair(sourceId, targetId) — adds a normalized pair to correctResponse;
no-op if the pair already exists.
removePair(sourceId, targetId) — removes the pair from correctResponse.
setMaxAssociations(value) — updates the maxAssociations 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,
teleportTargetId: String,
}
Emits: 'update:interaction'
UI behaviour:
- Renders the prompt RTE at the top.
- Renders a flat list of choices. Each row contains:
- A content RTE (TipTapEditor) with
:readonly="mode !== 'edit'".
- A delete button on the right (disabled when only one choice remains).
- Below the list, an "Add option" button (using
AddListItemButton).
- Below the choices, a "Correct Pairs" section where the author defines which
choices should be connected:
- Renders the defined pairs as dismissible tags/chips (
Source ↔ Target).
- Provides a UI to add a new pair by selecting two choices from KSelect dropdowns.
- The "Add pair" UI must filter out already-fully-matched choices (where the
choice has reached its matchMax count).
Accessibility
- The "connect pairs" UX must be keyboard accessible. Use
KSelect dropdowns
(not drag-and-drop lines) for the pair authoring UI to ensure screen reader and
keyboard compatibility.
- Focus Management: When
addChoice() is called, use Vue template refs to focus
the new choice's RTE. Never use document.getElementById.
- Each pair chip must have an accessible remove button with a descriptive
aria-label (e.g., "Remove pair: Antonio ↔ Prospero").
View mode:
- With
showAnswers: true: render the list of choices with correct pairs clearly
indicated (e.g., a "connected to" label between paired items), read-only.
- With
showAnswers: false: choices are hidden.
Emits 'update:interaction' with an early return guard:
if (props.mode !== 'edit') return;
Calls runValidation() on blur. Never on addChoice().
Acceptance Criteria
parseAssociateInteraction correctly parses a flat choice list and unordered
pairs, normalizing pair order lexicographically.
buildAssociateInteractionXML roundtrips: parse(buildXML(state)) produces
an equivalent state.
- Pairs in
<qti-correct-response> are emitted as normalized space-separated
<qti-value> tags.
- Descriptor is registered and validated properly.
useAssociateInteraction safely adds/removes pairs without orphaned IDs or
duplicate pairs.
AssociateInteractionEditor.vue renders a flat choice list with a
keyboard-accessible pair authoring UI.
- A11y: keyboard-only users can add choices and define pairs using dropdowns.
- Existing lint and test suites pass.
Testing
- Unit tests for
parseAssociateInteraction: round-trip, pair normalization,
absent max-associations → undefined, shuffle attribute preservation.
- 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 and must benormalized on parse (canonicalize smaller lexicographic ID first).
max-associationscontrols the total number of pairs a learner can make.match-maxon each choice controls how many pairs that specific choicecan be part of.
generateRandomSlug('assoc').shufflecontrols whether the delivery engine randomizes display order.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 a flatchoicesarray.Each choice gets
{ id, content, matchMax }(defaultmatchMax = 1).max-associationsfrom the<qti-associate-interaction>element; store asundefinedwhen absent.QTIDeclaration.fromXMLto extractcorrectResponseas{ source, target }pairs. Normalize each pair so thelexicographically smaller ID is always
source.Export
buildAssociateInteractionXML(state, questionType, declarationSchema):state.promptas<qti-prompt>.<qti-simple-associable-choice>with itsmatch-maxattribute.max-associationsonly when defined.<qti-response-declaration>withcardinality="multiple",base-type="pair",and
<qti-correct-response>listing space-separated normalized pairs.Export
_defaultState()— seedschoiceswith two empty choices with generated IDsto prevent an empty
<qti-correct-response/>schema error for new items.2.
interactions/associate/validate.jsExport
validateAssociateInteraction(state) → ValidationError[]:state.promptis empty or whitespace-onlystate.choicescorrectResponse3.
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:addChoice()— appends a new choice with a generatedassoc_<8chars>ID.removeChoice(choiceId)— removes a choice and strips anycorrectResponsepairsthat included it. No-op when only one choice remains.
setChoiceContent(choiceId, html)— updates the choice's content field.setMatchMax(choiceId, value)— updates thematchMaxfor a specific choice.addPair(sourceId, targetId)— adds a normalized pair tocorrectResponse;no-op if the pair already exists.
removePair(sourceId, targetId)— removes the pair fromcorrectResponse.setMaxAssociations(value)— updates themaxAssociationsfield.7.
interactions/associate/AssociateInteractionEditor.vueA Vue SFC wiring the composable to the UI.
Props:
Emits:
'update:interaction'UI behaviour:
:readonly="mode !== 'edit'".AddListItemButton).choices should be connected:
Source ↔ Target).choice has reached its
matchMaxcount).Accessibility
KSelectdropdowns(not drag-and-drop lines) for the pair authoring UI to ensure screen reader and
keyboard compatibility.
addChoice()is called, use Vue template refs to focusthe new choice's RTE. Never use
document.getElementById.aria-label(e.g.,"Remove pair: Antonio ↔ Prospero").View mode:
showAnswers: true: render the list of choices with correct pairs clearlyindicated (e.g., a "connected to" label between paired items), read-only.
showAnswers: false: choices are hidden.Emits
'update:interaction'with an early return guard:Calls
runValidation()on blur. Never onaddChoice().Acceptance Criteria
parseAssociateInteractioncorrectly parses a flat choice list and unorderedpairs, normalizing pair order lexicographically.
buildAssociateInteractionXMLroundtrips:parse(buildXML(state))producesan equivalent state.
<qti-correct-response>are emitted as normalized space-separated<qti-value>tags.useAssociateInteractionsafely adds/removes pairs without orphaned IDs orduplicate pairs.
AssociateInteractionEditor.vuerenders a flat choice list with akeyboard-accessible pair authoring UI.
Testing
parseAssociateInteraction: round-trip, pair normalization,absent
max-associations→undefined,shuffleattribute preservation.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.