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..e5f25514dc 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
@@ -1,15 +1,24 @@
-import { render, screen } from '@testing-library/vue';
+import { render, screen, fireEvent, within } 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,
+ CHOICE_MULTI_SELECT_XML,
UNKNOWN_INTERACTION_XML,
mockInteractionBlock as interactionBlock,
} 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, {
@@ -36,12 +45,72 @@ describe('InteractionSection', () => {
expect(screen.getByText('Mercury')).toBeInTheDocument();
expect(screen.getByText('Venus')).toBeInTheDocument();
});
+
+ it('teleports answer settings into the question type selector header', async () => {
+ renderSection({ interaction: interactionBlock(CHOICE_MULTI_SELECT_XML) });
+ await nextTick();
+ const targetDiv = document.querySelector('.answer-settings-group');
+ expect(targetDiv).toBeInTheDocument();
+ expect(
+ within(targetDiv).getByRole('checkbox', { name: tr.$tr('shuffleAnswersLabel') }),
+ ).toBeInTheDocument();
+ });
});
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();
+ });
+ });
+
+ describe('type switching', () => {
+ it('preserves the prompt but resets choices when switching from choice to text-entry', async () => {
+ const Wrapper = {
+ components: { InteractionSection },
+ template: `
+
+ `,
+ data() {
+ return {
+ interactionBlock: {
+ bodyXml: CHOICE_SINGLE_SELECT_XML,
+ responseDeclarations: [],
+ },
+ };
+ },
+ methods: {
+ onUpdate(val) {
+ this.interactionBlock = val;
+ this.$emit('wrapper-update', val);
+ },
+ },
+ };
+
+ const { emitted } = render(Wrapper, {
+ routes: new VueRouter(),
+ });
+
+ await nextTick();
+
+ const selectedOption = screen.getAllByText(tr.$tr('singleSelectLabel'))[0];
+ await fireEvent.click(selectedOption);
+
+ const textEntryOption = screen.getByText(tr.$tr('textEntryLabel'));
+ await fireEvent.click(textEntryOption);
+
+ await nextTick();
+
+ const emits = emitted()['wrapper-update'];
+ const switchXml = emits.at(-1)[0].bodyXml;
+
+ expect(switchXml).toContain('Which planet is closest to the Sun?');
+ expect(switchXml).toContain('
{{ parseError }}
- $emit('update:interaction', interaction)"
- />
+
+
+
+
+
@@ -26,10 +35,17 @@
import { computed, watch } from 'vue';
import useInteractionDescriptor from '../../composables/useInteractionDescriptor';
+ import QuestionTypeSelector from '../QuestionTypeSelector/index.vue';
+ import { generateRandomSlug } from '../../utils/generateRandomSlug';
+ import { descriptors } from '../../interactions';
export default {
name: 'InteractionSection',
+ components: {
+ QuestionTypeSelector,
+ },
+
setup(props, { emit }) {
const interactionRef = computed(() => props.interaction);
const { descriptor, questionType, parseError } = useInteractionDescriptor(interactionRef);
@@ -42,7 +58,37 @@
{ immediate: true },
);
- return { descriptor, questionType, parseError };
+ const onUpdateQuestionType = newType => {
+ const newDescriptor = descriptors.find(d => d.questionTypes.includes(newType));
+ if (newDescriptor && newDescriptor !== descriptor.value) {
+ const oldState = descriptor.value.parse(
+ props.interaction.bodyXml,
+ props.interaction.responseDeclarations,
+ );
+ const freshState = newDescriptor.parse('', []);
+ const withPrompt = { ...freshState, prompt: oldState.prompt ?? '' };
+ const newInteraction = newDescriptor.buildXML(withPrompt, newType);
+ emit('update:interaction', newInteraction);
+ }
+
+ questionType.value = newType;
+ emit('update:questionType', newType);
+ };
+
+ const onUpdateInteraction = updatedInteraction => {
+ emit('update:interaction', updatedInteraction);
+ };
+
+ const settingsTargetId = generateRandomSlug('answer-settings');
+
+ return {
+ descriptor,
+ questionType,
+ parseError,
+ onUpdateQuestionType,
+ onUpdateInteraction,
+ settingsTargetId,
+ };
},
props: {
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..a699e6ed16 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
@@ -29,7 +29,7 @@
(currentQuestionType = type)"
@@ -103,6 +103,11 @@
currentResponseDeclarations.value = interactions.value[0].responseDeclarations;
}
+ const currentInteraction = computed(() => ({
+ bodyXml: currentBodyXml.value,
+ responseDeclarations: currentResponseDeclarations.value,
+ }));
+
const questionNumberLabel = computed(() =>
questionNumberLabel$({
number: props.index + 1,
@@ -155,6 +160,7 @@
return {
currentQuestionType,
interactions,
+ currentInteraction,
questionNumberLabel,
questionNumberAndTypeLabel,
closeBtnLabel$,
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..1a1fd991d5
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/__tests__/QuestionTypeSelector.spec.js
@@ -0,0 +1,69 @@
+import { render, screen, fireEvent, within } from '@testing-library/vue';
+import VueRouter from 'vue-router';
+import QuestionTypeSelector from '../index.vue';
+import { QuestionType } from '../../../constants';
+import { qtiEditorStrings as tr } from '../../../qtiEditorStrings';
+
+const defaultProps = {
+ questionType: QuestionType.SINGLE_SELECT,
+ settingsTargetId: 'test-settings-target',
+};
+
+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 selected type label inside the type group', () => {
+ renderHeader();
+ const group = screen.getByRole('group', { name: tr.$tr('typeLabel') });
+ expect(within(group).getByText(tr.$tr('singleSelectLabel'))).toBeInTheDocument();
+ });
+
+ 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('emits update:questionType when a new type is selected', async () => {
+ const { emitted } = renderHeader();
+
+ // Click the currently selected option to open the dropdown
+ await fireEvent.click(screen.getByText(tr.$tr('singleSelectLabel')));
+
+ // Click the new option from the dropdown menu
+ await fireEvent.click(screen.getByText(tr.$tr('multiSelectLabel')));
+
+ expect(emitted()['update:questionType']).toBeTruthy();
+ expect(emitted()['update:questionType'][0]).toEqual([QuestionType.MULTI_SELECT]);
+ });
+});
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..1cb89f6cf5
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue
@@ -0,0 +1,241 @@
+
+
+
+
+
+ {{ 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..05fa5419f4 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,125 @@ describe('useChoiceInteraction', () => {
expect(state.value.shuffle).toBe(true);
});
});
+
+ describe('showAnswerCount', () => {
+ it('defaults to true (maxChoices !== 0 in the fixture)', () => {
+ const { state } = setup([
+ makeAnswer({ id: 'a', correct: true }),
+ makeAnswer({ id: 'b', correct: false }),
+ ]);
+ expect(state.value.showAnswerCount).toBe(true);
+ });
+
+ it('setShowAnswerCount(false) updates state.showAnswerCount', () => {
+ const { state, setShowAnswerCount } = setup([
+ makeAnswer({ id: 'a', correct: true }),
+ makeAnswer({ id: 'b', correct: false }),
+ ]);
+ setShowAnswerCount(false);
+ expect(state.value.showAnswerCount).toBe(false);
+ });
+ });
+
+ describe('max-choices / min-choices XML output', () => {
+ 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');
+ expect(interaction?.getAttribute('min-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');
+ expect(interaction?.getAttribute('min-choices')).toBe('2');
+ });
+
+ it('sets max-choices to 1 and omits min-choices for single select', () => {
+ const { bodyXml } = setup(
+ [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false })],
+ QuestionType.SINGLE_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('1');
+ expect(interaction?.hasAttribute('min-choices')).toBe(false);
+ });
+ });
+
+ describe('questionType conversion', () => {
+ it('updates response declaration cardinality to match the new type', () => {
+ const { questionTypeRef, responseDeclarations } = setup(
+ [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false })],
+ QuestionType.SINGLE_SELECT,
+ );
+
+ // Verify initial state
+ let parser = new DOMParser();
+ let doc = parser.parseFromString(responseDeclarations.value[0], 'text/xml');
+ let declaration = doc.querySelector('qti-response-declaration');
+ expect(declaration?.getAttribute('cardinality')).toBe('single');
+
+ // Change type to multi-select
+ questionTypeRef.value = QuestionType.MULTI_SELECT;
+
+ // Verify updated state
+ parser = new DOMParser();
+ doc = parser.parseFromString(responseDeclarations.value[0], 'text/xml');
+ declaration = doc.querySelector('qti-response-declaration');
+ expect(declaration?.getAttribute('cardinality')).toBe('multiple');
+ });
+ });
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js
index 661f902e7e..d030f693d2 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 { computed, readonly } from 'vue';
import { QuestionType } from '../constants';
import { generateRandomSlug } from '../utils/generateRandomSlug';
import { choiceInteractionDescriptor } from '../interactions/choice/ChoiceInteractionDescriptor';
@@ -17,10 +17,7 @@ import { useInteraction } from './useInteraction';
export function useChoiceInteraction(interactionBlock, questionType) {
const base = useInteraction(choiceInteractionDescriptor, interactionBlock, questionType);
const { state } = base;
-
- // ---------------------------------------------------------------------------
- // Structural mutations
- // ---------------------------------------------------------------------------
+ const isSingleSelect = computed(() => questionType.value === QuestionType.SINGLE_SELECT);
function addChoice() {
state.value = {
@@ -74,10 +71,6 @@ export function useChoiceInteraction(interactionBlock, questionType) {
};
}
- // ---------------------------------------------------------------------------
- // Field mutations
- // ---------------------------------------------------------------------------
-
function setPrompt(html) {
state.value = { ...state.value, prompt: html };
}
@@ -93,9 +86,14 @@ export function useChoiceInteraction(interactionBlock, questionType) {
state.value = { ...state.value, shuffle: val };
}
+ function setShowAnswerCount(val) {
+ state.value = { ...state.value, showAnswerCount: val };
+ }
+
return {
...base,
state: readonly(state),
+ isSingleSelect,
addChoice,
removeChoice,
moveChoiceUp,
@@ -104,5 +102,6 @@ export function useChoiceInteraction(interactionBlock, questionType) {
setPrompt,
setChoiceContent,
setShuffle,
+ setShowAnswerCount,
};
}
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..f07dd56db7 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,19 @@
+
+
+
+
@@ -51,6 +64,7 @@
{{ errorTooManyCorrectAnswers$() }}
-
+
-
+
+
+
-
-
-
-
-
@@ -166,7 +182,7 @@
-
+
import { computed, ref, watch, getCurrentInstance } from 'vue';
+ import Teleport from 'vue2-teleport';
import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow';
import { themePalette, themeTokens } from 'kolibri-design-system/lib/styles/theme';
import { qtiEditorStrings } from '../../qtiEditorStrings';
- import { QuestionType, ValidationError } from '../../constants';
+ import { ValidationError } from '../../constants';
+ import { generateRandomSlug } from '../../utils/generateRandomSlug';
import { useChoiceInteraction } from '../../composables/useChoiceInteraction';
import CollapsibleToolbar from '../../components/CollapsibleToolbar/index.vue';
import ValidationMessage from '../../components/ValidationMessage/index.vue';
import AddListItemButton from '../../components/AddListItemButton/index.vue';
+ import AnswerSettings from './components/AnswerSettings/index.vue';
import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor';
import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService';
export default {
name: 'ChoiceInteractionEditor',
- components: { TipTapEditor, CollapsibleToolbar, ValidationMessage, AddListItemButton },
+ components: {
+ TipTapEditor,
+ CollapsibleToolbar,
+ ValidationMessage,
+ AddListItemButton,
+ AnswerSettings,
+ Teleport,
+ },
setup(props, { emit }) {
const { windowIsSmall } = useKResponsiveWindow();
@@ -231,6 +257,7 @@
bodyXml,
responseDeclarations,
errors,
+ isSingleSelect,
addChoice,
removeChoice,
moveChoiceUp,
@@ -238,6 +265,8 @@
toggleCorrectChoice,
setPrompt,
setChoiceContent,
+ setShuffle,
+ setShowAnswerCount,
} = useChoiceInteraction(props.interaction, questionTypeRef);
const isQuestionOpen = ref(false);
@@ -302,8 +331,6 @@
}));
watch(workingInteraction, newVal => emit('update:interaction', newVal), { immediate: true });
- const isSingleSelect = computed(() => props.questionType === QuestionType.SINGLE_SELECT);
-
const answersDescription = computed(() =>
isSingleSelect.value
? answersDescriptionSingleChoice$()
@@ -453,6 +480,8 @@
};
}
+ const answersHeaderId = generateRandomSlug('answers-header');
+
return {
EditorImageProcessor,
promptWrapperClass,
@@ -462,6 +491,7 @@
windowIsSmall,
answersLabel$,
answersDescription,
+ answersHeaderId,
isQuestionOpen,
closeQuestion,
closeChoice,
@@ -474,6 +504,8 @@
choiceHasError,
setPrompt,
setChoiceContent,
+ setShuffle,
+ setShowAnswerCount,
onToggleCorrect,
onAddChoice,
getChoiceRowActions,
@@ -514,6 +546,10 @@
type: Boolean,
default: false,
},
+ teleportTargetId: {
+ type: String,
+ required: true,
+ },
},
emits: ['update:interaction'],
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..7cf59d9378 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
@@ -1,4 +1,4 @@
-import { render, screen, fireEvent } from '@testing-library/vue';
+import { render, screen, fireEvent, within } from '@testing-library/vue';
import { nextTick } from 'vue';
import VueRouter from 'vue-router';
import ChoiceInteractionEditor from '../ChoiceInteractionEditor.vue';
@@ -24,9 +24,23 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
};
});
+let teleportContainer;
+
+beforeEach(() => {
+ teleportContainer = document.createElement('div');
+ teleportContainer.id = 'test-settings-target';
+ document.body.appendChild(teleportContainer);
+});
+
+afterEach(() => {
+ if (teleportContainer && teleportContainer.parentNode) {
+ teleportContainer.parentNode.removeChild(teleportContainer);
+ }
+});
+
const renderEditor = (props = {}) =>
render(ChoiceInteractionEditor, {
- props: { mode: 'edit', ...props },
+ props: { mode: 'edit', teleportTargetId: 'test-settings-target', ...props },
routes: new VueRouter(),
});
@@ -91,12 +105,18 @@ describe('ChoiceInteractionEditor', () => {
});
describe('multiSelect (KCheckbox)', () => {
+ const choiceCheckboxes = () => {
+ const group = screen.queryByRole('group', { name: tr.$tr('answersLabel') });
+ if (!group) return [];
+ return Array.from(group.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 +134,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 +145,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 +157,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 +348,87 @@ 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(
+ within(teleportContainer).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(
+ within(teleportContainer).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(
+ within(teleportContainer).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(
+ within(teleportContainer).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(
+ within(teleportContainer).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 = within(teleportContainer).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 = within(teleportContainer).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/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js
index 833bbbc638..53f5f286bf 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js
@@ -18,17 +18,12 @@ const buildXML = choiceInteractionDescriptor.buildXML.bind(choiceInteractionDesc
describe('parse()', () => {
describe('attribute defaults', () => {
- it('defaults maxChoices to 0 when attribute is absent', () => {
+ it('defaults showAnswerCount to true when max-choices attribute is absent', () => {
const xml = `
A
`;
const state = parse(xml, []);
- expect(state.maxChoices).toBe(0);
- });
-
- it('defaults minChoices to 0 when attribute is absent', () => {
- const state = parse(CHOICE_SINGLE_SELECT_XML, []);
- expect(state.minChoices).toBe(0);
+ expect(state.showAnswerCount).toBe(true);
});
it('defaults shuffle to false when attribute is absent', () => {
@@ -48,9 +43,12 @@ describe('parse()', () => {
});
describe('attribute reading', () => {
- it('reads max-choices attribute', () => {
- const state = parse(CHOICE_SINGLE_SELECT_XML, []);
- expect(state.maxChoices).toBe(1);
+ it('sets showAnswerCount to false when max-choices="0"', () => {
+ const xml = `
+ A
+ `;
+ const state = parse(xml, []);
+ expect(state.showAnswerCount).toBe(false);
});
it('reads shuffle attribute when true', () => {
@@ -134,6 +132,7 @@ describe('parse()', () => {
expect(state.choices[0].content).toBe('');
expect(state.choices[0].correct).toBe(false);
expect(state.prompt).toBe('');
+ expect(state.showAnswerCount).toBe(true);
});
it('returns default state for malformed XML', () => {
@@ -141,6 +140,16 @@ describe('parse()', () => {
expect(state.choices).toHaveLength(1);
});
});
+
+ describe('showAnswerCount', () => {
+ it('parses max-choices="0" as showAnswerCount = false', () => {
+ const xml = `
+ A
+ `;
+ const state = parse(xml, []);
+ expect(state.showAnswerCount).toBe(false);
+ });
+ });
});
describe('buildXML()', () => {
@@ -158,8 +167,7 @@ describe('buildXML()', () => {
{ id: 'choice_a', content: 'Option A', correct: true, fixed: false },
{ id: 'choice_b', content: 'Option B', correct: false, fixed: false },
],
- maxChoices: 1,
- minChoices: 0,
+ showAnswerCount: true,
shuffle: false,
orientation: Orientation.VERTICAL,
};
@@ -178,7 +186,6 @@ describe('buildXML()', () => {
{ id: 'a', content: 'A', correct: true, fixed: false },
{ id: 'b', content: 'B', correct: true, fixed: false },
],
- maxChoices: 2,
};
const { responseDeclarations } = buildXML(multiState, QuestionType.MULTI_SELECT);
const decl = parseXmlString(responseDeclarations[0]);
@@ -207,7 +214,6 @@ describe('buildXML()', () => {
{ id: 'y', content: 'Y', correct: true, fixed: false },
{ id: 'z', content: 'Z', correct: false, fixed: false },
],
- maxChoices: 2,
};
const { responseDeclarations } = buildXML(multiState, QuestionType.MULTI_SELECT);
const decl = parseXmlString(responseDeclarations[0]);
@@ -219,24 +225,46 @@ describe('buildXML()', () => {
});
describe('body XML', () => {
- it('omits min-choices attribute when minChoices is 0', () => {
+ it('omits min-choices attribute for single-select (spec: only multi-select uses min-choices)', () => {
const { bodyXml } = buildXML(baseState, QuestionType.SINGLE_SELECT);
const root = parseXmlString(bodyXml);
expect(root.getAttribute('min-choices')).toBeNull();
});
- it('sets min-choices attribute when minChoices > 0', () => {
- const { bodyXml } = buildXML({ ...baseState, minChoices: 1 }, QuestionType.SINGLE_SELECT);
+ it('sets min-choices attribute for multi-select when showAnswerCount is true', () => {
+ const multiState = {
+ ...baseState,
+ choices: [
+ { id: 'choice_a', content: 'Option A', correct: true, fixed: false },
+ { id: 'choice_b', content: 'Option B', correct: false, fixed: false },
+ ],
+ showAnswerCount: true,
+ };
+ const { bodyXml } = buildXML(multiState, QuestionType.MULTI_SELECT);
const root = parseXmlString(bodyXml);
expect(root.getAttribute('min-choices')).toBe('1');
});
- it('sets max-choices attribute from state', () => {
+ it('sets max-choices to 1 for single-select regardless of correct count', () => {
const { bodyXml } = buildXML(baseState, QuestionType.SINGLE_SELECT);
const root = parseXmlString(bodyXml);
expect(root.getAttribute('max-choices')).toBe('1');
});
+ it('emits explicit max-choices="0" for multi-select + showAnswerCount + zero correct answers', () => {
+ const noCorrectState = {
+ ...baseState,
+ choices: [
+ { id: 'choice_a', content: 'Option A', correct: false, fixed: false },
+ { id: 'choice_b', content: 'Option B', correct: false, fixed: false },
+ ],
+ showAnswerCount: true,
+ };
+ const { bodyXml } = buildXML(noCorrectState, QuestionType.MULTI_SELECT);
+ const root = parseXmlString(bodyXml);
+ expect(root.getAttribute('max-choices')).toBe('0');
+ });
+
it('renders one per choice with the correct identifier', () => {
const { bodyXml } = buildXML(baseState, QuestionType.SINGLE_SELECT);
const root = parseXmlString(bodyXml);
@@ -279,7 +307,7 @@ describe('parse → buildXML → parse round-trip', () => {
const { bodyXml, responseDeclarations } = buildXML(original, QuestionType.SINGLE_SELECT);
const reparsed = parse(bodyXml, responseDeclarations);
- expect(reparsed.maxChoices).toBe(original.maxChoices);
+ expect(reparsed.showAnswerCount).toBe(original.showAnswerCount);
expect(reparsed.shuffle).toBe(original.shuffle);
expect(reparsed.orientation).toBe(original.orientation);
expect(reparsed.choices.map(a => a.id)).toEqual(original.choices.map(a => a.id));
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js
index 1071c7caea..b333a3bee3 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js
@@ -17,8 +17,6 @@ function makeState(overrides = {}) {
makeAnswer({ id: 'choice_a', content: 'Four', correct: true }),
makeAnswer({ id: 'choice_b', content: 'Five', correct: false }),
],
- maxChoices: 1,
- minChoices: 0,
shuffle: false,
orientation: Orientation.VERTICAL,
...overrides,
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..b239dbc361
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/components/AnswerSettings/index.vue
@@ -0,0 +1,163 @@
+
+
+
+
+ {{ answerSettingsLabel$() }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ shuffleAnswersInfoBody$() }}
+
+
+
+
+
+ {{ showAnswerCountInfoBody$() }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js
index a2a52448c1..d9c83d4872 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js
@@ -3,7 +3,7 @@ import { getPromptHTML, parseXML } from '../../serialization/parseItem';
import { buildXmlNode } from '../../serialization/assembleItem';
import CorrectResponse from '../../serialization/qti/declarations/correctResponse';
import { generateRandomSlug } from '../../utils/generateRandomSlug';
-import { Orientation, RESPONSE_IDENTIFIER } from '../../constants';
+import { Orientation, QuestionType, RESPONSE_IDENTIFIER } from '../../constants';
/**
* @typedef {object} ChoiceAnswer
@@ -15,12 +15,11 @@ import { Orientation, RESPONSE_IDENTIFIER } from '../../constants';
/**
* @typedef {object} ChoiceState
- * @property {string} prompt - HTML content of ; default ""
- * @property {ChoiceAnswer[]} answers
- * @property {number} maxChoices - From max-choices attribute (0 = unlimited)
- * @property {number} minChoices - From min-choices attribute; default 0
- * @property {boolean} shuffle - From shuffle attribute; default false
- * @property {string} orientation - From orientation attribute; default "vertical"
+ * @property {string} prompt - HTML content of ; default ""
+ * @property {ChoiceAnswer[]} choices
+ * @property {boolean} showAnswerCount - true unless max-choices="0" in the source XML
+ * @property {boolean} shuffle - From shuffle attribute; default false
+ * @property {string} orientation - From orientation attribute; default "vertical"
*/
const serializer = new XMLSerializer();
@@ -30,8 +29,7 @@ export function _defaultState() {
responseIdentifier: RESPONSE_IDENTIFIER,
prompt: '',
choices: [{ id: generateRandomSlug('choice'), content: '', correct: false }],
- maxChoices: 1,
- minChoices: 0,
+ showAnswerCount: true,
shuffle: false,
orientation: Orientation.VERTICAL,
};
@@ -84,11 +82,10 @@ export function parseChoiceInteraction(bodyXml, responseDeclarations) {
}
const responseIdentifier = root.getAttribute('response-identifier') || RESPONSE_IDENTIFIER;
- const maxChoices = parseInt(root.getAttribute('max-choices') ?? '0', 10);
- const minChoices = parseInt(root.getAttribute('min-choices') ?? '0', 10);
const shuffle = root.getAttribute('shuffle') === 'true';
const orientation = root.getAttribute('orientation') ?? Orientation.VERTICAL;
const prompt = getPromptHTML(root);
+ const showAnswerCount = root.getAttribute('max-choices') !== '0';
const correctIds = _extractCorrectIds(responseDeclarations);
@@ -99,7 +96,14 @@ export function parseChoiceInteraction(bodyXml, responseDeclarations) {
fixed: el.getAttribute('fixed') === 'true',
}));
- return { responseIdentifier, prompt, choices, maxChoices, minChoices, shuffle, orientation };
+ return {
+ responseIdentifier,
+ prompt,
+ choices,
+ showAnswerCount,
+ shuffle,
+ orientation,
+ };
}
/**
@@ -115,19 +119,31 @@ export function buildChoiceInteractionXML(state, questionType, declarationSchema
responseIdentifier = RESPONSE_IDENTIFIER,
prompt,
choices,
- maxChoices,
- minChoices,
+ showAnswerCount = true,
shuffle,
orientation,
} = state;
+ const correctCount = choices.filter(c => c.correct).length;
+
+ let maxChoicesAttr;
+ let minChoicesAttr;
+ if (questionType === QuestionType.SINGLE_SELECT) {
+ maxChoicesAttr = 1;
+ } else if (!showAnswerCount) {
+ maxChoicesAttr = 0;
+ } else {
+ maxChoicesAttr = correctCount;
+ minChoicesAttr = correctCount;
+ }
+
const attrs = {
'response-identifier': responseIdentifier,
- 'max-choices': maxChoices,
+ 'max-choices': maxChoicesAttr,
shuffle: String(shuffle),
orientation,
};
- if (minChoices > 0) attrs['min-choices'] = minChoices;
+ if (minChoicesAttr > 0) attrs['min-choices'] = minChoicesAttr;
const children = [];
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..9ddc4aa727 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue
@@ -191,6 +191,7 @@
name: 'TextEntryEditor',
components: { TipTapEditor, ValidationMessage, AddListItemButton },
+ inheritAttrs: false,
setup(props, { emit }) {
const { windowIsSmall } = useKResponsiveWindow();
@@ -394,6 +395,7 @@
type: String,
default: null,
},
+
mode: {
type: String,
default: 'view',
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 2bec8b13c5..ad66909570 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.1.4",
"vuetify": "^1.5.24",
"vuex": "^3.0.1",
"workbox-core": "^7.4.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 24a5265eac..d370a65703 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.1.4
+ version: 1.1.4
vuetify:
specifier: ^1.5.24
version: 1.5.24(vue@2.7.16)