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 }}

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