Skip to content

[QTI] Implement Match Interaction editor #6166

Description

@AlexVelezLl

❌ 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 match interaction plugin end-to-end: XML parsing, XML assembly, validation, a useMatchInteraction composable built on useInteraction, and a working interactions/match/Editor.vue.

Along the way, extract the two pieces of the associate editor that both interactions need — the shuffled response pool and the distractor container — into shared components under shared/views/QTIEditor/components/. The distractor container is generalised into a reusable list of rich-text chips: the match editor uses one instance per matching row to hold that row's correct answers, and one instance for the distractor pool.

  • Complexity: High
  • Target branch: unstable

Context

The match interaction maps to <qti-match-interaction> and covers a "matching rows" question type: a set of prompts on one side, a shuffled set of responses on the other, and the learner drags responses onto the prompt they belong to.

In the UI it is very close to the associate interaction, with two differences:

  1. There is no Pair N label — the editor shows raw matching rows.
  2. A row is 1-N, not 1-1: each row has one prompt and a list of correct answers. Associate rows were always exactly two items.

Structurally, match differs from associate in that its choices live in two <qti-simple-match-set> elements rather than one flat pool, and the response declaration uses base-type="directedPair" rather than "pair" — "Row Col" and "Col Row" are not equivalent, the row identifier always comes first.

QTI XML reference

Official spec example (QTI 3.0 match interaction, IMS Global BPIG):

<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
  identifier="qti3-match" title="Matrix Matching"
  adaptive="false" time-dependent="false">

  <qti-response-declaration identifier="RESPONSE"
    cardinality="multiple" base-type="directedPair">
    <qti-correct-response>
      <qti-value>Row_Dog Col_Mammal</qti-value>
      <qti-value>Row_Eagle Col_Bird</qti-value>
      <qti-value>Row_Frog Col_Amphibian</qti-value>
    </qti-correct-response>
  </qti-response-declaration>

  <qti-item-body>
    <qti-match-interaction response-identifier="RESPONSE" shuffle="true">
      <qti-prompt>Match each animal to its biological classification class:</qti-prompt>
      <qti-simple-match-set>
        <qti-simple-associable-choice identifier="Row_Dog" match-max="1">Dog</qti-simple-associable-choice>
        <qti-simple-associable-choice identifier="Row_Eagle" match-max="1">Eagle</qti-simple-associable-choice>
        <qti-simple-associable-choice identifier="Row_Frog" match-max="1">Frog</qti-simple-associable-choice>
      </qti-simple-match-set>
      <qti-simple-match-set>
        <qti-simple-associable-choice identifier="Col_Bird" match-max="1">Bird</qti-simple-associable-choice>
        <qti-simple-associable-choice identifier="Col_Mammal" match-max="1">Mammal</qti-simple-associable-choice>
        <qti-simple-associable-choice identifier="Col_Amphibian" match-max="1">Amphibian</qti-simple-associable-choice>
      </qti-simple-match-set>
    </qti-match-interaction>
  </qti-item-body>
</qti-assessment-item>

Rules:

  1. base-type is always "directedPair" (not "pair"). The row identifier is always the first token of a <qti-value>, the response identifier the second.
  2. The interaction holds exactly two <qti-simple-match-set> elements, and their order is significant: the first is the row set (prompts), the second is the response set (answers + distractors). Emit them in that order and do not reorder the choices within a set — the authored order is what the editor shows on reload.
  3. max-associations controls how many associations the learner may make in total. Always set it to the total number of <qti-value> entries in <qti-correct-response> (i.e. the sum of every row's answers).
  4. match-max on a row choice is how many responses that row accepts. Emit match-max = max(row.matches.length, 1).
  5. A response is either a correct answer or a distractor, never both. Distractors are exactly the response-set choices named by no <qti-correct-response> value. This is where match diverges from associate: associate shares one flat pool, so it reads a choice's surplus match-max capacity as distractor entries. Match has a dedicated response set, so appearance in the correct response is the only signal, and there is no surplus to read.
  6. match-max on a response choice is how many rows it may be dropped onto. Emit the number of correct-response values naming it, or 1 for a distractor — never 0, which means unlimited in QTI rather than zero. It is never read on import: a hand-written item whose match-max disagrees with its correct response, or leaves it at the unlimited 0 the published spec example uses, still imports by appearances alone.
  7. shuffle should always be emitted as "true".
  8. A choice with no identifier is assigned generateRandomSlug('row') (row set) or generateRandomSlug('choice') (response set).
  9. Identifiers must be unique across both match sets — resolve ids over the union of the two sets, not per set.
  10. Two answers with identical content collapse into a single choice whose match-max counts both — that is how one response serves two rows. Distractors are never merged, with each other or with an answer: duplicated content on a distractor is a validation error the author has to see, and merging would make one of the two silently disappear instead. Row prompts are not deduplicated against responses either: they live in a different set and are different choices even when their text is identical.

State shape

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

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

/**
 * @typedef {object} MatchRow
 * @property {string}        id       - QTI identifier of the row's first-set choice
 * @property {string}        content  - HTML content of the row prompt
 * @property {MatchChoice[]} matches  - Second-set choices this row is correctly matched to
 */

/**
 * @typedef {object} MatchState
 * @property {string}        responseIdentifier - Response identifier attribute
 * @property {string}        prompt             - HTML content of <qti-prompt>; default ""
 * @property {MatchRow[]}    rows               - Matching rows, in authored order
 * @property {MatchChoice[]} distractors        - Response-set choices matched by no row
 */

Architecture this plugs into

  • Each interaction is a directory of Descriptor.js, Editor.vue, parse.js and validation.js, with its specs in __tests__/.
  • Descriptor.js exports a class extending interactions/InteractionDescriptor.js and a module singleton. The base class checks the contract in its constructor, i.e. at import time — there is no defineInteraction helper any more, and the editor component is not a property of the descriptor.
  • Descriptors must not import .vue files. descriptors.js is imported by the headless validator (validateItem.js), which runs outside any editor.
  • Registration is therefore in two places that have to agree: interactions/descriptors.js (the descriptors array) and interactions/index.js (the editors map keyed by QtiInteraction). interactions/__tests__/registry.spec.js asserts the two lists match.
  • DOM helpers (parseXML, getPromptHTML) live in serialization/xml.js.
  • Emptiness and duplicate comparison go through utils/richText.js — hasRichTextContent(content) and richTextComparisonKey(content). Do not strip tags by hand. A choice can be an image or a formula with no text at all, and a text-only test calls those empty and silently drops them.
  • useInteraction validates on every state change (the old 400 ms debounce is gone) and exposes runValidation.
  • An interaction editor emits update:interaction and update:errors. It is also handed a teleportTargetId prop, which only the choice editor uses.

The Change

Part A — extract the shared pieces from the associate editor

interactions/associate/Editor.vue currently owns both of these inline. Move them out and have the associate editor consume them, so it loses the corresponding template, script and style, and both interactions render the same markup from one source. Associate behaviour must not change — interactions/associate/__tests__/Editor.spec.js is the regression guard and should keep passing unmodified, except for the tests that move into the new components' specs.

A1. components/ShuffledResponsePool/index.vue

The read-only pool shown in mode="view" — a tinted box with a section label and a wrapping list of view-mode chips, green-bordered and green-tinted when the chip is a correct answer and answers are being revealed.

props: {
  /** @type {Array<{ content: String, isCorrect: Boolean }>} */
  choices: { type: Array, required: true },
  label:   { type: String, required: true },   // section label and list aria-label
}
  • The component owns the shuffling; the caller passes the choices in state order.
  • The caller decides correctness, the component only paints it. Keep the existing $themePalette.green.v_600 border and green.v_50 background.
  • Keyed by position, not by id: a choice id may legitimately repeat in the pool.

A2. components/EditableChipList/index.vue

The generalised distractor container: a box holding N rich-text chips, each editable in place, plus a draft chip for the one being written. This is the component the match rows reuse for their correct answers. Its behaviour is carried over verbatim from the associate distractor pool; the one new capability is how a new chip is started.

props: {
  /** @type {Array<{ id: String, content: String }>} */
  chips:      { type: Array, required: true },
  /** Index of the chip currently open for editing, or null. Controlled by the parent. */
  openIndex:  { type: Number, default: null },
  /** The chip being written, or null. Controlled by the parent. */
  draft:      { type: Object, default: null },
  /** 'button' renders an AddListItemButton; 'region' makes the whole box clickable. */
  addMode:    { type: String, default: 'button' },
  addLabel:   { type: String, required: true },  // button label / region aria-label
  listLabel:  { type: String, required: true },  // aria-label for the chip list
  /** (position: Number) => String — 1-based; accessible label for editing a chip */
  chipLabel:   { type: Function, required: true },
  /** (position: Number) => String — 1-based; accessible label for deleting a chip */
  deleteLabel: { type: Function, required: true },
  /** Parallel to `chips`; null where a chip has no error */
  errorMessages: { type: Array, default: () => [] },
}

emits: [
  'open-chip',    // (index)
  'update-chip',  // (index, html)
  'remove-chip',  // (index)
  'open-draft',
  'update-draft', // (html)
  'discard-draft',
  'close',        // TipTap 'minimize' — the parent commits and clears the open target
]
  • addMode: 'button' reproduces today's associate distractor pool: an AddListItemButton under the chips.
  • addMode: 'region' makes the container itself the add affordance — cursor: text, clicking anywhere in the box that is not a chip opens the draft editor. This is what a matching row's answer list uses. It still needs a keyboard route to the same action, so use ClickableRegion — its hidden overlay button is already a focusable, labelled target — rather than a bare @click on a div.

The component is controlled: the parent passes openIndex and draft and decides what happens on close. That is deliberate — the QTI editors enforce at most one open TipTap editor per interaction, and the match editor has several chip lists plus a prompt and N row prompts competing for that single slot.

Part B — the match interaction plugin

1. interactions/match/parse.js

Export parseMatchInteraction(bodyXml, responseDeclarations) → MatchState:

  • Read the two <qti-simple-match-set> children. If there are fewer or more than two, the editor should not be able to open.
  • Build rows from the first set, in document order, each starting with matches: [].
  • Read the correct response with QTIDeclaration.fromXML. Drop any value naming an identifier absent from the corresponding set.
  • Derive distractors from the second set: every choice named by no correct-response value becomes one distractor, in document order.
  • Assign generateRandomSlug('row') / generateRandomSlug('choice') to choices with no identifier.

_defaultState() seeds one row with a blank prompt and one blank answer, and no distractors — the same shape a freshly added row gets.

Export buildMatchInteractionXML(state, questionType, declarationSchema) → { bodyXml, responseDeclarations }:

  • Resolve identifiers the way buildAssociateInteractionXML does.
  • Emit the row set first, then the response set, with match-max per rules 4 and 6.
  • Build the response set in two parts, per rule 10: the rows' answers grouped by content into one choice each, carrying the number of rows that answer serves as its match-max; then every distractor as its own choice with match-max="1", ungrouped.
  • Emit shuffle="true" and max-associations = total answers across all rows.
  • Build the <qti-response-declaration> with cardinality="multiple", base-type="directedPair" and one <qti-value> per answer, "rowId responseId", ordered by row and then by position within the row.

2. interactions/match/validation.js

Export validateMatchInteraction(state) → ValidationError[].

Message Condition Code
Question is required state.prompt is empty PROMPT_REQUIRED
Prompt cannot be blank a row's content is empty EMPTY_ROW_CONTENT (new)
Answer cannot be blank an answer or distractor is empty EMPTY_CHOICE_CONTENT
Add at least one answer a row has no non-blank answer ROW_WITHOUT_MATCH (new)
1 or more valid rows are required no row has both a non-blank prompt and ≥1 non-blank answer TOO_FEW_ROWS (new)
Answers within a row cannot be the same two answers in one row share a comparison key DUPLICATE_MATCH_CONTENT (new)
Rows cannot repeat the same prompt two rows share a prompt comparison key DUPLICATE_ROW_CONTENT (new)
Distractors cannot repeat another item a distractor's key equals another distractor's or any row's answer DUPLICATE_DISTRACTOR_CONTENT

DUPLICATE_DISTRACTOR_CONTENT is what holds rule 5 up. Nothing in the state shape stops an author writing a distractor that reads the same as one of the answers, and the XML cannot represent that choice as both — so this is the rule that tells them, and it fires on the distractor rather than on the answer, because the answer is the one the question needs.

Two things that are not errors:

  • The same answer content matched by two different rows. That is the point of allowing match-max > 1, and it is how "which of these belong to X and to Y" is authored.
  • A row prompt whose text equals an answer's text. They live in different sets.

3. interactions/match/Descriptor.js

  • placement is left at its Placement.BLOCK default.
  • matches(el) is not overridden — the base class's tag-name comparison is correct for a block interaction.
  • getQuestionType() returns QuestionType.MATCH; match has exactly one question type.
  • getResponseDeclarationSchema() returns { baseType: BaseType.DIRECTED_PAIR, cardinality: Cardinality.MULTIPLE }.
  • getTypeOptions(tr) returns the single matchLabel$ / matchDescription$ option.
  • parse, buildXML and validate delegate to the modules above.
  • No .vue import anywhere in this file or its imports.

4. Registration

  • interactions/descriptors.js — import the singleton and append it to descriptors.
  • interactions/index.js — import ./match/Editor.vue and add it to editors under QtiInteraction.MATCH.

5. constants.js, qtiEditorStrings.js & QTIItemEditor

  • Add MATCH: 'match' to QuestionType.
  • Add EMPTY_ROW_CONTENT, ROW_WITHOUT_MATCH, TOO_FEW_ROWS, DUPLICATE_ROW_CONTENT and DUPLICATE_MATCH_CONTENT to ValidationError.
  • Add the new translation keys. matchLabel already exists as a placeholder — give it a matchDescription sibling and check its context still reads correctly.
  • Add [QuestionType.MATCH]: qtiEditorStrings.matchLabel$ to QUESTION_TYPE_LABELS in components/QTIItemEditor/index.vue. Registering the descriptor does not populate that map, and without the entry every match item's view-mode header reads "Unknown type".

6. composables/useMatchInteraction.js

Build on useInteraction, return readonly(state) plus mutation methods:

  • addRow() — appends a row with a blank prompt, one blank answer, and a generated id.
  • removeRow(index) — removes a row; refuses when only one row remains, the way removePair does.
  • setRowContent(index, html) — updates the row prompt.
  • addMatch(rowIndex, content = ''), removeMatch(rowIndex, index), setMatchContent(rowIndex, index, html)
  • addDistractor(content = ''), removeDistractor(index), setDistractorContent(index, html) — identical to the associate versions.
  • setPrompt(html)

Every method replaces state.value with a new object rather than mutating in place.

7. interactions/match/Editor.vue

A Vue SFC wiring the composable to the UI, in Composition API, KDS and theme tokens only. It should generally look similar to the associate interaction with these distinctions in mind:

Description Design
Base state with matches and distractors. Image
On view mode, it should show prompt and correct answers in two columns Image
Mobile view Image

View mode renders the rows read-only in the same shape the associate editor uses for its pairs: the row prompt as a chip beside its answer chips (only shown if showAnswers), green when showAnswers. The only difference from associate is that the answers side can hold more than one chip, and that the "prompt" column is always displayed.

Editing rules (all inherited from the associate editor):

  • At most one TipTap editor is open across the whole interaction at any time.
  • Entering edit mode opens the question prompt when it is blank, otherwise the first row's prompt.
  • Deleting anything closes the open editor first, since open targets are held by index, then hands focus to the element taking the deleted one's place.
  • Leaving edit mode discards any draft rather than committing it — the parent has stopped listening, so a commit would go unreported.
  • Validation messages show regardless of whether the field has been touched, matching the other editors today. [QTI] Delay validation messages until the author has engaged with the question #6107 changes that for every editor at once; do not special-case match here.

Responsive: below the large breakpoint a row stacks its prompt above its answers, the way the associate pair row stacks its two cards; below the small breakpoint every row stacks whether or not it is being edited. Use useKResponsiveWindow, plain CSS in a <style> block, and no inline directional styles.


Acceptance Criteria

Shared components

  • ShuffledResponsePool and EditableChipList exist under shared/views/QTIEditor/components/, each with its own spec.
  • interactions/associate/Editor.vue consumes both and no longer carries its own copy of that template, script or CSS.
  • The associate editor's behaviour is unchanged: its Editor.spec.js passes without weakening any assertion. Tests only move when the behaviour they cover moved into a shared component's spec.
  • EditableChipList supports both add affordances, and addMode: 'region' is reachable by keyboard as well as by pointer.

Parsing and serialization

  • parseMatchInteraction reads both match sets, assigns each row its answers from the directedPair correct response in authored order, and takes every response named by no correct-response value — and only those — as a distractor, without reading match-max.
  • buildMatchInteractionXML round-trips: parse(buildXML(state)) produces an equivalent state, including a row with two answers, a response shared by two rows, and two distractors holding the same content.
  • The row set is always emitted before the response set, and every <qti-value> is "rowId responseId" in that order.
  • max-associations equals the total number of answers; shuffle="true" is emitted.
  • An interaction that does not hold exactly two <qti-simple-match-set> elements is treated as unsupported rather than opened in the editor, and malformed XML surfaces as a parse error rather than throwing.
  • Emptiness and duplicate detection go through utils/richText.js, so an image-only or formula-only choice is neither dropped nor reported blank.

Plugin wiring

  • The descriptor is registered in descriptors.js, the editor in index.js, and registry.spec.js passes.
  • descriptors.js and everything it imports stay free of .vue files — validateQtiItem validates a match item headlessly.
  • A match item's card header reads its question-type label, not "Unknown type".
  • useMatchInteraction adds and removes rows, answers and distractors safely, and refuses to remove the last row.

Editor

  • Rows show no Pair N numbering.
  • A row's answer container opens a draft editor when its empty area is clicked, shows cursor: text, and commits the draft when that editor closes with content.
  • update:errors is emitted so the question card can show that the item needs work.
  • Every user-visible string comes from createTranslator; no hard-coded copy.
  • Colours come from $themeTokens / $themePalette; no raw hex values; non-dynamic styles live in <style> blocks.
  • A11y: keyboard-only users can reach and operate every control — add/remove a row, add/remove an answer, add/remove a distractor, and open each editor — and focus lands somewhere sensible after each removal. No axe-core violations beyond the pre-existing AddListItemButton contrast one.
  • Existing lint and test suites pass; pre-commit run --all-files is clean.

Testing

Use the Editor.vue tests as an integration test of the entire editor, not only a unit test.


References


AI usage

I used Claude Code to draft this issue from the associate interaction implementation as merged in #6113, the QTI 3.0 match interaction spec example, and the abstraction plan for the shared pool and chip-list components. I reviewed the XML rules against the spec example and the existing parse.js / validation.js behaviour before including them.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions