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 9261242d32..c5f008646e 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 @@ -9,6 +9,8 @@ import { mockInteractionBlock as interactionBlock, } from '../../../utils/testingFixtures'; +jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); + const renderSection = (props = {}) => render(InteractionSection, { props: { mode: 'edit', ...props }, 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 ee9137e245..4bf3efb30f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue @@ -15,6 +15,8 @@ :interaction="interaction" :mode="mode" :showAnswers="showAnswers" + @update:bodyXml="xml => $emit('update:bodyXml', xml)" + @update:responseDeclarations="decls => $emit('update:responseDeclarations', decls)" /> @@ -67,7 +69,7 @@ }, }, - emits: ['update:questionType'], + emits: ['update:questionType', 'update:bodyXml', 'update:responseDeclarations'], }; 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 e21b5139f1..27f4748601 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -33,6 +33,8 @@ :mode="mode" :showAnswers="showAnswers" @update:questionType="type => (currentQuestionType = type)" + @update:bodyXml="onBodyXmlUpdate" + @update:responseDeclarations="onResponseDeclarationsUpdate" />

questionNumberLabel$({ @@ -116,6 +119,38 @@ }), ); + /** + * Track the current bodyXml and responseDeclarations for the interaction. + * Initialized from the parsed item; updated when the editor emits changes. + */ + const currentBodyXml = ref( + interactions.value.length > 0 ? interactions.value[0].bodyXml : '', + ); + const currentResponseDeclarations = ref( + interactions.value.length > 0 ? interactions.value[0].responseDeclarations : [], + ); + + function onBodyXmlUpdate(xml) { + currentBodyXml.value = xml; + emitRawData(); + } + + function onResponseDeclarationsUpdate(decls) { + currentResponseDeclarations.value = decls; + emitRawData(); + } + + function emitRawData() { + const newRawData = reassembleItemXml({ + identifier: identifier.value, + title: title.value, + language: language.value, + bodyXml: currentBodyXml.value, + responseDeclarations: currentResponseDeclarations.value, + }); + emit('update:rawData', newRawData); + } + return { currentQuestionType, interactions, @@ -123,6 +158,8 @@ questionNumberAndTypeLabel, closeBtnLabel$, questionContentPlaceholder$, + onBodyXmlUpdate, + onResponseDeclarationsUpdate, }; }, @@ -158,7 +195,7 @@ }, }, - emits: ['close'], + emits: ['close', 'update:rawData'], }; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue new file mode 100644 index 0000000000..dc98110055 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue @@ -0,0 +1,40 @@ + + + + + + + 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 new file mode 100644 index 0000000000..0b48c4d064 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js @@ -0,0 +1,197 @@ +import { ref } from 'vue'; +import { useChoiceInteraction } from '../useChoiceInteraction'; +import { QuestionType } from '../../constants'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeAnswer(overrides = {}) { + return { id: 'choice_a', content: 'A', correct: false, fixed: false, ...overrides }; +} + +function makeBlock(answers, questionType = QuestionType.SINGLE_SELECT) { + const maxChoices = questionType === QuestionType.SINGLE_SELECT ? 1 : 2; + const correctIds = answers.filter(a => a.correct).map(a => a.id); + + const bodyXml = ` + ${answers.map(a => `${a.content}`).join('\n ')} + `; + + const declaration = ` + + ${correctIds.map(id => `${id}`).join('')} + + `; + + return { bodyXml, responseDeclarations: [declaration] }; +} + +function setup(answers, questionType = QuestionType.SINGLE_SELECT) { + const qt = ref(questionType); + const block = makeBlock(answers, questionType); + return { qt, ...useChoiceInteraction(block, qt) }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useChoiceInteraction', () => { + describe('addChoice()', () => { + it('appends a new answer to the list', () => { + const { state, addChoice } = setup([ + makeAnswer({ id: 'a', content: 'A' }), + makeAnswer({ id: 'b', content: 'B' }), + ]); + addChoice(); + expect(state.value.answers).toHaveLength(3); + }); + + it('new answer has a generated "choice_" identifier', () => { + const { state, addChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + addChoice(); + const newAnswer = state.value.answers[2]; + expect(newAnswer.id).toMatch(/^choice_[a-z0-9]{8}$/); + }); + + it('new answer has empty content and correct: false', () => { + const { state, addChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + addChoice(); + const newAnswer = state.value.answers[2]; + expect(newAnswer.content).toBe(''); + expect(newAnswer.correct).toBe(false); + }); + }); + + describe('removeChoice()', () => { + it('removes the answer with the given id', () => { + const { state, removeChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + removeChoice('a'); + expect(state.value.answers.find(a => a.id === 'a')).toBeUndefined(); + }); + + it('is a no-op when only one answer remains', () => { + const { state, removeChoice } = setup([makeAnswer({ id: 'a' })]); + removeChoice('a'); + expect(state.value.answers).toHaveLength(1); + }); + }); + + describe('moveChoiceUp()', () => { + it('swaps answer with the previous one', () => { + const { state, moveChoiceUp } = setup([ + makeAnswer({ id: 'a' }), + makeAnswer({ id: 'b' }), + makeAnswer({ id: 'c' }), + ]); + moveChoiceUp('b'); + expect(state.value.answers.map(a => a.id)).toEqual(['b', 'a', 'c']); + }); + + it('is a no-op when the answer is first', () => { + const { state, moveChoiceUp } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + moveChoiceUp('a'); + expect(state.value.answers.map(a => a.id)).toEqual(['a', 'b']); + }); + }); + + describe('moveChoiceDown()', () => { + it('swaps answer with the next one', () => { + const { state, moveChoiceDown } = setup([ + makeAnswer({ id: 'a' }), + makeAnswer({ id: 'b' }), + makeAnswer({ id: 'c' }), + ]); + moveChoiceDown('b'); + expect(state.value.answers.map(a => a.id)).toEqual(['a', 'c', 'b']); + }); + + it('is a no-op when the answer is last', () => { + const { state, moveChoiceDown } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + moveChoiceDown('b'); + expect(state.value.answers.map(a => a.id)).toEqual(['a', 'b']); + }); + }); + + describe('toggleCorrectChoice()', () => { + it('singleSelect: sets only the target as correct and clears others', () => { + const { state, toggleCorrectChoice, qt } = setup([ + makeAnswer({ id: 'a', correct: true }), + makeAnswer({ id: 'b', correct: false }), + ]); + qt.value = QuestionType.SINGLE_SELECT; + toggleCorrectChoice('b'); + expect(state.value.answers.find(a => a.id === 'b').correct).toBe(true); + expect(state.value.answers.find(a => a.id === 'a').correct).toBe(false); + }); + + it('multiSelect: toggles only the target, leaves others unchanged', () => { + const { state, toggleCorrectChoice, qt } = setup( + [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false })], + QuestionType.MULTI_SELECT, + ); + qt.value = QuestionType.MULTI_SELECT; + toggleCorrectChoice('b'); + expect(state.value.answers.find(a => a.id === 'b').correct).toBe(true); + expect(state.value.answers.find(a => a.id === 'a').correct).toBe(true); + }); + + it('multiSelect: toggles correct off when already correct', () => { + const { state, toggleCorrectChoice, qt } = setup( + [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })], + QuestionType.MULTI_SELECT, + ); + qt.value = QuestionType.MULTI_SELECT; + toggleCorrectChoice('a'); + expect(state.value.answers.find(a => a.id === 'a').correct).toBe(false); + expect(state.value.answers.find(a => a.id === 'b').correct).toBe(true); + }); + }); + + describe('setPrompt()', () => { + it('updates the prompt field', () => { + const { state, setPrompt } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + setPrompt('

New prompt

'); + expect(state.value.prompt).toBe('

New prompt

'); + }); + }); + + describe('setChoiceContent()', () => { + it('updates content for the target answer only', () => { + const { state, setChoiceContent } = setup([ + makeAnswer({ id: 'a', content: 'Old' }), + makeAnswer({ id: 'b', content: 'Unchanged' }), + ]); + setChoiceContent('a', 'New content'); + expect(state.value.answers.find(a => a.id === 'a').content).toBe('New content'); + expect(state.value.answers.find(a => a.id === 'b').content).toBe('Unchanged'); + }); + }); + + describe('setShuffle()', () => { + it('updates the shuffle flag', () => { + const { state, setShuffle } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + setShuffle(true); + expect(state.value.shuffle).toBe(true); + }); + }); + + describe('setOrientation()', () => { + it('updates the orientation field', () => { + const { state, setOrientation } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + setOrientation('horizontal'); + expect(state.value.orientation).toBe('horizontal'); + }); + }); + + describe('setMaxChoices()', () => { + it('updates the maxChoices field', () => { + const { state, setMaxChoices } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + setMaxChoices(3); + expect(state.value.maxChoices).toBe(3); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js new file mode 100644 index 0000000000..0196a9c215 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js @@ -0,0 +1,139 @@ +import { ref } from 'vue'; +import { useInteraction } from '../useInteraction'; + +// --------------------------------------------------------------------------- +// Minimal descriptor stub +// --------------------------------------------------------------------------- + +function makeDescriptor({ parseReturn = {}, buildReturn = null, validateReturn = [] } = {}) { + return { + parse: jest.fn(() => parseReturn), + buildXML: jest.fn(() => buildReturn ?? { bodyXml: '', declarations: [''] }), + validate: jest.fn(() => validateReturn), + }; +} + +describe('useInteraction', () => { + it('calls descriptor.parse once with the interaction block on creation', () => { + const descriptor = makeDescriptor(); + const block = { bodyXml: '', responseDeclarations: [''] }; + const questionType = ref('singleSelect'); + + useInteraction(descriptor, block, questionType); + + expect(descriptor.parse).toHaveBeenCalledTimes(1); + expect(descriptor.parse).toHaveBeenCalledWith(block.bodyXml, block.responseDeclarations); + }); + + it('exposes initial parsed state as a reactive ref', () => { + const parseReturn = { prompt: 'Hello', answers: [] }; + const descriptor = makeDescriptor({ parseReturn }); + const questionType = ref('singleSelect'); + + const { state } = useInteraction( + descriptor, + { bodyXml: '', responseDeclarations: [] }, + questionType, + ); + + expect(state.value).toEqual(parseReturn); + }); + + it('bodyXml and declarations are computed from buildXML', () => { + const descriptor = makeDescriptor({ + buildReturn: { bodyXml: '', declarations: ['', ''] }, + }); + const questionType = ref('singleSelect'); + + const { bodyXml, declarations } = useInteraction( + descriptor, + { bodyXml: '', responseDeclarations: [] }, + questionType, + ); + + expect(bodyXml.value).toBe(''); + expect(declarations.value).toEqual(['', '']); + }); + + it('errors starts as an empty array', () => { + const descriptor = makeDescriptor(); + const questionType = ref('singleSelect'); + + const { errors } = useInteraction( + descriptor, + { bodyXml: '', responseDeclarations: [] }, + questionType, + ); + + expect(errors.value).toEqual([]); + }); + + it('runValidation populates errors from descriptor.validate', () => { + const validateReturn = [{ code: 'PROMPT_REQUIRED' }]; + const descriptor = makeDescriptor({ validateReturn }); + const questionType = ref('singleSelect'); + + const { errors, runValidation } = useInteraction( + descriptor, + { bodyXml: '', responseDeclarations: [] }, + questionType, + ); + + expect(errors.value).toEqual([]); + runValidation(); + expect(errors.value).toEqual(validateReturn); + }); + + it('runValidation passes current state and questionType to validate', () => { + const descriptor = makeDescriptor(); + const questionType = ref('singleSelect'); + const block = { bodyXml: '', responseDeclarations: [] }; + + const { state, runValidation } = useInteraction(descriptor, block, questionType); + state.value = { prompt: 'updated' }; + questionType.value = 'multiSelect'; + runValidation(); + + expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'multiSelect'); + }); + + it('bodyXml recomputes when state changes', () => { + let callCount = 0; + const descriptor = { + parse: jest.fn(() => ({ prompt: '' })), + buildXML: jest.fn(() => ({ bodyXml: `call-${++callCount}`, declarations: [] })), + validate: jest.fn(() => []), + }; + const questionType = ref('singleSelect'); + + const { state, bodyXml } = useInteraction( + descriptor, + { bodyXml: '', responseDeclarations: [] }, + questionType, + ); + + const first = bodyXml.value; + state.value = { prompt: 'changed' }; + const second = bodyXml.value; + + expect(first).not.toBe(second); + }); + + it('bodyXml recomputes when questionType changes', () => { + const descriptor = makeDescriptor(); + const questionType = ref('singleSelect'); + + const { bodyXml } = useInteraction( + descriptor, + { bodyXml: '', responseDeclarations: [] }, + questionType, + ); + + bodyXml.value; // trigger initial compute + questionType.value = 'multiSelect'; + bodyXml.value; // trigger recompute + + expect(descriptor.buildXML).toHaveBeenCalledTimes(2); + expect(descriptor.buildXML).toHaveBeenLastCalledWith(expect.anything(), 'multiSelect'); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js new file mode 100644 index 0000000000..8f365dbb88 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js @@ -0,0 +1,118 @@ +import { QuestionType } from '../constants'; +import { generateRandomSlug } from '../utils/generateRandomSlug'; +import { choiceInteractionDescriptor } from '../interactions/choice/ChoiceInteractionDescriptor'; +import { useInteraction } from './useInteraction'; + +/** + * Composable for the choice interaction editor. + * + * Extends useInteraction with all mutation methods needed by ChoiceEditor.vue. + * State mutations always produce a new array reference so Vue's computed + * dependencies invalidate correctly. + * + * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock + * @param {import('vue').Ref} questionType + */ +export function useChoiceInteraction(interactionBlock, questionType) { + // Use the import-safe descriptor core — it owns parse/buildXML/validate schema + // without depending on choice/index.js or ChoiceInteractionEditor.vue. + const base = useInteraction(choiceInteractionDescriptor, interactionBlock, questionType); + const { state } = base; + + // --------------------------------------------------------------------------- + // Structural mutations + // --------------------------------------------------------------------------- + + function addChoice() { + state.value = { + ...state.value, + answers: [ + ...state.value.answers, + { id: generateRandomSlug('choice'), content: '', correct: false, fixed: false }, + ], + }; + } + + function removeChoice(id) { + if (state.value.answers.length <= 1) return; + state.value = { + ...state.value, + answers: state.value.answers.filter(a => a.id !== id), + }; + } + + function moveChoiceUp(id) { + const answers = [...state.value.answers]; + const idx = answers.findIndex(a => a.id === id); + if (idx <= 0) return; + [answers[idx - 1], answers[idx]] = [answers[idx], answers[idx - 1]]; + state.value = { ...state.value, answers }; + } + + function moveChoiceDown(id) { + const answers = [...state.value.answers]; + const idx = answers.findIndex(a => a.id === id); + if (idx === -1 || idx >= answers.length - 1) return; + [answers[idx], answers[idx + 1]] = [answers[idx + 1], answers[idx]]; + state.value = { ...state.value, answers }; + } + + /** + * Toggle the correct flag for a single choice. + * + * singleSelect: clears all others and sets only the target to correct. + * multiSelect: toggles only the target answer's correct field. + */ + function toggleCorrectChoice(id) { + state.value = { + ...state.value, + answers: state.value.answers.map(a => { + if (questionType.value === QuestionType.SINGLE_SELECT) { + return { ...a, correct: a.id === id }; + } + return a.id === id ? { ...a, correct: !a.correct } : a; + }), + }; + } + + // --------------------------------------------------------------------------- + // Field mutations + // --------------------------------------------------------------------------- + + function setPrompt(html) { + state.value = { ...state.value, prompt: html }; + } + + function setChoiceContent(id, html) { + state.value = { + ...state.value, + answers: state.value.answers.map(a => (a.id === id ? { ...a, content: html } : a)), + }; + } + + function setShuffle(val) { + state.value = { ...state.value, shuffle: val }; + } + + function setOrientation(val) { + state.value = { ...state.value, orientation: val }; + } + + function setMaxChoices(n) { + state.value = { ...state.value, maxChoices: n }; + } + + return { + ...base, + addChoice, + removeChoice, + moveChoiceUp, + moveChoiceDown, + toggleCorrectChoice, + setPrompt, + setChoiceContent, + setShuffle, + setOrientation, + setMaxChoices, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js new file mode 100644 index 0000000000..6259490dc6 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js @@ -0,0 +1,46 @@ +import { ref, computed } from 'vue'; + +/** + * Base composable for all interaction editors. + * + * Handles the parse → state → buildXML → validate lifecycle that every + * interaction plugin must go through. Individual interaction composables + * (e.g. useChoiceInteraction) call this and add mutation methods on top. + * + * @param {import('../interactions/defineInteraction').InteractionDescriptor} descriptor + * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock + * @param {import('vue').Ref} questionType + * @returns {{ + * state: import('vue').Ref, + * bodyXml: import('vue').ComputedRef, + * declarations: import('vue').ComputedRef, + * errors: import('vue').Ref>, + * runValidation: () => void, + * }} + */ +export function useInteraction(descriptor, interactionBlock, questionType) { + const initialState = descriptor.parse( + interactionBlock.bodyXml, + interactionBlock.responseDeclarations, + ); + + const state = ref(initialState); + + // Rebuild XML whenever state or questionType changes. + const built = computed(() => { + if (!questionType.value) return { bodyXml: '', declarations: [] }; + return descriptor.buildXML(state.value, questionType.value); + }); + + const bodyXml = computed(() => built.value.bodyXml); + const declarations = computed(() => built.value.declarations); + + // Errors start empty — never shown automatically so authors aren't startled on first load. + const errors = ref([]); + + function runValidation() { + errors.value = descriptor.validate(state.value, questionType.value); + } + + return { state, bodyXml, declarations, errors, runValidation }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index 2dca90936c..4169f4f783 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -73,3 +73,16 @@ export const QuestionType = Object.freeze({ SINGLE_SELECT: 'singleSelect', MULTI_SELECT: 'multiSelect', }); + +/** + * Error codes returned by each interaction's validate() function. + * Interaction-agnostic codes live here; interaction-specific codes may extend + * this set in their own validate.js module. + */ +export const ValidationError = Object.freeze({ + PROMPT_REQUIRED: 'PROMPT_REQUIRED', + NO_CORRECT_ANSWER: 'NO_CORRECT_ANSWER', + TOO_MANY_CORRECT_ANSWERS: 'TOO_MANY_CORRECT_ANSWERS', + EMPTY_CHOICE_CONTENT: 'EMPTY_CHOICE_CONTENT', + TOO_FEW_CHOICES: 'TOO_FEW_CHOICES', +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue index 34f4d58f92..cc9b2af339 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue @@ -29,6 +29,7 @@ :showAnswers="showAnswers" data-testid="item" @close="closeItem" + @update:rawData="newXml => updateItemRawData(item.assessment_id, newXml)" >