Skip to content

Add the QTI associate interaction plugin - #6113

Merged
AlexVelezLl merged 7 commits into
learningequality:unstablefrom
rtibblesbot:issue-6101-e6feda
Sep 23, 2026
Merged

AlexVelezLl merged 7 commits into
learningequality:unstablefrom
rtibblesbot:issue-6101-e6feda

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

<qti-associate-interaction> had no plugin, so it fell back to the choice descriptor: header "Multiple Choice", no choices rendered, an associate pool holding no <qti-simple-choice>. The plugin parses the flat pool into correct pairs plus distractors, serializes a cardinality="multiple" base-type="pair" declaration, validates and renders.

Long formulas are unbreakable inline nodes that painted over the card border. The bound went into TipTapEditor's .editor-content, so every TipTap editor clips, legacy ones included.

References

Fixes #6101.

Reviewer guidance

749 QTI editor Jest tests pass. Sole axe-core violation: AddListItemButton label contrast, shared with the untouched ordering editor.

Open risks (XML rules 8, 9):

  • buildXML merges equal-text choices, bumping match-max; blank text exempt, else new pairs never round-trip.
  • Distractors are match-max minus correct-response uses, so oversized hand-written match-max invents them on import.

QA steps

Setup: pnpm devsetup data, then #/qti-demo in a channel, question 7.

  1. Toggle Show answers: Connect pairs header, pool of all options plus both distractors, pairs green only when on.
  2. Edit prompt and pair cards, close, reopen: text, order, numbering survive, nothing merged or dropped.
  3. Add pair opens the new row's first card; Add distractor drafts below the pool — filled joins on close, blank is dropped, second press re-focuses, close abandons.
  4. Keyboard-delete pairs and distractors, open row included: rows renumber, focus moves to the replacing row or Add pair, last delete disabled.
  5. Trigger then clear each error: blank prompt, blank choice, self-matching pair, distractor repeating a distractor or a paired item (flags both), too few pairs — red borders, incomplete marker clears.
  6. Switch a question's type to Associate and back: prompt survives, blank editor usable at once, card header label follows.

Evidence

View mode and Show answers

Answers off Answers on
Answers off Answers on
Answers off Answers on

View mode

Edit mode

Repeated choice, answers on

412px, answers on

412px, answers on

412px, correct-pairs list

RTL, answers on

RTL, answers on

Show answers toggle

Show answers toggle

View to edit

Editing and the XML round trip

Before edits After edits
Initial Edited
Edits made Reopened

Reopened

Closed, view mode

Reparsed from XML

Closed without minimize, reparsed

Fourth pair added and filled

View mode after close

Reopened, pairs preserved

Close and reopen

Add a pair and edit both cards

Adding pairs and distractors

Add pressed After
New row open Fifth pair and grown pool
Draft open below the pool Draft written
Draft typed On close
Typed Committed to the pool
Empty chip Emptied distractor dropped

Draft open with text

Second draft open

Draft absent

412px, draft open

412px, toolbar overflow and minimize

RTL, draft open

Add a pair and distractors

Distractor lifecycle

Earlier draft behaviour

Committing an open editor

Open Committed
Draft typed In the pool
Emptied, still open Dropped from the pool
412px, draft open 412px, committed
RTL, draft open RTL, committed

Edit mode baseline

Prompt typed

Pair card typed

Bolded draft, caret kept

All commits applied

Commit on minimize

Deleting pairs and distractors

Before delete After delete
Pair 2 editor open Rows renumbered
Distractor 1 editor open Empty distractor pool
Mouse delete, no focus ring Focus ring on the replacing delete button

Three pairs baseline

Last pair, delete disabled

Last pair, delete disabled

All distractors removed

One pair, no distractors

Focus ring on Add pair

Keyboard delete

Delete pairs and distractors

Validation

With errors Cleared
Every error at once Clean
No valid pair Fixed
Clean edit mode Blank prompt

Blank prompt

Blank choice

Pair whose items match

Distractor repeating a distractor

Distractor repeating a paired item

Too few pairs

Closed card, incomplete

Blank pair card

Pair whose items match

412px, errors

412px, distractor errors

RTL, errors

RTL, errors

Validation

Validation messages

Switching the response type

Before switch After switch
Choice Blank associate
Blank after the switch Usable after a round trip

Type select open

Type select open

Closed card header

Associate to ordering

Associate to single choice

Response type info modal

Response type info modal

Add pair and distractor straight away

412px, blank associate

RTL, type select open

RTL, blank associate

Type switch

Switch type to associate

Long content inside the card

Pair with a long URL

Pair, formula popover

Pair, URL and formula

Pair, long formula

Pair, formula, card closed

Distractor, long formula

Distractor chip, card closed

View mode pool, formula

Choice editor, formula option

Ordering prompt and item

Hint editor, long formula

412px, hints and ordering

412px, choice view mode

412px, associate editor

412px, distractor chip

412px, text entry prompt

412px, choice option

RTL, pair with a formula

Long content stays inside the card

Responsive layout

Wide Narrow
1440px, no rows stacked 412px, every row stacked
1440px, editing, no stack 412px, every row stacked

841px boundary

900px, no rows stacked

800px, only the open row stacked

620px, only the open row stacked

599px, every row stacked

RTL, 800px

RTL, 412px

800px, only the edited row stacks

850px, side-by-side toolbar

800px, edited row stacked

1440px again

RTL, 800px

620px, narrow side by side

Resize breakpoints

Resize while editing

An authored associate question, saved and reloaded

Authored After reload
Edit mode Edit mode after reload
Resource panel, answers off Resource panel, answers on

Resource panel, answers scrolled

Reloaded edit mode, rich text

RTL, resource panel, answers on

412px, resource panel, answers on

412px, answers scrolled

Resource panel, incomplete banner

Save and reload

Deviations from the issue spec

Criterion Why not
A new distractor is saved from the editor opened below the pool Committed on editor close, no Save button, per review — matches the other RTEs

AI usage

Used Claude Code to implement the plugin test-first against a written plan, following the ordering interaction as the reference. Verified with the Jest suite, pre-commit, manual QA on the QTI demo page, and an axe-core audit.


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?
  • Ran pre-flight CI checks (lint, format, tests) and verified all pass
  • Rebased onto the target branch and resolved any conflicts
  • Reorganized commit history into clean, logical commits
  • Audited the diff to ensure only issue-relevant files are changed
  • Built PR body from the repository's PR template with evidence blocks
@rtibblesbot

⚪ Updating PR

Last updated: 2026-09-23 22:53 UTC

adddistractor-1-draft-open-below-pool

adddistractor-2-draft-written-quito

adddistractor-mobile-412-draft-open

adddistractor-mobile-412-toolbar-more-minimize-overlap

adddistractor-rtl-draft-open

addpair-1-new-row-open

addpair-2-fifth-pair-and-grown-pool

associate-add-pair-and-distractors.webm

associate-edit-after-edits

associate-edit-close-reopen.webm

associate-edit-initial

associate-edit-reopened

associate-keyboard-delete.webm
associate-minimize-commits.webm
associate-resize-while-editing.webm
associate-show-answers-toggle.webm
associate-type-switch.webm

associate-validation-01-clean-edit-mode

associate-validation-02-blank-prompt

associate-validation-03-blank-pair-card

associate-validation-04-duplicate-pair

associate-validation-05-no-valid-pair

associate-validation-06-all-fixed

associate-validation-07-rtl-errors

associate-validation-messages.webm
associate-validation.webm

associate-view-answers-off

associate-view-answers-on

associate-view-mobile-412-answers-on

associate-view-rtl-answers-on

clip-1-associate-pair-long-url

clip-10-ordering-prompt-and-item-formula

clip-11-hint-editor-long-formula

clip-12-mobile-412-hints-and-ordering

clip-13-mobile-412-choice-view-formula

clip-14-mobile-412-associate-edit-formula

clip-15-mobile-412-distractor-chip-formula

clip-16-rtl-associate-pair-formula

clip-17-mobile-412-textentry-prompt-formula

clip-18-mobile-412-choice-option-formula

clip-2-associate-pair-formula-popover

clip-3-associate-pair-url-plus-formula

clip-4-associate-pair-long-formula-clipped

clip-5-associate-pair-formula-closed-card

clip-6-associate-distractor-long-formula

clip-7-associate-distractor-chip-closed-formula

clip-8-associate-view-mode-formula-pool

clip-9-choice-edit-mode-formula-option

clip-long-content-stays-inside-card.webm

delete-01-three-pairs-baseline

@rtibblesbot
rtibblesbot force-pushed the issue-6101-e6feda branch 3 times, most recently from 6e5a1f4 to 2c9fff3 Compare August 31, 2026 16:53
@rtibblesbot
rtibblesbot marked this pull request as ready for review August 31, 2026 16:54

@AlexVelezLl AlexVelezLl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rtibblesbot, from the screenshots, I can see that the padding of the chips is not the same as in the specs, and the colors of the pair rows are not the same as in the specs. Take a closer look at the images in the issue and check that everything is correctly aligned.

@rtibblesbot

Copy link
Copy Markdown
Contributor Author

@AlexVelezLl Fixed, and I swept every element in the editor against the six mockups rather than only the two you named — eight divergences, all changed:

  • Pair rowsgrey.v_100grey.v_50 with a fineLine border; cards now sit on surface instead of inheriting the row tint.
  • Pair cards — 40px tall with an 8px text inset; were 50px at the editor's default 24px inset.
  • Chipspadding: 4px 12px with an 8px gap before the delete control; were 0 4px 0 12px, so the × sat flush against the border. Dropping the editor's paragraph margins takes them from 50px to 34px.
  • Chip fill — a chip inverts against its container: tinted in the distractor pool, white in the shuffled pool. Both were white.
  • Shuffled pool boxgrey.v_100grey.v_50.
  • Correct-answer chip bordergreen.v_500green.v_600, matching the mockup and the choice/ordering editors.
  • Pair validation message — aligned to the row's left edge; it was indented under the cards.
  • CopyMatching pairs, Distractors (optional), Response pool (shuffled), Add distractor and both sublabels, verbatim from the mockups. The branch shipped the plan's provisional wording (Correct pairs, Additional options, Options (shuffled), Add option) — which is also why the labels in the PR body's screenshots don't match what the branch renders. The four below are current.

Two mockup details I left out, both more than styling — say the word and I'll add them:

  • the SAVE button on the new-distractor editor (the sibling editors commit on type and close via the minimize control)
  • the Answer placeholder in an empty pair card (TipTapEditor has no placeholder prop)
State Screenshot
Edit Edit
View, answers shown View
Validation Validation
Small screen Mobile

@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

@AlexVelezLl AlexVelezLl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just few findings from a high-level overview.

},

associateLabel: {
message: 'Connect pairs',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you translate it to "Associate" instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to Associate.

message: 'Connect pairs',
context: 'Display name for an associate question type shown in the question type selector',
},
associateDescription: {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must associate pairs of items, or something similar

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now Learners must associate pairs of items.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add what we added for choice interaction, and let's prevent the removal of the last pair, also let's disable the remove button if its the last pair.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removePair is a no-op at one pair, matching removeChoice, and the delete button is disabled there. Spec gained that case plus a max-associations block mirroring choice's max-choices one. Checked the branch's other list mutators: removeDistractor is the only sibling, and distractors are optional, so it keeps no floor.


it('appends a third pair when the add pair button is clicked', async () => {
renderEditor();
await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addPairBtn') }));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use userEvent.setup() instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whole editor spec is on userEvent.setup() now — 43 interactions, no fireEvent left under interactions/associate/. It was the only spec on the branch using it; the parse, validate and composable specs call the units directly.

Comment on lines +83 to +89
it('appends a second distractor when the add distractor button is clicked', async () => {
renderEditor();
await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addDistractorBtn') }));
expect(
screen.getByRole('button', { name: tr.$tr('deleteDistractorBtn', { number: 2 }) }),
).toBeInTheDocument();
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This behavior is incorrect. When the "Add distractor" button is clicked, it should show a TipTapEditor below the distractors pool, and it should be saved when the Save button is clicked.

Image

If focus is lost and tiptap is closed, let's add a small "new distractor" label so that users can come back and edit it until they hit save. Once they hit save, then we can show the add distractor button again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworked. Add distractor opens a TipTap editor below the pool with a Save button; the draft lives outside state, so nothing joins the pool and nothing is emitted until Save. Closing the editor (by clicking a pair, say) leaves a small New distractor chip that reopens it with the written content still there; Save turns it into a chip and brings the Add distractor button back.

Verified in the running editor, not only in jsdom — worth it, because the first version passed its tests and was dead in the browser: the click that opens the draft finishes bubbling after TipTap has mounted its outside-click listener, so the editor closed itself immediately. Add pair had the same bug (the new pair's editor never stayed open). Both add buttons now stop the click.

expect(contentsOf(parse(ASSOCIATE_XML, [decl]).pairs)).toEqual([['Capulet', 'Montague']]);
});

it('drops a pair naming identifiers that only exist on Object.prototype', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear to me what this tests for, is because these choices are not declared on ASSOCIATE_XML?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — constructor/toString are not declared in ASSOCIATE_XML, and the point is that the pool lookup must not resolve them off Object.prototype (it is a Map, not a plain object). Renamed the test to say that and added the reason as a comment on both prototype cases.

Comment on lines +267 to +296
it('counts a distractor repeat of paired content towards match-max', () => {
const distractors = [{ id: 'choice_zzz00000', content: 'Antonio' }];
const root = parseXmlString(build({ ...baseState, distractors }).bodyXml);
const antonio = choicesOf(root).filter(el => el.textContent === 'Antonio');
expect(antonio).toHaveLength(1);
expect(antonio[0].getAttribute('match-max')).toBe('2');
});

it('reassigns the id of a later choice that reuses an id with different content', () => {
const pairs = [
baseState.pairs[0],
[
{ id: 'choice_aaa11111', content: 'Capulet' },
{ id: 'choice_ddd44444', content: 'Montague' },
],
];
const { bodyXml, responseDeclarations } = build({
...baseState,
pairs,
distractors: [],
});
const [capulet] = choicesOf(parseXmlString(bodyXml)).filter(
el => el.textContent === 'Capulet',
);
expect(capulet.getAttribute('identifier')).toMatch(/^choice_/);
expect(capulet.getAttribute('identifier')).not.toBe('choice_aaa11111');
expect(valuesOf(parseXmlString(responseDeclarations[0]))[1]).toBe(
`${capulet.getAttribute('identifier')} choice_ddd44444`,
);
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you make some permutations of these to test what happens if: the first appearance is repeated, then a following has the same id but different content, etc. Also, lets test that the first appearance keep the id.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an id normalization block with four permutations: a repeat carrying a different id (the first id wins, the second never appears), a later choice reusing an id with different content (the first keeps the id), a repeat followed by an id conflict on the same choice (match-max="2" plus a fresh id for the conflict), and a third choice repeating already-reassigned content. Each asserts the pool and the <qti-value> list.

Comment on lines +128 to +133
describe('TOO_FEW_PAIRS', () => {
it('returns error when there are no pairs at all', () => {
expect(errorCodes(validateAssociateInteraction(makeState({ pairs: [] })))).toContain(
ValidationError.TOO_FEW_PAIRS,
);
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should test also valid pairs, if there is a pair, but its invalid, then show the too few valid pairs error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added: the only pair blank, the only pair holding the same content twice, every pair invalid for a different reason, and one valid pair among invalid ones (no error).

pairedCount.set(id, (pairedCount.get(id) || 0) + 1);
}

const distractors = pool.flatMap(({ id, content, matchMax }) =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use 'lodash/flatMap' instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, and the rest of the branch with it — 6 sites: parse.js (3), validate.js (1), the editor (2). No native flat/flatMap left in the associate plugin.

Comment on lines +298 to +305
function shuffled(items) {
const result = [...items];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use lodash/shuffle instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the hand-rolled Fisher–Yates is gone.

@AlexVelezLl AlexVelezLl self-assigned this Sep 8, 2026
Comment on lines +265 to +272
<KIconButton
icon="close"
size="mini"
:ariaLabel="deleteDistractorBtn$({ number: index + 1 })"
:tooltip="deleteDistractorBtn$({ number: index + 1 })"
:color="$themePalette.grey.v_800"
@click="onRemoveDistractor(index)"
/>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets use size="small" and v_700 for the color

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — size="small", $themePalette.grey.v_700.

Searched the editor for other KIconButtons: one more, the pair delete, which your other comment puts at base/gray/400 — also now size="small".

Comment on lines +290 to +331
<div
v-if="isDraftOpen"
class="draft-editor item-border"
:style="{ borderColor: $themeTokens.fineLine }"
>
<TipTapEditor
:value="draft.content"
mode="edit"
format="html"
:minHeight="'48px'"
autofocus
:imageProcessor="EditorImageProcessor"
:tabindex="-1"
class="editor"
@update="setDraftContent"
@minimize="closeOpenTarget"
/>
</div>
<ClickableRegion
v-else
class="chip draft-chip is-tinted"
:style="{ borderColor: $themeTokens.fineLine }"
:aria-label="editNewDistractorLabel$()"
@click="openDraft"
>
{{ newDistractorLabel$() }}
</ClickableRegion>
<KButton
primary
:text="saveDistractorBtn$()"
@click="saveDraft"
/>
</div>
<div
v-else
@click.stop
>
<AddListItemButton
:label="addDistractorBtn$()"
@click="onAddDistractor"
/>
</div>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make some UX changes here so that it's consistent with other RTEs where we don't need a save button.

  1. Let's remove the save button. When the RTE is blurred or closed, it will automatically be added to the list (if it was new) or updated. If the content is empty, then remove it from the list.
  2. The add distractor will always be present.
  3. With this, no "new distractor" placeholder chip should be present at any point.
  4. The remove button should always be present on the current editable row, no matter if it's a new distractor; this will be the "cancel" button in case the user regrets it after hitting the add distractor button.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworked, all four points:

  1. Save gone. Closing the editor commits: a written draft joins the pool, and an existing distractor emptied then closed leaves it. Verified in the browser that TipTap's blur-time update lands before the click that closed the editor, so the committed content is the typed content.
  2. Add distractor is always rendered.
  3. newDistractorLabel / editNewDistractorLabel / saveDistractorBtn deleted.
  4. The draft row carries the same remove button as a chip, which is also how you abandon it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New distractor, running locally — no Save, Add distractor still there, remove button on the row:

new distractor

/>
</div>

<ValidationMessage

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we set the RTE border to red if it has an error? If it's an "Answers within a pair cannot be the same" error, then both RTEs should have a red border.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. pairItemStyle turns a card's border red for a blank member, for both members when the pair duplicates itself, and for a member a distractor repeats (your validate.js comment).

Swept the other bordered editors in the file for the same gap: distractor chips get the same treatment via distractorStyle; the prompt already had it.

Comment on lines +245 to +272
<ClickableRegion
class="chip-region"
:class="{ 'is-clickable': !isDistractorOpen(index) }"
:suppressed="isDistractorOpen(index)"
:aria-label="editDistractorLabel$({ number: index + 1 })"
@click="openDistractor(index)"
>
<TipTapEditor
:value="choice.content"
:mode="isDistractorOpen(index) ? 'edit' : 'view'"
format="html"
:minHeight="'48px'"
:autofocus="isDistractorOpen(index)"
:imageProcessor="EditorImageProcessor"
:tabindex="-1"
class="editor"
@update="html => setDistractorContent(index, html)"
@minimize="closeOpenTarget"
/>
</ClickableRegion>
<KIconButton
icon="close"
size="mini"
:ariaLabel="deleteDistractorBtn$({ number: index + 1 })"
:tooltip="deleteDistractorBtn$({ number: index + 1 })"
:color="$themePalette.grey.v_800"
@click="onRemoveDistractor(index)"
/>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a small gap between the remove button and the RTE content.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets also remove the li's padding, and the border color if the distractor is in edit mode.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both done. The gap was only missing while editing — the chip class carried gap: 8px, the editing row had no rule of its own. It now shares gap: 8px with the chip, and drops the padding and the border colour while open, since the editor draws its own.

:disabled="state.pairs.length <= 1"
:ariaLabel="deletePairBtn$({ number: index + 1 })"
:tooltip="deletePairBtn$({ number: index + 1 })"
:color="$themePalette.grey.v_800"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use base/gray/400 as per the specs and size="small".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — $themePalette.grey.v_400, size="small".

padding: 4px 12px;
background-color: v-bind('$themeTokens.surface');
border: 1px solid;
border-radius: 4px;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Radius of all chips is 8px

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — 8px on .chip and on .chip-region, the two rules that round a chip. The pool box, pair rows and pair cards are cards rather than chips, so they stay at 4px; say if the specs put those at 8px too.

Comment on lines +597 to +599
const byId = new Map(
[...flatten(state.value.pairs), ...state.value.distractors].map(c => [c.id, c]),
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There may be repeated IDs that are still valid, right? This Map will squash them. However, if the same response appears in two different pairs, they will see the response twice in the chips list.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, the Map was wrong. It now folds by content instead of by id, which is what buildAssociateInteractionXML does — same-content choices become one qti-simple-associable-choice with a match-max. So the preview shows exactly the options delivery renders: a response used in two pairs is one chip that can be used twice, not two chips.

If you meant it should show twice, say so and I will key it by position instead — the change is one line.

>
<li
v-for="choice in shuffledPool"
:key="choice.id"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We cannot use choice.id if id may be repeated across several choices.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — keyed by position.

Swept the branch for the same assumption. Three :key on choices: the pair cards and the distractor chips already carried -${position} / -${index}, so only the pool needed changing. Three id-keyed lookups had the defect and are now matched on content: the blank-choice error set, the paired-ids set behind the green border in showAnswers, and the pool dedup above.

}

function openDraft() {
if (props.mode === 'edit') openTarget.value = { kind: 'draft' };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we have a small constant for these kinds?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — module-level frozen OpenTarget with PROMPT / PAIR / DISTRACTOR / DRAFT, used by every read and write of openTarget.kind. No other bare-string discriminators in the branch.

} else if (state.value.pairs.length > 0) {
openPairItem(0, 0);
}
emit('update:interaction', workingInteraction.value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? This is different from what we have on other interaction editors, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change — this is the ordering editor's pattern, OrderingInteractionEditor.vue:309 and :318-321, line for line. TextEntryEditor.vue:345 gates the same way.

The gate is deliberate: a mode="view" preview must not write back to the parent, so the entry emit moved into the mode watcher and the ongoing one is gated. ChoiceInteractionEditor is the odd one out — it emits in view mode too. Covered by the does not emit update:interaction in view mode test.

Happy to align all four either way if you want one rule.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's always emit only in edit mode, and only if the content changed, not just because the mode changed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The mode watcher no longer emits; workingInteraction is now compared with isEqual, so an emit needs a real change to bodyXml or responseDeclarations — reopening an editor or retyping the same text stays silent.

Tests follow: mount in edit mode asserts no emit, plus a case for moving the open editor between pair items.

Searched every update:interaction emit under QTIEditor/ — four editors, three others match the pattern: ChoiceInteractionEditor.vue:382 (immediate, no mode gate), OrderingInteractionEditor.vue:309 (mode watcher), TextEntryEditor.vue:349 (immediate). All three are outside this diff, so I filed #6136 rather than widening the PR.

class="editor"
/>
</div>
</template>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When an item within a pair has duplicated content with distractors, the error message does not appear in the pair row.

Image

We should have another message below the other pairErrorMessage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the pair row now stacks every applicable message instead of showing the first match.

Audited the class rather than the line: all five codes the associate validator emits, against every control the editor reddens (prompt wrapper, pair card, distractor chip).

  • One gap: pairItemStyle reddened a pair card for DUPLICATE_DISTRACTOR_CONTENT, but pairErrorMessage never named it — and it returned one message where two can apply.
  • No gap on distractors: a blank distractor has no text to duplicate, so EMPTY_CHOICE_CONTENT and DUPLICATE_DISTRACTOR_CONTENT are mutually exclusive there.

pairErrorMessage(index) is now a pairErrorMessages computed returning an array; the row renders one ValidationMessage per entry. Two new tests: the message reaches the pair row the distractor repeats, and both messages stack on a pair that repeats itself and a distractor.

pair row with both messages

@rtibblesbot
rtibblesbot force-pushed the issue-6101-e6feda branch 2 times, most recently from f0e8e7f to 72ed32a Compare September 15, 2026 22:07
Comment on lines +615 to +617
::v-deep .editor-content {
overflow: hidden;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a default style within the TipTapEditor instead? Does anyone benefit from having it unbounded?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — moved to EditorContentWrapper's own .editor-content rule (770fabe). Toolbars and popovers are siblings of that element, so clipping there leaves them whole.

Searched the branch for the same class — every consumer-side copy of a style that belongs to TipTapEditor. grep -rn "editor-content" frontend/ gave 7 hits across 4 files: 3 were this identical ::v-deep .editor-content { overflow: hidden } override (ordering, text entry, associate), all now deleted. The other 2 associate hits set per-chip padding, which is genuinely that card's layout, so they stay.

Kept min-width: 0 on .editor in those three: that one is about the editor being a flex item in their row, not about the editor itself.

Choice was never in the set — it bounds at .choice-content, a level above, which also clips the non-editor parts of the card. Left alone.

The five legacy exercise editors (AnswersEditor, HintsEditor, AssessmentItemEditor, AssessmentItemPreview, ResourceDrawer) pick up the bound for free; none of them reaches into .editor-content, so nothing there relied on the overflow.

Comment on lines +61 to +76
<!-- Keyed by position: a choice id may repeat across the pool. -->
<li
v-for="(choice, index) in shuffledPool"
:key="index"
class="chip"
:style="poolChipStyle(choice)"
>
<TipTapEditor
:value="choice.content"
mode="view"
format="html"
:imageProcessor="EditorImageProcessor"
:tabindex="-1"
class="editor"
/>
</li>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still getting these de-duplicated. For example, I have these pairs. Test 2 and Test 1 should appear two times in the pool, but they only appear once.

Image Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the pool now renders one chip per occurrence it walks, so a choice used in two pairs shows twice, and so does a distractor carrying match-max="2". It was keying the pool into a Map by content first. The count of chips now equals the sum of the emitted match-max values.

Searched the branch for the same content-keyed collapse: three other sites key on choiceTextpairedTexts (green highlight in showAnswers), the duplicate-distractor error set, and the blank check. All three are membership tests that never decide what is rendered, so none changed. buildXML still folds equal content into one choice, which is exactly what the repeated chips represent.

Covered by a view-mode test on a fixture whose two pairs share one choice.

</div>

<ol
class="pairs-list"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some screen sizes show the TipTapEditor toolbar overflowing the container. Could you show the stacked view for screen sizes !windowIsLarge only when the editor of that row is opened? Let's first test how that feels, if it feels bad, we can have all of these stacked for !windowIsLarge.

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — below windowIsLarge a pair row stacks only while its own editor is open; the rest of the rows stay side by side, and nothing stacks on a large screen. The row was previously stacked on windowIsSmall regardless of what was open, which is what left the toolbar overflowing between the two breakpoints.

Searched the other responsive layout switches in the QTI editor: four files (QuestionTypeSelector, TextEntryEditor, ChoiceInteractionEditor, QTIEditor/index.vue) branch on windowIsSmall. None of them puts two editors side by side in one row, so none has this failure mode and none changed.

Two tests cover it: stacked row follows the open editor, and no row stacks when large.

:class="{ 'chip is-tinted': !isDistractorOpen(index) }"
:style="distractorStyle(index, choice)"
>
<ClickableRegion

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we not set the border radius to the TipTapEditor? It makes the focus outline look too round.

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed — the chip's 8px radius now stops at the chip. The region wrapping the editor is square, so the focus ring it draws is square too.

Checked all eight ClickableRegions in the QTI interaction editors. The other seven wrap a card whose own visible border carries the 4px radius, so their focus ring traces a border that is actually there. This one was the only region whose radius existed solely to round the ring.

// Two cards side by side leave an open editor too narrow to hold its
// toolbar on anything but a large screen, so the row being edited stacks.
function isPairRowStacked(index) {
return !windowIsLarge.value && isPairRowOpen(index);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If windowIsSmall these should always be stacked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a9317b0 (pushed before this round): isPairRowStacked is now windowIsSmall || (!windowIsLarge && isPairRowOpen(index)), and the spec asserts every row stacks on a small screen, open or not.

Swept the rest of the file for the same class — windowIsSmall / windowIsLarge have exactly one consumer here, that function. The two pools wrap rather than stack, so nothing else took a breakpoint guard.

Comment on lines +328 to +333
<div @click.stop>
<AddListItemButton
:label="addDistractorBtn$()"
@click="onAddDistractor"
/>
</div>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a small bug on here, if we added a previous distractor (i.e. we had the draft opened), then if we hit the add distractor button again, it does not focus the draft RTE again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5eaac1f. The draft editor is keyed by a counter the add button bumps, so each press mounts a new one: an editor only takes focus as it mounts, and Vue was patching the one already on screen instead. Covered by two tests (draft with content, empty draft) and checked in the browser — the written draft still commits to the pool and the caret lands in the new editor.

The mock now focuses on mount and on the viewedit switch, like the real component; without that no test could see this.

Searched for the same class across the QTI editors: the draft is the only TipTapEditor held permanently at mode="edit" (1 of 9 autofocus bindings). The other eight switch modes, where TipTapEditor's own mode watcher refocuses, so none needed the key.

:class="{ 'chip is-tinted': !isDistractorOpen(index) }"
:style="distractorStyle(index, choice)"
>
<ClickableRegion

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make the Clickable region cover the entire chip container instead? So that the entire chip is clickable, and not only the text content. (In that case, we should bring back the border-radius to the clickable container). On top of that, could you also add a hover background change similar to how other TipTapEditor clickable regions do?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in bdc67d8. ClickableRegion is now the chip itself: the remove button sits inside it behind @click.stop, the 8px radius and focus ring are back on the chip, and hover paints fineLine across the whole of it like the other regions.

Verified in the browser — a click on the chip padding opens the editor, the remove button still only removes, and the focus ring wraps the chip rather than the text.

Checked every other ClickableRegion on the branch (8 across the four interaction editors): this was the only one covering a subset of its card. The rest already wrap the whole card with their controls inside, so none changed.

@AlexVelezLl AlexVelezLl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We just merged #6095. Could you rebase on top of unstable? Also we did several architectural changes there, could you please adapt these changes to the new rules?

@rtibblesbot

Copy link
Copy Markdown
Contributor Author

Rebased onto unstable (e0bee94) and moved the associate plugin onto #6095's rules.

Read the #6095 diff for every rule it introduced and checked associate against each. Ten touch this plugin; all ten are applied:

  • AssociateInteractionDescriptor.js / AssociateInteractionEditor.vueDescriptor.js / Editor.vue; associate/index.js deleted
  • descriptor extends InteractionDescriptor via super({ type, questionTypes }); its matches() override dropped, the base default being the same test
  • registered in descriptors.js and in the editors map, so registry.spec.js's half-registration check passes
  • validate.jsvalidation.js
  • stripTagshasRichTextContent / richTextComparisonKey at all 13 call sites: 5 in validation.js, the buildXML dedup key in parse.js, 7 in Editor.vue. A pair item that is only an image or a formula now counts as content.
  • parseXML / getPromptHTML imported from serialization/xml
  • placement through Placement.BLOCK
  • the editor emits update:errors, so an incomplete associate question marks its card
  • validation debounce gone: Editor.spec.js drops the fake timers and asserts on the next tick
  • section labels are <h4 class="field-label">, as the other three editors' headings became

validateItem.js, resolveDescriptor and QuestionTypeSelector all read the registry, so associate reaches them without a change of its own.

Also folded this branch's review-round commits into the commits they patched: five commits now, so the push force-updates.

Editor on the rebased branch:

Associate editor after the rebase

The card's "Incomplete" flag is what update:errors buys — here a pair item cleared to blank:

Blank pair item flags the card incomplete

@rtibblesbot
rtibblesbot requested a review from a team September 21, 2026 20:32
@rtibblesbot

Copy link
Copy Markdown
Contributor Author

Rebased history force-pushed — the conflict with unstable is cleared and the PR now carries the six commits described above.

One fix on top of that: deleting a pair or a distractor left focus on <body>, because the press unmounts the very button it landed on. Focus now moves to the row that takes the removed one's place, or to the add button when no row does.

Grepped every call site of removePair / removeDistractor / discardDraft — six. Three are button presses that unmount their own button (onRemovePair, onRemoveDistractor, onDiscardDraft); all three changed, five tests added. The other three are the empty-on-close cleanup and leaving edit mode, where focus is already moving by the author's own action. Folded into "Add associate interaction editor and register the plugin".

Keyboard-only, after Enter on Delete pair 3:

Focus ring on Pair 2 delete button after Pair 3 was deleted

Whole run — delete the last pair, delete down to the one remaining pair, empty the distractor pool, cancel a draft:

assoc-focus.webm

axe-core on this state: 0 violations.

Splits the single flat pool of <qti-simple-associable-choice> elements into
authoring state: `pairs` from the correct response, `distractors` from the
match-max capacity the correct response does not consume. buildXML re-merges
them, normalizing ids so equal content shares one pool entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rtibblesbot

Copy link
Copy Markdown
Contributor Author

One section label was still a <div> — the associate editor's response pool. It is now <h4>, like the other three.

That corrects my earlier comment, which said the section labels had become <h4>. This one had not.

Swept all four interaction editors for label markup: 10 elements carrying .field-label / .section-label across choice, ordering, textEntry, associate. Nine were already <h4>; the one above was not. The two .section-sublabels stay <div> — descriptive text under a heading, not headings.

No visual change: .section-label sets margin: 0; font-size: 12px; font-weight: 600, so the UA heading defaults never apply. Computed on the live page: tag=H4 margin=0px fontSize=12px fontWeight=600. axe-core AA on the demo page: 0 violations.

Response pool label rendering identically to the Question label above it

Also in this push, from a self-review pass over the branch:

  • a blank pair member no longer keys into the correct-answer set, so an unwritten pool chip is never drawn green
  • isBlank drops a redundant gate on the EMPTY_CHOICE_CONTENT flag — validateAssociateInteraction pushes that error for every unfilled choice, so the gate could not change the answer

Jest over QTIEditor, TipTapEditor and channelEdit: 1690 passed.

Associate editor in edit mode: Matching pairs and Distractors labels

@rtibblesbot

Copy link
Copy Markdown
Contributor Author

One more case in the focus-after-removal class: deleting a distractor that is open and left blank dropped focus to <body>.

Closing the editor is what commits a distractor, and a blank one is dropped there — so the indexOf after closeOpenTarget() returned -1 and the early return skipped the focus move along with the removal. The index is now resolved before the close, the row removed only if it survived it, and focus moved either way.

Swept the branch for the same shape, an index read across closeOpenTarget(). Two sites resolve a distractor index: openDistractor already re-resolved after the close, onRemoveDistractor did not. onRemovePair holds a pair index, which closeOpenTarget() cannot shift. One site changed, one test added.

Keyboard-only on the demo page — the last distractor, opened and cleared:

Open distractor editor with empty content and its delete button

Enter on that delete button: pool empty, focus ring on Add distractor.

Focus ring on the Add distractor button, distractor pool empty

Both deletions in one run:

blank-distractor-focus.webm

axe-core AA on this state: 0 violations. Jest over QTIEditor and TipTapEditor: 1029 passed.

rtibblesbot and others added 5 commits September 21, 2026 14:26
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Toolbar buttons carry `@mousedown.prevent` so a formatting press keeps the
caret, which means the blur that syncs content to the parent never happens
when the author finishes by pressing minimize. A parent that acts on the
close then reads the content as it stood before the last edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Registering the descriptor does not populate QUESTION_TYPE_LABELS, so
QTIItemEditor gets an explicit ASSOCIATE entry — without it every associate
item's view-mode header reads "Unknown type".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A formula or a long URL is one unbreakable inline node, so it paints over
whatever bounds the editor. Clipping on the editor's own content wrapper bounds
every embed, not just the three that hit it.

`min-width: 0` on the editor root is the other half: a flex row will not shrink
it below that content without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@AlexVelezLl AlexVelezLl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Code changes look good, new intearction feels visually polished, good test coverage. I just pushed a couple of small style fixes.

@AlexVelezLl
AlexVelezLl merged commit 1cbdce8 into learningequality:unstable Sep 23, 2026
27 checks passed
@rtibblesbot
rtibblesbot deleted the issue-6101-e6feda branch September 23, 2026 22:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[QTI] Implement Associate Interaction editor

2 participants