Skip to content

feat: add AnswerSettings and QuestionSettingsHeader components - #6059

Open
Abhishek-Punhani wants to merge 3 commits into
learningequality:unstablefrom
Abhishek-Punhani:question-selector
Open

feat: add AnswerSettings and QuestionSettingsHeader components #6059
Abhishek-Punhani wants to merge 3 commits into
learningequality:unstablefrom
Abhishek-Punhani:question-selector

Conversation

@Abhishek-Punhani

Copy link
Copy Markdown
Member

Summary

Added QuestionSettingsHeader and AnswerSettings components to the QTI editor using portals.

This PR adds:

  • QuestionSettingsHeader/index.vue — UI component for selecting interaction types.
  • AnswerSettings/index.vue — UI component for interaction-specific configurations.

References

Closes #6033

Reviewer guidance

  1. Navigate to the QTI demo page.
  2. Verify the Question Type selector is rendered correctly.
  3. Verify the Answer Settings panel is displayed and updates as expected for different interaction types.

AI usage

Used Antigravity for a final review and minor code/style nitpicks. I reviewed all suggested changes, kept only the relevant improvements, and verified that the implementation worked as intended after applying them.

@learning-equality-bot

Copy link
Copy Markdown

👋 Hi @Abhishek-Punhani, thanks for contributing!

For the review process to begin, please verify that the following is satisfied:

  • Contribution is aligned with our contributing guidelines

  • Pull request description has correctly filled AI usage section & follows our AI guidance:

    AI guidance

    State explicitly whether you didn't use or used AI & how.

    If you used it, ensure that the PR is aligned with Using AI as well as our DEEP framework. DEEP asks you:

    • Disclose — Be open about when you've used AI for support.
    • Engage critically — Question what is generated. Review code for correctness and unnecessary complexity.
    • Edit — Review and refine AI output. Remove unnecessary code and verify it still works after your edits.
    • Process sharing — Explain how you used the AI so others can learn.

    Examples of good disclosures:

    "I used Claude Code to implement the component, prompting it to follow the pattern in ComponentX. I reviewed the generated code, removed unnecessary error handling, and verified the tests pass."

    "I brainstormed the approach with Gemini, then had it write failing tests for the feature. After reviewing the tests, I used Claude Code to generate the implementation. I refactored the output to reduce verbosity and ran the full test suite."

Also check that issue requirements are satisfied & you ran pre-commit locally.

Pull requests that don't follow the guidelines will be closed.

Reviewer assignment can take up to 2 weeks.

@Abhishek-Punhani

Copy link
Copy Markdown
Member Author

@AlexVelezLl, Vue 2.7 doesn't support the built-in Teleport component, so I've added a custom component to mirror its behaviour. Let me know if you have a better implementation in mind!

@AlexVelezLl

Copy link
Copy Markdown
Member

Oh, apologies @Abhishek-Punhani 😅, I just knew we had already used it in our ecosystem and didn't recall it was a dependency package. In KDS, we use vue2-teleport. Could you install that same version on Studio, please? I read it has some memory optimizations that'd be good to have!

@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.

Thanks @Abhishek-Punhani! I think there is a better way to do this to not remove the type selector from the DOM when we change the question type. Please let us know if there is any questions!

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.

Oh, given that this will only be used in the editor, could we move this component to the choice folder instead?

Comment on lines +125 to +129
settings: {
type: Array,
required: true,
validator: arr => arr.every(setting => ['shuffle', 'showAnswerCount'].includes(setting)),
},

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.

Given that this will only be rendered for choice interactions, I think it's fine to let it infer when to display each based on the questionType instead of this settings prop.

:title="showAnswerCountInfoTitle$()"
@cancel="showAnswerCountModal = false"
>
<p :style="{ color: $themeTokens.annotation }">

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 think we can leave the normal text color here, instead of this annotation.

Comment on lines +75 to +80
<template #actions>
<KButton
:text="closeBtnLabel$()"
@click="showAnswerCountModal = false"
/>
</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.

We can also just use the cancelText prop instead of this actions slot. Usually the actions slot is used for more complex button layouts.

:title="shuffleAnswersInfoTitle$()"
@cancel="showShuffleModal = false"
>
<p :style="{ color: $themeTokens.annotation }">

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.

Idem

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.

Actually, the most important responsibility of this component is the type selector, so could we reference "type selector" in the name instead of "SettingsHeader"? (Similarly for class names like question-settings-header, etc)

<template>

<div
v-if="mode === 'edit'"

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 sometimes have issues because of rendering conditions on the root element of a component, and a lint rule will soon be added to avoid this, so could we move forward with this condition and leave this responsibility to the parent component instead? i.e. let the parent do <QuestionTypeSelector v-if="mode==='edit'" instead

Comment on lines +27 to +34
<div class="select-display-row">
<KIcon
icon="language"
class="select-globe-icon"
:style="{ color: $themePalette.grey.v_700 }"
/>
<span class="select-value-text">{{ selectedOption.label }}</span>
</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.

Nice!

@update:showAnswerCount="setShowAnswerCount"
/>
</template>
</QuestionSettingsHeader>

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.

Oh, some comments: the interaction editors should not be the ones responsible for rendering this "settings header," which is actually a type selector, mainly because this will cause the DOM to remove these nodes when the component is unmounted. The type selector (and therefore, the whole header row) should be shared across all interaction editors and rendered independently of them, so that if we change the question type, the type selector component is not removed from the DOM (which would cause some accessibility issues).

So, what we can do instead is:

  • Have the InteractionSection component be the one that renders the QuestionTypeSelector component. This way, each interaction has its own type selector independent of the editor being rendered.
  • Let the QuestionTypeSelector be the one responsible for rendering the div with the proper ID so that editor components can target it (instead of using a slot).
  • We can take advantage of the fact that we can only have one item being edited at a time, and because of this, there will always only be one question type selector rendered at a time, and use a constant as id so that we don't have to keep track of any identifiers yet.

With this, the idea would be:

// QuestionTypeSelector
<div class="type-selector>
  ...
  <div id="qti-interaction-settings" />
...
// InteractionSection
<QuestionTypeSelector ... />
<component :is="descriptor.editorComponent" ... />
// ChoiceInteractionEditor
<div class="choice-editor">
  <Teleport to="qti-interaction-settings">
    <AnswerSettings ... />
  </Teleport>
  ....
</div>

This way only the interaction editors that actually wants to add settings are the only ones that needs to teleport anything.

Comment on lines +284 to +295
const questionTypeOptions = computed(() => [
{
value: QuestionType.SINGLE_SELECT,
label: singleSelectLabel$(),
description: singleChoiceDescription$(),
},
{
value: QuestionType.MULTI_SELECT,
label: multiSelectLabel$(),
description: multipleSelectionDescription$(),
},
]);

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.

Another little problem with this is that the questionType selector should render all question types for all interactions, not only the ones they are related to.

…iceInteractionEditor

Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
…eaders

Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
…tegrate question type selection into QTI editor sections

Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
@learning-equality-bot

Copy link
Copy Markdown

📢✨ Before we assign a reviewer, we'll turn on @rtibblesbot to pre-review. Its comments are generated by an LLM, and should be evaluated accordingly.

@rtibblesbot

rtibblesbot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

🔵 Review posted

Last updated: 2026-08-01 14:37 UTC

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PR #6059 — the Answer settings half of #6033 is clean: Composition API throughout, theme tokens only, useKResponsiveWindow instead of media queries, every string through qtiEditorStrings. The type-selector half has three correctness problems.

CI passing. Manual QA was required for this PR but did not run (dev server failed to start), so nothing here asserts visual correctness — the rendered layout, the globe adornment, and small-screen behaviour are unverified.

blocking

  • Type dropdown is populated from the whole interaction registry, so a choice question can be switched to a text-entry type and its XML is silently overwritten (useInteractionDescriptor.js:70).
  • KRadioButtonGroup wraps a list that renders checkboxes for multiple choice — verified TypeError in Firefox (ChoiceInteractionEditor.vue:80).
  • max-choices/min-choices now derive from the correct-answer count for single choice too, against the issue's explicit max-choices is always 1 (useChoiceInteraction.js:39).

suggestion / nitpick — see inline: Teleport unregistered in Jest so the new Answer-settings tests never exercise the teleport, dead teleport-target defaults, duplicated buildXML pipeline, showAnswerCount stored inside parsed state, untested update:questionType and cardinality acceptance criteria, missing group names on the two new control clusters.

Two smaller points that don't map to changed lines: the deleted comment <!-- Per-choice validation messages sit INSIDE the bordered card --> (ChoiceInteractionEditor.vue, ~line 165) describes code that is still there — AGENTS.md asks for existing comments to be preserved. And each question now installs two document-body MutationObservers via vue2-teleport; on a long assessment with TipTap running, worth watching during QA.


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

How was this generated?

Ran a phased review pipeline over the pull request diff:

  • Classified the diff to select review passes (core, frontend, backend) and whether manual QA was required
  • Core review pass checked correctness, design, architecture, testing, completeness, and DRY/SRP/Rule-of-Three principles
  • Specialized frontend/backend review passes applied framework-specific lenses where those files changed
  • For UI changes: manual QA and an accessibility audit against a live dev server, when available
  • Checked CI status and linked issue acceptance criteria
  • Synthesized one review from those passes and chose the verdict from the findings, CI status, and QA evidence

);

return { descriptor, questionType, parseError };
const typeOptions = computed(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

blocking: descriptors is the full registry (interactions/index.js:15), so a choice question's KSelect offers all five types, including the text-entry ones. Selecting one is a data-loss path: descriptor recomputes to the text-entry descriptor (line 64), <component :is> mounts TextEntryEditor with :interaction still holding the choice block, and its { immediate: true } watcher on workingInteraction (TextEntryEditor.vue:340) emits update:interaction on mount — which QTIItemEditor.onUpdateInteraction writes straight into currentBodyXml. Both descriptors declare convertsFrom = [], so there is no conversion step; the author's choice question is just replaced.

This is also outside #6033's scope ("The modal lists only the types that the current interaction plugin supports") and fails the criterion "Selector shows 'Single choice' and 'Multiple choice' for choice interactions".

Source the options from the resolved descriptor instead:

const typeOptions = computed(() => descriptor.value.getTypeOptions?.(qtiEditorStrings) ?? []);

That also makes :disabled="questionTypeOptions.length <= 1" meaningful, and lets TextEntryInteractionDescriptor.getTypeOptions stay for when text-entry switching is actually built. Note selectedOption in QuestionTypeSelector would then need a guard — questionTypeOptions[0] is undefined for a descriptor without getTypeOptions, and the template dereferences selectedOption.label.

</div>

<div class="choices-list">
<KRadioButtonGroup class="choices-list">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

blocking: the list only contains KRadioButtons when isSingleSelect is true — multi-select renders KCheckbox (lines ~120-140). KDS KRadioButtonGroup.vue mounted() (verified in node_modules, lines 41-56) does:

this.lastRadioIdx = this.radioButtons.length - 1;
const firstRadioButton = this.radioButtons[this.focusedRadioIdx];  // undefined
firstRadioButton.setTabIndex(0);                                   // TypeError

with no empty guard. The whole block is behind if (!this.isFirefox) return;, so this won't show up in Jest or Chrome QA, but it breaks the editor for every multiple-choice question in Firefox. Same crash for a single-choice question where every choice has a validation error, since the error KIcon replaces the radio button.

Separately, KRadioButtonGroup hard-codes role="radiogroup", so wrapping checkboxes misreports the control type and applies roving arrow-key navigation where Tab-per-checkbox is correct.

channelEdit/components/AnswersEditor/AnswersEditor.vue:11 already has the pattern:

<component :is="shouldHaveOneCorrectAnswer ? 'KRadioButtonGroup' : 'div'">

This change isn't called for by #6033 at all, so reverting it is equally valid. If the wrapper stays for single-select, give it aria-labelledby pointing at the "Answers" heading.

return state.value.choices.filter(c => c.correct).length;
});

const stateForXml = computed(() => ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

blocking: there is no questionType branch here, so the multi-select rule is applied to single choice as well, and buildChoiceInteractionXML writes state.maxChoices verbatim (interactions/choice/parse.js:126). Three concrete regressions:

  • Single choice with no correct answer marked yet → max-choices="0" (unlimited) instead of 1.
  • Switching multiple → single with 3 answers marked correct → max-choices="3" alongside cardinality="single"; toggleCorrectChoice doesn't prune on the type switch.
  • Every single-choice question with a correct answer now also emits min-choices="1" (previously dropped, since parsed minChoices was 0), silently making it mandatory to answer.

#6033 is explicit: "For single choice this checkbox is hidden — max-choices is always 1." Gating stateForXml on questionType.value === QuestionType.SINGLE_SELECT, and leaving minChoices at the parsed value in that case, covers all three.

Vue.component('BaseMenu', BaseMenu);
Vue.component('Divider', Divider);
Vue.component('Icon', Icon);
Vue.component('Teleport', Teleport);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: this global registration is the only one — jest_config/setup.js registers only ActionLink, so in tests <Teleport> renders as an unknown element with its children in place, and Vue.config.silent = true suppresses the warning. That means the whole describe('Answer settings') block in ChoiceInteractionEditor.spec.js passes because the label rendered inline, not at the teleport target; target resolution, the settingsTargetId string surgery, and the ordering of the two teleported blocks are all untested.

It also makes these components silently depend on shared/app.js having run, which is a bad fit for code living in shared/views/. Importing Teleport from 'vue2-teleport' locally in InteractionSection and ChoiceInteractionEditor (and dropping the global registration) fixes both, and avoids squatting on a Vue 3 built-in name. Worth one integration assertion in QTIItemEditor.spec.js that the settings land inside #qti-question-settings-0.

},
teleportTarget: {
type: String,
default: '#qti-question-settings',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: #qti-question-settings exists nowhere in the app — only the -${index} variant does. Same for InteractionSection's 'qti-interaction-settings' fallback (index.vue:75). They also interact badly: if InteractionSection renders without teleportTarget, v-if="teleportTarget" skips QuestionTypeSelector, so the #qti-interaction-settings div is never created — yet line 36 still passes that truthy string down, so AnswerSettings teleports into a querySelector that resolves to null and the settings silently vanish.

Make both ids required: true with no default, and pass one id consistently instead of stripping and re-adding the # across three components. Better still: if the descriptor exposed a settingsComponent alongside editorComponent, InteractionSection could render it into a QuestionTypeSelector slot directly — no ids, no teleport, no test-environment divergence.

expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

it('disables selector when only one option available', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: neither assertion touches :disabled — the hidden input exists with two options too, and the "Type" label is static. This test passes with :disabled deleted entirely. Assert on the control: expect(screen.getByRole('combobox')).toBeDisabled().

More importantly, the component's one behaviour — @update:questionType (index.vue:23) — has no test, and neither do the two acceptance criteria with real logic behind them: "changing the type updates questionType and re-renders ChoiceInteractionEditor", and "cardinality in the response declaration XML updates to match the new type". The second is the one that has to survive any refactor of the buildXML plumbing; a useChoiceInteraction spec that flips questionTypeRef and parses responseDeclarations.value[0] covers it cheaply.

<template>

<div class="answer-settings">
<div

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: this group heading (and "Type" in QuestionTypeSelector/index.vue:9) is an unassociated <div>, so to a screen reader it's a loose text node — the checkbox set has no group name and "Type" isn't connected to the control it labels. role="group" + aria-labelledby on the label element keeps the current visuals and satisfies WCAG 2.2 AA:

<div role="group" :aria-labelledby="labelId" class="answer-settings">
  <div :id="labelId" class="answer-settings-label">{{ answerSettingsLabel$() }}</div>

That also gives the tests a semantic handle for within(), replacing the document.querySelector('.choices-list') and .select-display-row class queries in the new specs — those assert styling hooks rather than behaviour and break on a rename. The if (!list) return [] guard in ChoiceInteractionEditor.spec.js:93 is worse than a failure: a markup change turns those assertions vacuous instead of red.

:questionType="questionType"
:questionTypeOptions="typeOptions"
:settingsTargetId="settingsTargetId"
@update:questionType="

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nitpick: a two-statement handler that writes to a setup-owned ref from a template expression. It works in Vue 2.7 (proxyWithRefUnwrap forwards the set), but it hides the mutation from the script block — and the watch(questionType, ..., { immediate: true }) at line ~55 already emits update:questionType, so each selection emits twice. A named onQuestionTypeChange(newType) in setup that only assigns the ref, letting the watcher emit, is clearer and emits once.

Related: currentQuestionType is stored in QTIItemEditor but never fed back as a prop, so this ref is the real source of truth. Fine functionally — worth a comment so the duplicated state isn't read as a wiring bug later.

</div>
</div>

<div :id="`qti-question-settings-${index}`"></div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nitpick: unique across questions in one QTIEditor, but vue2-teleport resolves targets with document.querySelector, which takes the first document-wide match. Two QTIEditor instances on a page would teleport both selectors into the first card. Suffixing with _uid makes it collision-proof for one interpolation.

<div
class="question-type-selector"
:class="{ 'small-screen': windowIsSmall }"
:style="{ borderBottom: `1px solid ${$themeTokens.fineLine}` }"

@rtibblesbot rtibblesbot Aug 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved — addressed in the current code.

praise: the conventions that usually slip are all clean here — no @media (useKResponsiveWindow drives .small-screen), no hard-coded colours, and the only inline bindings are color and borderBottom, both direction-agnostic so RTLCSS has nothing to flip.

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PR #6059 — 12 findings open, 1 new. CI passing; QA did not run.

Prior-finding status

RESOLVED AnswerSettings/
RESOLVED QuestionTypeSelector/index.vue:1
RESOLVED InteractionSection/index.vue:15
RESOLVED package.json:1
RESOLVED InteractionSection/index.vue:2
RESOLVED AnswerSettings/index.vue:29
RESOLVED AnswerSettings/index.vue:51
UNADDRESSED useInteractionDescriptor.js:70
UNADDRESSED ChoiceInteractionEditor.vue:80
UNADDRESSED useChoiceInteraction.js:39
UNADDRESSED app.js:263
UNADDRESSED ChoiceInteractionEditor.vue:547
UNADDRESSED useChoiceInteraction.js:45
UNADDRESSED useChoiceInteraction.js:21
UNADDRESSED QuestionTypeSelector.spec.js:77
UNADDRESSED AnswerSettings/index.vue:4
UNADDRESSED InteractionSection/index.vue:20
UNADDRESSED QTIItemEditor/index.vue:29
UNADDRESSED ChoiceInteractionEditor.vue:168
ACKNOWLEDGED QuestionTypeSelector/index.vue:6


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

How was this generated?

Compared the current PR state against findings from a prior review:

  • Retrieved prior bot reviews via the GitHub API
  • Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
  • Only raised NEW findings for newly introduced code
  • Ran the same phased review passes as a first review (core, frontend/backend lenses, manual QA when required)
  • Synthesized one review from the passes and chose the verdict from the findings, CI status, and QA evidence

<KIconButton
icon="infoOutline"
:tooltip="shuffleAnswersInfoTitle$()"
:ariaLabel="shuffleAnswersInfoTitle$()"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: ariaLabel duplicates the checkbox label.

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] Question Type Selector and Answer Settings

3 participants