diff --git a/contentcuration/contentcuration/frontend/shared/app.js b/contentcuration/contentcuration/frontend/shared/app.js
index 2a4bc1c573..1549d219cc 100644
--- a/contentcuration/contentcuration/frontend/shared/app.js
+++ b/contentcuration/contentcuration/frontend/shared/app.js
@@ -109,6 +109,7 @@ import { Workbox, messageSW } from 'workbox-window';
import KThemePlugin from 'kolibri-design-system/lib/KThemePlugin';
import trackInputModality from 'kolibri-design-system/lib/styles/trackInputModality';
+import Teleport from 'vue2-teleport';
import AnalyticsPlugin from './analytics/plugin';
import { theme, icons } from 'shared/vuetify';
@@ -259,6 +260,7 @@ Vue.component('ActionLink', ActionLink);
Vue.component('BaseMenu', BaseMenu);
Vue.component('Divider', Divider);
Vue.component('Icon', Icon);
+Vue.component('Teleport', Teleport);
function initiateServiceWorker() {
// Second conditional must be removed if you are doing dev work on the service
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js
index 438046186b..1b048e218b 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/vue';
import { nextTick } from 'vue';
import VueRouter from 'vue-router';
import InteractionSection from '../index.vue';
+import { qtiEditorStrings as tr } from '../../../qtiEditorStrings';
import {
CHOICE_SINGLE_SELECT_XML,
@@ -10,6 +11,13 @@ import {
} from '../../../utils/testingFixtures';
jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
+jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
+ const { ref } = require('vue');
+ return {
+ __esModule: true,
+ default: () => ({ windowIsSmall: ref(false) }),
+ };
+});
const renderSection = (props = {}) =>
render(InteractionSection, {
@@ -41,7 +49,7 @@ describe('InteractionSection', () => {
describe('parse error handling', () => {
it('shows a parse error when XML is malformed', () => {
renderSection({ interaction: interactionBlock('not-xml<{{') });
- expect(screen.getByText('This question could not be loaded')).toBeInTheDocument();
+ expect(screen.getByText(tr.$tr('errorParsingQuestion'))).toBeInTheDocument();
});
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue
index 2aff6f8a7e..39929fbf27 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue
@@ -7,16 +7,36 @@
>
{{ parseError }}
- $emit('update:interaction', interaction)"
- />
+
+
+ {
+ questionType = newType;
+ $emit('update:questionType', newType);
+ }
+ "
+ />
+
+
+ $emit('update:interaction', interaction)"
+ />
+
@@ -26,13 +46,19 @@
import { computed, watch } from 'vue';
import useInteractionDescriptor from '../../composables/useInteractionDescriptor';
+ import QuestionTypeSelector from '../QuestionTypeSelector/index.vue';
export default {
name: 'InteractionSection',
+ components: {
+ QuestionTypeSelector,
+ },
+
setup(props, { emit }) {
const interactionRef = computed(() => props.interaction);
- const { descriptor, questionType, parseError } = useInteractionDescriptor(interactionRef);
+ const { descriptor, questionType, typeOptions, parseError } =
+ useInteractionDescriptor(interactionRef);
watch(
questionType,
@@ -42,7 +68,14 @@
{ immediate: true },
);
- return { descriptor, questionType, parseError };
+ const settingsTargetId = computed(() => {
+ if (props.teleportTarget && props.teleportTarget.startsWith('#')) {
+ return `${props.teleportTarget.substring(1)}-answer-settings`;
+ }
+ return 'qti-interaction-settings';
+ });
+
+ return { descriptor, questionType, typeOptions, parseError, settingsTargetId };
},
props: {
@@ -66,6 +99,10 @@
type: Boolean,
default: false,
},
+ teleportTarget: {
+ type: String,
+ default: '',
+ },
},
emits: ['update:questionType', 'update:interaction'],
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
index 9e4a2fd42c..8a9d19fe02 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
@@ -5,6 +5,13 @@ import { qtiEditorStrings } from '../../../qtiEditorStrings';
import { AssessmentItemTypes } from '../../../constants';
jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
+jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
+ const { ref } = require('vue');
+ return {
+ __esModule: true,
+ default: () => ({ windowIsSmall: ref(false) }),
+ };
+});
const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings;
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
index 659f052d2c..dcc49b7908 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
@@ -26,12 +26,15 @@
+
+
(currentQuestionType = type)"
@update:interaction="onUpdateInteraction"
/>
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/__tests__/QuestionTypeSelector.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/__tests__/QuestionTypeSelector.spec.js
new file mode 100644
index 0000000000..d318cf0356
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/__tests__/QuestionTypeSelector.spec.js
@@ -0,0 +1,86 @@
+import { render, screen, fireEvent } from '@testing-library/vue';
+import VueRouter from 'vue-router';
+import QuestionTypeSelector from '../index.vue';
+import { QuestionType } from '../../../constants';
+import { qtiEditorStrings as tr } from '../../../qtiEditorStrings';
+
+jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
+ const { ref } = require('vue');
+ return {
+ __esModule: true,
+ default: () => ({ windowIsSmall: ref(false) }),
+ };
+});
+
+const defaultProps = {
+ questionType: QuestionType.SINGLE_SELECT,
+ questionTypeOptions: [
+ {
+ value: QuestionType.SINGLE_SELECT,
+ label: tr.$tr('singleSelectLabel'),
+ description: tr.$tr('singleChoiceDescription'),
+ },
+ {
+ value: QuestionType.MULTI_SELECT,
+ label: tr.$tr('multiSelectLabel'),
+ description: tr.$tr('multipleSelectionDescription'),
+ },
+ ],
+ mode: 'edit',
+};
+
+const renderHeader = (props = {}) =>
+ render(QuestionTypeSelector, {
+ props: { ...defaultProps, ...props },
+ routes: new VueRouter(),
+ });
+
+describe('QuestionTypeSelector', () => {
+ it('renders the type meta-label in edit mode', () => {
+ renderHeader();
+ expect(screen.getByText(tr.$tr('typeLabel'))).toBeInTheDocument();
+ });
+
+ it('renders a KSelect with the selected option label (not raw enum)', () => {
+ renderHeader();
+ expect(screen.getByText(tr.$tr('singleSelectLabel'))).toBeInTheDocument();
+ expect(screen.queryByText(QuestionType.SINGLE_SELECT)).not.toBeInTheDocument();
+ });
+
+ it('renders the globe icon inside the KSelect via #display slot', () => {
+ renderHeader();
+ expect(document.querySelector('.select-globe-icon')).not.toBeNull();
+ expect(document.querySelector('.select-display-row')).not.toBeNull();
+ });
+
+ it('opens type info modal when info button clicked', async () => {
+ renderHeader();
+
+ const helpButton = screen.getByRole('button', { name: tr.$tr('responseTypeInfoTitle') });
+ await fireEvent.click(helpButton);
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText(tr.$tr('singleChoiceDescription'))).toBeInTheDocument();
+ expect(screen.getByText(tr.$tr('multipleSelectionDescription'))).toBeInTheDocument();
+ });
+
+ it('closes type info modal when Close button clicked', async () => {
+ renderHeader();
+
+ await fireEvent.click(screen.getByRole('button', { name: tr.$tr('responseTypeInfoTitle') }));
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ await fireEvent.click(screen.getByRole('button', { name: tr.$tr('closeBtnLabel') }));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('disables selector when only one option available', () => {
+ renderHeader({
+ questionTypeOptions: [defaultProps.questionTypeOptions[0]],
+ });
+
+ const hiddenInput = document.querySelector('input[type="hidden"]');
+ expect(hiddenInput).not.toBeNull();
+ expect(screen.getByText(tr.$tr('typeLabel'))).toBeInTheDocument();
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue
new file mode 100644
index 0000000000..e4479f9f17
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue
@@ -0,0 +1,245 @@
+
+
+
+
+
+ {{ typeLabel$() }}
+
+
+
+
+
+
+
+ {{ selectedOption.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+ {{ option.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js
index 3c85a35f11..a1326b45cb 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js
@@ -174,4 +174,86 @@ describe('useChoiceInteraction', () => {
expect(state.value.shuffle).toBe(true);
});
});
+
+ describe('showAnswerCount', () => {
+ it('defaults to true', () => {
+ const { showAnswerCount } = setup([
+ makeAnswer({ id: 'a', correct: true }),
+ makeAnswer({ id: 'b', correct: false }),
+ ]);
+ expect(showAnswerCount.value).toBe(true);
+ });
+
+ it('can be set to false', () => {
+ const { showAnswerCount, setShowAnswerCount } = setup([
+ makeAnswer({ id: 'a', correct: true }),
+ makeAnswer({ id: 'b', correct: false }),
+ ]);
+ setShowAnswerCount(false);
+ expect(showAnswerCount.value).toBe(false);
+ });
+ });
+
+ describe('effectiveMaxChoices', () => {
+ it('when showAnswerCount is true, max-choices equals number of correct answers', () => {
+ const { bodyXml } = setup(
+ [
+ makeAnswer({ id: 'a', correct: true }),
+ makeAnswer({ id: 'b', correct: true }),
+ makeAnswer({ id: 'c', correct: false }),
+ ],
+ QuestionType.MULTI_SELECT,
+ );
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(bodyXml.value, 'text/xml');
+ const interaction = doc.querySelector('qti-choice-interaction');
+
+ expect(interaction?.getAttribute('max-choices')).toBe('2');
+ });
+
+ it('when showAnswerCount is false, max-choices is 0', () => {
+ const { bodyXml, setShowAnswerCount } = setup(
+ [
+ makeAnswer({ id: 'a', correct: true }),
+ makeAnswer({ id: 'b', correct: true }),
+ makeAnswer({ id: 'c', correct: false }),
+ ],
+ QuestionType.MULTI_SELECT,
+ );
+
+ setShowAnswerCount(false);
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(bodyXml.value, 'text/xml');
+ const interaction = doc.querySelector('qti-choice-interaction');
+
+ expect(interaction?.getAttribute('max-choices')).toBe('0');
+ });
+
+ it('updates automatically when correct answers change and showAnswerCount is true', () => {
+ const { bodyXml, toggleCorrectChoice } = setup(
+ [
+ makeAnswer({ id: 'a', correct: true }),
+ makeAnswer({ id: 'b', correct: false }),
+ makeAnswer({ id: 'c', correct: false }),
+ ],
+ QuestionType.MULTI_SELECT,
+ );
+
+ // Initially 1 correct answer
+ let parser = new DOMParser();
+ let doc = parser.parseFromString(bodyXml.value, 'text/xml');
+ let interaction = doc.querySelector('qti-choice-interaction');
+ expect(interaction?.getAttribute('max-choices')).toBe('1');
+
+ // Toggle second answer correct
+ toggleCorrectChoice('b');
+
+ parser = new DOMParser();
+ doc = parser.parseFromString(bodyXml.value, 'text/xml');
+ interaction = doc.querySelector('qti-choice-interaction');
+ expect(interaction?.getAttribute('max-choices')).toBe('2');
+ });
+ });
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js
index 661f902e7e..d067e562e5 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js
@@ -1,4 +1,4 @@
-import { readonly } from 'vue';
+import { readonly, computed } from 'vue';
import { QuestionType } from '../constants';
import { generateRandomSlug } from '../utils/generateRandomSlug';
import { choiceInteractionDescriptor } from '../interactions/choice/ChoiceInteractionDescriptor';
@@ -18,9 +18,37 @@ export function useChoiceInteraction(interactionBlock, questionType) {
const base = useInteraction(choiceInteractionDescriptor, interactionBlock, questionType);
const { state } = base;
- // ---------------------------------------------------------------------------
- // Structural mutations
- // ---------------------------------------------------------------------------
+ const parsedShowAnswerCount = (state.value.maxChoices ?? 1) !== 0;
+
+ const showAnswerCount = computed({
+ get: () => state.value.showAnswerCount ?? parsedShowAnswerCount,
+ set: val => {
+ state.value = { ...state.value, showAnswerCount: val };
+ },
+ });
+
+ function setShowAnswerCount(val) {
+ showAnswerCount.value = val;
+ }
+
+ const effectiveMaxChoices = computed(() => {
+ if (!showAnswerCount.value) return 0;
+ return state.value.choices.filter(c => c.correct).length;
+ });
+
+ const stateForXml = computed(() => ({
+ ...state.value,
+ maxChoices: effectiveMaxChoices.value,
+ minChoices: effectiveMaxChoices.value,
+ }));
+
+ const builtXml = computed(() => {
+ if (!questionType.value) return { bodyXml: '', responseDeclarations: [] };
+ return choiceInteractionDescriptor.buildXML(stateForXml.value, questionType.value);
+ });
+
+ const bodyXml = computed(() => builtXml.value.bodyXml);
+ const responseDeclarations = computed(() => builtXml.value.responseDeclarations);
function addChoice() {
state.value = {
@@ -74,10 +102,6 @@ export function useChoiceInteraction(interactionBlock, questionType) {
};
}
- // ---------------------------------------------------------------------------
- // Field mutations
- // ---------------------------------------------------------------------------
-
function setPrompt(html) {
state.value = { ...state.value, prompt: html };
}
@@ -96,6 +120,10 @@ export function useChoiceInteraction(interactionBlock, questionType) {
return {
...base,
state: readonly(state),
+ bodyXml,
+ responseDeclarations,
+ showAnswerCount: readonly(showAnswerCount),
+ setShowAnswerCount,
addChoice,
removeChoice,
moveChoiceUp,
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js
index 76b40f311b..eb2f164b05 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js
@@ -67,5 +67,9 @@ export default function useInteractionDescriptor(interactionRef) {
registry[DEFAULT_INTERACTION],
);
- return { descriptor, questionType, parseError };
+ const typeOptions = computed(() => {
+ return descriptors.flatMap(d => d.getTypeOptions?.(qtiEditorStrings) ?? []);
+ });
+
+ return { descriptor, questionType, typeOptions, parseError };
}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js
index 5aa0e0d4cf..26b04163f6 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js
@@ -15,6 +15,21 @@ export class ChoiceInteractionDescriptor {
this.convertsFrom = [];
}
+ getTypeOptions(tr) {
+ return [
+ {
+ value: QuestionType.SINGLE_SELECT,
+ label: tr.singleSelectLabel$(),
+ description: tr.singleChoiceDescription$(),
+ },
+ {
+ value: QuestionType.MULTI_SELECT,
+ label: tr.multiSelectLabel$(),
+ description: tr.multipleSelectionDescription$(),
+ },
+ ];
+ }
+
/** @param {Element} el */
matches(el) {
return el.tagName.toLowerCase() === QtiInteraction.CHOICE;
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
index b46aa1a058..27c45b9bf9 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
@@ -1,6 +1,20 @@
+
+
+
+
@@ -63,7 +77,7 @@
{{ answersDescription }}
-
-
-
+
props.questionType === QuestionType.SINGLE_SELECT);
+
const isQuestionOpen = ref(false);
const openChoiceId = ref(null);
@@ -302,8 +329,6 @@
}));
watch(workingInteraction, newVal => emit('update:interaction', newVal), { immediate: true });
- const isSingleSelect = computed(() => props.questionType === QuestionType.SINGLE_SELECT);
-
const answersDescription = computed(() =>
isSingleSelect.value
? answersDescriptionSingleChoice$()
@@ -460,6 +485,7 @@
state,
isSingleSelect,
windowIsSmall,
+ showAnswerCount,
answersLabel$,
answersDescription,
isQuestionOpen,
@@ -474,6 +500,8 @@
choiceHasError,
setPrompt,
setChoiceContent,
+ setShuffle,
+ setShowAnswerCount,
onToggleCorrect,
onAddChoice,
getChoiceRowActions,
@@ -514,9 +542,13 @@
type: Boolean,
default: false,
},
+ teleportTarget: {
+ type: String,
+ default: '#qti-question-settings',
+ },
},
- emits: ['update:interaction'],
+ emits: ['update:interaction', 'update:questionType'],
};
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js
index d54f2bfcdc..28d5898d6a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js
@@ -91,12 +91,18 @@ describe('ChoiceInteractionEditor', () => {
});
describe('multiSelect (KCheckbox)', () => {
+ const choiceCheckboxes = () => {
+ const list = document.querySelector('.choices-list');
+ if (!list) return [];
+ return Array.from(list.querySelectorAll('input[type="checkbox"]'));
+ };
+
it('renders a checkbox for each choice', () => {
renderEditor({
interaction: block(CHOICE_MULTI_SELECT_XML),
questionType: QuestionType.MULTI_SELECT,
});
- expect(screen.getAllByRole('checkbox')).toHaveLength(3);
+ expect(choiceCheckboxes()).toHaveLength(3);
});
it('renders the correct choice labels', () => {
@@ -114,7 +120,7 @@ describe('ChoiceInteractionEditor', () => {
interaction: blockWithDecl(CHOICE_MULTI_SELECT_XML, MULTI_DECL),
questionType: QuestionType.MULTI_SELECT,
});
- const checkboxes = screen.getAllByRole('checkbox');
+ const checkboxes = choiceCheckboxes();
expect(checkboxes[0]).toBeChecked(); // a
expect(checkboxes[1]).not.toBeChecked(); // b
expect(checkboxes[2]).toBeChecked(); // c
@@ -125,7 +131,7 @@ describe('ChoiceInteractionEditor', () => {
interaction: block(CHOICE_MULTI_SELECT_XML),
questionType: QuestionType.MULTI_SELECT,
});
- const [checkA, checkB] = screen.getAllByRole('checkbox');
+ const [checkA, checkB] = choiceCheckboxes();
await fireEvent.click(checkA);
await fireEvent.click(checkB);
expect(checkA).toBeChecked();
@@ -137,7 +143,7 @@ describe('ChoiceInteractionEditor', () => {
interaction: blockWithDecl(CHOICE_MULTI_SELECT_XML, MULTI_DECL),
questionType: QuestionType.MULTI_SELECT,
});
- const [checkA] = screen.getAllByRole('checkbox');
+ const [checkA] = choiceCheckboxes();
await fireEvent.click(checkA);
expect(checkA).not.toBeChecked();
});
@@ -328,6 +334,79 @@ describe('ChoiceInteractionEditor', () => {
});
});
+ describe('Answer settings', () => {
+ 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('renders shuffle answers checkbox', () => {
+ renderEditor({
+ interaction: block(CHOICE_MULTI_SELECT_XML),
+ questionType: QuestionType.MULTI_SELECT,
+ });
+ // KIconButton also has the same aria-label — use role=checkbox specifically
+ expect(
+ screen.getByRole('checkbox', { name: tr.$tr('shuffleAnswersLabel') }),
+ ).toBeInTheDocument();
+ });
+
+ it('hides show-answer-count checkbox for single choice', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ expect(
+ screen.queryByRole('checkbox', { name: tr.$tr('showAnswerCountLabel') }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows show-answer-count checkbox for multi choice', () => {
+ renderEditor({
+ interaction: block(CHOICE_MULTI_SELECT_XML),
+ questionType: QuestionType.MULTI_SELECT,
+ });
+ expect(
+ screen.getByRole('checkbox', { name: tr.$tr('showAnswerCountLabel') }),
+ ).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.getByRole('checkbox', { name: 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 modal', 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('closeBtnLabel') }));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
describe('accessibility', () => {
it('all radios have an accessible label', () => {
renderEditor({
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/components/AnswerSettings/__tests__/AnswerSettings.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/components/AnswerSettings/__tests__/AnswerSettings.spec.js
new file mode 100644
index 0000000000..1c9e9d772b
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/components/AnswerSettings/__tests__/AnswerSettings.spec.js
@@ -0,0 +1,123 @@
+import { render, screen, fireEvent } from '@testing-library/vue';
+import VueRouter from 'vue-router';
+import AnswerSettings from '../index.vue';
+import { qtiEditorStrings as tr } from '../../../../../qtiEditorStrings';
+import { QuestionType } from '../../../../../constants';
+
+jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
+ const { ref } = require('vue');
+ return {
+ __esModule: true,
+ default: () => ({ windowIsSmall: ref(false) }),
+ };
+});
+
+const defaultProps = {
+ questionType: QuestionType.MULTI_SELECT,
+ shuffle: false,
+ showAnswerCount: true,
+};
+
+describe('AnswerSettings', () => {
+ it('renders answer settings label', () => {
+ render(AnswerSettings, { props: defaultProps, routes: new VueRouter() });
+ expect(screen.getByText(tr.$tr('answerSettingsLabel'))).toBeInTheDocument();
+ });
+
+ it('renders shuffle checkbox when included in settings', () => {
+ render(AnswerSettings, { props: defaultProps, routes: new VueRouter() });
+ expect(
+ screen.getByRole('checkbox', { name: tr.$tr('shuffleAnswersLabel') }),
+ ).toBeInTheDocument();
+ });
+
+ it('renders show answer count checkbox when questionType is MULTI_SELECT', () => {
+ render(AnswerSettings, { props: defaultProps, routes: new VueRouter() });
+ expect(
+ screen.getByRole('checkbox', { name: tr.$tr('showAnswerCountLabel') }),
+ ).toBeInTheDocument();
+ });
+
+ it('does not render show answer count checkbox when questionType is SINGLE_SELECT', () => {
+ render(AnswerSettings, {
+ props: { ...defaultProps, questionType: QuestionType.SINGLE_SELECT },
+ routes: new VueRouter(),
+ });
+ expect(
+ screen.queryByRole('checkbox', { name: tr.$tr('showAnswerCountLabel') }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('emits update:shuffle when shuffle checkbox toggled', async () => {
+ const { emitted } = render(AnswerSettings, { props: defaultProps, routes: new VueRouter() });
+
+ const checkbox = screen.getByRole('checkbox', { name: tr.$tr('shuffleAnswersLabel') });
+ await fireEvent.click(checkbox);
+
+ expect(emitted()['update:shuffle']).toBeTruthy();
+ expect(emitted()['update:shuffle'][0]).toEqual([true]);
+ });
+
+ it('emits update:showAnswerCount when show answer count checkbox toggled', async () => {
+ const { emitted } = render(AnswerSettings, { props: defaultProps, routes: new VueRouter() });
+
+ const checkbox = screen.getByRole('checkbox', { name: tr.$tr('showAnswerCountLabel') });
+ await fireEvent.click(checkbox);
+
+ expect(emitted()['update:showAnswerCount']).toBeTruthy();
+ expect(emitted()['update:showAnswerCount'][0]).toEqual([false]);
+ });
+
+ it('opens shuffle info modal when info button clicked', async () => {
+ render(AnswerSettings, { props: defaultProps, routes: new VueRouter() });
+
+ const infoButtons = screen.getAllByRole('button', { name: tr.$tr('shuffleAnswersInfoTitle') });
+ await fireEvent.click(infoButtons[0]);
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText(tr.$tr('shuffleAnswersInfoBody'))).toBeInTheDocument();
+ });
+
+ it('opens show answer count info modal when info button clicked', async () => {
+ render(AnswerSettings, { props: defaultProps, routes: new VueRouter() });
+
+ const infoButtons = screen.getAllByRole('button', {
+ name: tr.$tr('showAnswerCountInfoTitle'),
+ });
+ await fireEvent.click(infoButtons[0]);
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText(tr.$tr('showAnswerCountInfoBody'))).toBeInTheDocument();
+ });
+
+ it('closes modal when Close button clicked', async () => {
+ render(AnswerSettings, { props: defaultProps, routes: new VueRouter() });
+
+ const infoButtons = screen.getAllByRole('button', { name: tr.$tr('shuffleAnswersInfoTitle') });
+ await fireEvent.click(infoButtons[0]);
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ await fireEvent.click(screen.getByRole('button', { name: tr.$tr('closeBtnLabel') }));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('reflects shuffle prop value in checkbox', () => {
+ render(AnswerSettings, {
+ props: { ...defaultProps, shuffle: true },
+ routes: new VueRouter(),
+ });
+
+ const checkbox = screen.getByRole('checkbox', { name: tr.$tr('shuffleAnswersLabel') });
+ expect(checkbox).toBeChecked();
+ });
+
+ it('reflects showAnswerCount prop value in checkbox', () => {
+ render(AnswerSettings, {
+ props: { ...defaultProps, showAnswerCount: false },
+ routes: new VueRouter(),
+ });
+
+ const checkbox = screen.getByRole('checkbox', { name: tr.$tr('showAnswerCountLabel') });
+ expect(checkbox).not.toBeChecked();
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/components/AnswerSettings/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/components/AnswerSettings/index.vue
new file mode 100644
index 0000000000..44f2de749c
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/components/AnswerSettings/index.vue
@@ -0,0 +1,154 @@
+
+
+
+
+ {{ answerSettingsLabel$() }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ shuffleAnswersInfoBody$() }}
+
+
+
+
+
+ {{ showAnswerCountInfoBody$() }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue
index fde531bde2..6e248cbfca 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue
@@ -394,6 +394,7 @@
type: String,
default: null,
},
+
mode: {
type: String,
default: 'view',
@@ -406,7 +407,7 @@
},
},
- emits: ['update:interaction'],
+ emits: ['update:interaction', 'update:questionType'],
};
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js
index 6113fca18c..9972eb691f 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js
@@ -23,6 +23,26 @@ class TextEntryInteractionDescriptor {
this.convertsFrom = [];
}
+ getTypeOptions(tr) {
+ return [
+ {
+ value: QuestionType.NUMERIC,
+ label: tr.numericLabel$(),
+ description: tr.numericDescription$(),
+ },
+ {
+ value: QuestionType.TEXT_ENTRY,
+ label: tr.textEntryLabel$(),
+ description: tr.textEntryDescription$(),
+ },
+ {
+ value: QuestionType.FREE_RESPONSE,
+ label: tr.freeResponseLabel$(),
+ description: tr.freeResponseDescription$(),
+ },
+ ];
+ }
+
/** @param {Element} el */
matches(el) {
if (el.tagName.toLowerCase() === QtiInteraction.TEXT_ENTRY) return true;
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
index 8e00d238ba..b7d4a8f6c7 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
@@ -206,4 +206,57 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', {
message: 'This question could not be loaded',
context: 'Shown in place of the interaction editor when the QTI XML fails to parse',
},
+ // Question type selector
+ typeLabel: {
+ message: 'Type',
+ context: 'Short label above the question type selector',
+ },
+ responseTypeLabel: {
+ message: 'Response type',
+ context: 'Label for the question type selector dropdown',
+ },
+ responseTypeInfoTitle: {
+ message: 'Response type',
+ context: 'Modal title explaining available question types',
+ },
+ singleChoiceDescription: {
+ message: 'Learners choose one correct answer from a list of options.',
+ context: 'Description of single choice question type in info modal',
+ },
+ multipleSelectionDescription: {
+ message:
+ 'Learners identify all correct answers from a list, where more than one option may apply.',
+ context: 'Description of multiple selection question type in info modal',
+ },
+ // Answer settings
+ answerSettingsLabel: {
+ message: 'Answer settings',
+ context: 'Section header for answer configuration controls',
+ },
+ shuffleAnswersLabel: {
+ message: 'Shuffle answers for learners',
+ context: 'Checkbox label to randomize answer order',
+ },
+ shuffleAnswersInfoTitle: {
+ message: 'Shuffle answers for learners',
+ context: 'Modal title explaining shuffle behavior',
+ },
+ shuffleAnswersInfoBody: {
+ message:
+ '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.',
+ context: 'Modal body explaining shuffle behavior',
+ },
+ showAnswerCountLabel: {
+ message: 'Show learners how many answers to select',
+ context: 'Checkbox label for displaying answer count hint',
+ },
+ showAnswerCountInfoTitle: {
+ message: 'Show learners how many answers to select',
+ context: 'Modal title explaining answer count hint',
+ },
+ showAnswerCountInfoBody: {
+ message:
+ '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.',
+ context: 'Modal body explaining answer count hint',
+ },
});
diff --git a/package.json b/package.json
index 2e28f8fae8..1aec675286 100644
--- a/package.json
+++ b/package.json
@@ -99,6 +99,7 @@
"vue-custom-element": "https://github.com/learningequality/vue-custom-element.git#master",
"vue-intl": "^3.0.0",
"vue-router": "3.6.5",
+ "vue2-teleport": "^1.2.2",
"vuetify": "^1.5.24",
"vuex": "^3.0.1",
"workbox-core": "^7.4.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 29113ba178..53a1abdcb7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -149,6 +149,9 @@ importers:
vue-router:
specifier: 3.6.5
version: 3.6.5(vue@2.7.16)
+ vue2-teleport:
+ specifier: ^1.2.2
+ version: 1.2.2
vuetify:
specifier: ^1.5.24
version: 1.5.24(vue@2.7.16)
@@ -7706,6 +7709,9 @@ packages:
vue2-teleport@1.1.4:
resolution: {integrity: sha512-mGTszyQP6k3sSSk7MBq+PZdVojHYLwg5772hl3UVpu5uaLBqWIZ5eNP6/TjkDrf1XUTTxybvpXC6inpjwO+i/Q==}
+ vue2-teleport@1.2.2:
+ resolution: {integrity: sha512-HiGkijzS+qcM6OAovlPfdnbVbi+cA2DX8qfFIm0QF4Mmh7nuQUZomGwkQ4Qei5Wy3SwtLeHvVc9CSfk+pzleog==}
+
vue@2.7.16:
resolution: {integrity: sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==}
deprecated: Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.
@@ -16840,6 +16846,8 @@ snapshots:
vue2-teleport@1.1.4: {}
+ vue2-teleport@1.2.2: {}
+
vue@2.7.16:
dependencies:
'@vue/compiler-sfc': 2.7.16