Skip to content

[QTI] Question Type Selector and Answer Settings #6033

Description

@Abhishek-Punhani

This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.

Overview

Right now, the QTI editor has no way to switch question types (single choice / multiple choice) and no "Answer settings" controls. This issue adds both.

What we're building:

  1. Question type selector — a dropdown that lets the author pick the question type (Single choice / Multiple choice). Selecting a type rebuilds the interaction XML with the correct QTI cardinality.
  2. Answer settings section — two checkboxes that sit next to the type selector in the card header row:
    • Shuffle answers for learners — already in state (shuffle), just needs a UI control.
    • Show learners how many answers to select — when checked, max-choices and min-choices are set to the number of correct answers; when unchecked, both are left at 0 (unlimited).

Both controls are owned by the individual interaction plugin (e.g. ChoiceInteractionEditor.vue) but need to appear in the card header area, which is rendered by QTIItemEditor. A Vue portal (teleport) is how we bridge that gap — the interaction component writes the controls into a named slot/target that the parent renders in the right place.
Complexity: Medium
Target branch: unstable


Context

The current component tree is:

QTIEditor (index.vue)
  └── QTIItemEditor
        ├── header row  ← where Type selector + Answer settings should eventually live
        └── InteractionSection
              └── ChoiceInteractionEditor  ← interaction plugin lives here

QTIItemEditor renders the header (Question 1 of N) and delegates everything inside the card body to InteractionSection, which looks up the right editor component from the plugin registry and renders it.

The type selector and answer settings are interaction-specific — they only make sense for a choice interaction, not for a text-entry or matching interaction. So the code that drives them belongs inside ChoiceInteractionEditor, not in QTIItemEditor. A portal is the right mechanism to hoist that UI into the header row.

"Show learners how many answers to select" logic

  • Checked (default): max-choices = number of correct answers, min-choices = same. This tells learners exactly how many to pick.
  • Unchecked: max-choices = 0, min-choices = 0. Learners see no count hint.
  • When the author changes which answers are correct, max-choices / min-choices must update automatically (if the checkbox is checked).
  • For single choice this checkbox is hidden — max-choices is always 1.

Identifier and title generation (related note)

assembleItem.js currently uses identifier || 'item' and title || '' as fallbacks. These will be properly generated in useQtiItem once the "add question" button is wired up with a type selector. There is already a TODO comment in the file marking this.


What changes

1. constants.js

Add a new AnswerSettings object (or extend ChoiceState) if needed. No new constants are strictly required — the feature is driven by a local ref in the composable.

2. useChoiceInteraction.js

Add a showAnswerCount ref (boolean, default true) and a setShowAnswerCount(val) mutation.

Add a computed effectiveMaxChoices:

  • If showAnswerCount is false0.
  • If showAnswerCount is truestate.value.choices.filter(c => c.correct).length.

Pass effectiveMaxChoices into buildXML so it overrides state.maxChoices when assembling XML.

Use the existing pattern: mutate state.value as a new plain object so Vue's reactivity picks up the change.

3. ChoiceInteractionDescriptor.js / parse.js

buildXML already accepts state and derives maxChoices from it. No signature change needed — useChoiceInteraction just passes a modified state (or an override) when calling buildXML.

4. ChoiceInteractionEditor.vue

Add an Answer settings section rendered with the portal to the question settings. It contains:

  • Shuffle answers for learnersKCheckbox bound to setShuffle.
  • Show learners how many answers to selectKCheckbox bound to setShowAnswerCount. Hidden when questionType === 'singleSelect'.

Each checkbox row has a KIconButton (info icon, KDS icon: 'help') next to it. Clicking it opens a KModal that explains what the setting does. The modal has a title, a short explanation, and a single Close button.

Use KCheckbox, KIconButton, and KModal from KDS. Do not use Vuetify.

5. qtiEditorStrings.js

Add translator strings:

  • answerSettingsLabel — "Answer settings"
  • shuffleAnswersLabel — "Shuffle answers for learners"
  • shuffleAnswersInfoTitle — "Shuffle answers for learners"
  • shuffleAnswersInfoBody — "The order of answer choices will be randomized each time a learner sees this question. This helps prevent learners from memorizing answer positions rather than understanding the content."
  • showAnswerCountLabel — "Show learners how many answers to select"
  • showAnswerCountInfoTitle — "Answers to select"
  • showAnswerCountInfoBody — "When enabled, learners see a hint below the answer options so they know how many answers to choose. Toggle this off to increase question difficulty."
  • closeBtn — "Close"
  • questionTypeInfoTitle — "Response type"
  • singleChoiceDescription — "Learners choose one correct answer from a list of options."
  • multipleChoiceDescription — "Learners identify all correct answers from a list, where more than one option may apply."

6. Question type selector

Add a KSelect in QTIItemEditor's header row that lists the available question types for the current interaction. For the choice interaction: Single choice, Multiple choice.

Changing the type emits update:questionType up to QTIItemEditor, which passes it back down as a prop to InteractionSectionChoiceInteractionEditor.

The selector also has a KIconButton (info icon) next to it. Clicking it opens a KModal that describes all available question types — name + one-line description — so authors understand the differences before choosing. The modal lists only the types that the current interaction plugin supports (for choice: Single choice and Multiple choice).


Out of scope

  • Any question type other than single choice and multiple choice.
  • min-choices / max-choices number inputs (the checkbox is the only control).

Acceptance criteria

Answer settings

  • ChoiceInteractionEditor renders an "Answer settings" section above the question prompt.
  • "Shuffle answers for learners" checkbox is bound to state.shuffle; toggling it updates the emitted XML.
  • "Show learners how many answers to select" is hidden for single choice questions.
  • When the checkbox is checked (default), max-choices and min-choices in the output XML equal the number of correct answers.
  • When the checkbox is unchecked, max-choices and min-choices are 0 in the output XML.
  • Changing which answers are correct while the checkbox is checked automatically updates max-choices / min-choices.
  • Both checkboxes use KCheckbox from KDS. No Vuetify.
  • Both checkboxes have accessible labels taken from qtiEditorStrings.
  • Each checkbox row has a KIconButton (info icon). Clicking it opens a KModal with the setting's title and explanation text.
  • The modal has a single Close button that dismisses it.
  • Modal title and body text come from qtiEditorStrings (no hardcoded strings).

Question type selector

  • QTIItemEditor renders a type selector in the card header.
  • Selector shows "Single choice" and "Multiple choice" for choice interactions.
  • Changing the type updates questionType and re-renders ChoiceInteractionEditor with the new type.
  • cardinality in the response declaration XML updates to match the new type.
  • The selector uses KSelect from KDS.
  • A KIconButton (info icon) sits next to the selector. Clicking it opens a KModal listing each available question type with its name and a one-line description.
  • The type info modal has a Close button that dismisses it.
  • All type names and descriptions come from qtiEditorStrings (no hardcoded strings).

Testing

Use @testing-library/vue for all component tests.

useChoiceInteraction.spec.js

it('showAnswerCount defaults to true', () => {
  const { showAnswerCount } = useChoiceInteraction(block, questionTypeRef);
  expect(showAnswerCount.value).toBe(true);
});

it('setShowAnswerCount(false) sets max-choices to 0 in built XML', async () => {
  const { setShowAnswerCount, bodyXml } = useChoiceInteraction(block, questionTypeRef);
  setShowAnswerCount(false);
  await nextTick();
  const root = new DOMParser().parseFromString(bodyXml.value, 'text/xml').documentElement;
  expect(root.getAttribute('max-choices')).toBe('0');
});

it('max-choices updates when correct answers change and showAnswerCount is true', async () => {
  const { toggleCorrectChoice, bodyXml } = useChoiceInteraction(block, questionTypeRef);
  // mark second choice correct (now 2 correct)
  toggleCorrectChoice('choice_b');
  await nextTick();
  const root = new DOMParser().parseFromString(bodyXml.value, 'text/xml').documentElement;
  expect(root.getAttribute('max-choices')).toBe('2');
});

ChoiceInteractionEditor.spec.js

it('renders Answer settings section in edit mode', () => {
  renderEditor({ interaction: block(CHOICE_MULTI_SELECT_XML), questionType: QuestionType.MULTI_SELECT });
  expect(screen.getByText(tr.$tr('answerSettingsLabel'))).toBeInTheDocument();
});

it('hides show-answer-count checkbox for single choice', () => {
  renderEditor({ interaction: block(CHOICE_SINGLE_SELECT_XML), questionType: QuestionType.SINGLE_SELECT });
  expect(screen.queryByLabelText(tr.$tr('showAnswerCountLabel'))).not.toBeInTheDocument();
});

it('toggling shuffle emits updated XML with shuffle="true"', async () => {
  const { emitted } = renderEditor({ interaction: block(CHOICE_MULTI_SELECT_XML), questionType: QuestionType.MULTI_SELECT });
  await fireEvent.click(screen.getByLabelText(tr.$tr('shuffleAnswersLabel')));
  const latest = emitted()['update:interaction'].at(-1)[0];
  expect(latest.bodyXml).toContain('shuffle="true"');
});

it('clicking the info button next to shuffle opens a KModal', async () => {
  renderEditor({ interaction: block(CHOICE_MULTI_SELECT_XML), questionType: QuestionType.MULTI_SELECT });
  const infoBtn = screen.getByRole('button', { name: tr.$tr('shuffleAnswersInfoTitle') });
  await fireEvent.click(infoBtn);
  expect(screen.getByRole('dialog')).toBeInTheDocument();
  expect(screen.getByText(tr.$tr('shuffleAnswersInfoBody'))).toBeInTheDocument();
});

it('KModal closes when the Close button is clicked', async () => {
  renderEditor({ interaction: block(CHOICE_MULTI_SELECT_XML), questionType: QuestionType.MULTI_SELECT });
  const infoBtn = screen.getByRole('button', { name: tr.$tr('shuffleAnswersInfoTitle') });
  await fireEvent.click(infoBtn);
  await fireEvent.click(screen.getByRole('button', { name: tr.$tr('closeBtn') }));
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

QTIItemEditor.spec.js

it('clicking the type info button opens a modal with question type descriptions', async () => {
  renderItemEditor({ item, index: 0, total: 1, mode: 'edit' });
  const infoBtn = screen.getByRole('button', { name: tr.$tr('questionTypeInfoTitle') });
  await fireEvent.click(infoBtn);
  expect(screen.getByRole('dialog')).toBeInTheDocument();
  expect(screen.getByText(tr.$tr('singleChoiceDescription'))).toBeInTheDocument();
  expect(screen.getByText(tr.$tr('multipleChoiceDescription'))).toBeInTheDocument();
});

it('type info modal closes on Close', async () => {
  renderItemEditor({ item, index: 0, total: 1, mode: 'edit' });
  await fireEvent.click(screen.getByRole('button', { name: tr.$tr('questionTypeInfoTitle') }));
  await fireEvent.click(screen.getByRole('button', { name: tr.$tr('closeBtn') }));
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

Resources

Accessibility Requirements

  • All KCheckbox controls must have a visible label that is also their accessible name — no icon-only checkboxes.
  • The KIconButton info buttons must have an aria-label matching the modal title they open (e.g. aria-label="Shuffle answers for learners") so screen reader users know what will open.
  • Each KModal must trap focus while open and return focus to the triggering button when closed. KModal from KDS handles this automatically — do not suppress it.
  • The KModal must be dismissible with the Escape key. Again, KModal handles this by default.
  • The KSelect type selector must have an accessible label (e.g. "Response type") so it is not announced as an unlabelled combo box.
  • Do not use aria-hidden on any of the new controls.
  • All new strings must go through qtiEditorStrings so they are translatable.

AI usage

I used Claude (Claude Code) to draft this issue from design decisions and the QTI editor architecture.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0 - criticalPriority: Release blocker or regression

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions