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>
 * @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:

  1. base-type is always "pair" (not "directedPair").
  2. A pair is unordered — "A P" and "P A" are equivalent and must be
    normalized on parse (canonicalize smaller lexicographic ID first).
  3. max-associations controls the total number of pairs a learner can make.
  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('assoc').
  6. 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-associationsundefined, 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions