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..2aff6f8a7e 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,7 @@ :interaction="interaction" :mode="mode" :showAnswers="showAnswers" + @update:interaction="interaction => $emit('update:interaction', interaction)" /> @@ -30,8 +31,8 @@ name: 'InteractionSection', setup(props, { emit }) { - const bodyXmlRef = computed(() => props.interaction?.bodyXml); - const { descriptor, questionType, parseError } = useInteractionDescriptor(bodyXmlRef); + const interactionRef = computed(() => props.interaction); + const { descriptor, questionType, parseError } = useInteractionDescriptor(interactionRef); watch( questionType, @@ -67,7 +68,7 @@ }, }, - emits: ['update:questionType'], + emits: ['update:questionType', 'update:interaction'], }; 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..00d8eb76d2 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,7 @@ :mode="mode" :showAnswers="showAnswers" @update:questionType="type => (currentQuestionType = type)" + @update:interaction="onUpdateInteraction" />

- import { computed, ref } from 'vue'; + import { computed, ref, watch } from 'vue'; import { qtiEditorStrings } from '../../qtiEditorStrings'; import { QuestionType } from '../../constants'; import useQtiItem from '../../composables/useQtiItem'; + import { assembleItemXml } from '../../serialization/assembleItem'; import InteractionSection from '../InteractionSection/index.vue'; export default { @@ -70,7 +72,7 @@ components: { InteractionSection }, - setup(props) { + setup(props, { emit }) { const { questionNumberLabel$, questionNumberAndTypeLabel$, @@ -79,7 +81,7 @@ unknownTypeLabel$, } = qtiEditorStrings; - const { interactions } = useQtiItem(props.item.raw_data); + const { identifier, title, language, interactions } = useQtiItem(props.item.raw_data); const questionNumberLabel = computed(() => questionNumberLabel$({ @@ -116,6 +118,46 @@ }), ); + /** + * Track the current bodyXml and responseDeclarations for the interaction. + * Initialised from the parsed item; updated atomically when the editor + * emits update:interaction. + * + * Note: We are currently using only the first interaction, but this + * architecture is designed to be expansible to multiple interactions + * per question in the future. + */ + const currentBodyXml = ref( + interactions.value.length > 0 ? interactions.value[0].bodyXml : '', + ); + const currentResponseDeclarations = ref( + interactions.value.length > 0 ? interactions.value[0].responseDeclarations : [], + ); + + const rawData = computed(() => + assembleItemXml({ + identifier: identifier.value, + title: title.value, + language: language.value, + bodyXml: currentBodyXml.value, + responseDeclarations: currentResponseDeclarations.value, + }), + ); + + // Emit only when the assembled XML actually changes after initial mount + watch(rawData, newVal => { + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.log('[QTIItemEditor] assembled XML:\n', newVal); + } + emit('update:rawData', newVal); + }); + + function onUpdateInteraction({ bodyXml, responseDeclarations }) { + currentBodyXml.value = bodyXml; + currentResponseDeclarations.value = responseDeclarations; + } + return { currentQuestionType, interactions, @@ -123,6 +165,7 @@ questionNumberAndTypeLabel, closeBtnLabel$, questionContentPlaceholder$, + onUpdateInteraction, }; }, @@ -158,7 +201,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..58808bc72c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue @@ -0,0 +1,33 @@ + + + + + + + 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..dd66283d0f --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js @@ -0,0 +1,181 @@ +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(choices, questionType = QuestionType.SINGLE_SELECT) { + const maxChoices = questionType === QuestionType.SINGLE_SELECT ? 1 : 2; + const correctIds = choices.filter(a => a.correct).map(a => a.id); + + const bodyXml = ` + ${choices.map(a => `${a.content}`).join('\n ')} + `; + + const declaration = ` + + ${correctIds.map(id => `${id}`).join('')} + + `; + + return { bodyXml, responseDeclarations: [declaration] }; +} + +function setup(choices, questionType = QuestionType.SINGLE_SELECT) { + const qt = ref(questionType); + const block = makeBlock(choices, questionType); + return { qt, ...useChoiceInteraction(block, qt) }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useChoiceInteraction', () => { + describe('addChoice()', () => { + it('appends a new choice to the list', () => { + const { state, addChoice } = setup([ + makeAnswer({ id: 'a', content: 'A' }), + makeAnswer({ id: 'b', content: 'B' }), + ]); + addChoice(); + expect(state.value.choices).toHaveLength(3); + }); + + it('new choice has a generated "choice_" identifier', () => { + const { state, addChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + addChoice(); + const newAnswer = state.value.choices[2]; + expect(newAnswer.id).toMatch(/^choice_[a-z0-9]{8}$/); + }); + + it('new choice has empty content and correct: false', () => { + const { state, addChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + addChoice(); + const newAnswer = state.value.choices[2]; + expect(newAnswer.content).toBe(''); + expect(newAnswer.correct).toBe(false); + }); + }); + + describe('removeChoice()', () => { + it('removes the choice with the given id', () => { + const { state, removeChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + removeChoice('a'); + expect(state.value.choices.find(a => a.id === 'a')).toBeUndefined(); + }); + + it('is a no-op when only one choice remains', () => { + const { state, removeChoice } = setup([makeAnswer({ id: 'a' })]); + removeChoice('a'); + expect(state.value.choices).toHaveLength(1); + }); + }); + + describe('moveChoiceUp()', () => { + it('swaps choice with the previous one', () => { + const { state, moveChoiceUp } = setup([ + makeAnswer({ id: 'a' }), + makeAnswer({ id: 'b' }), + makeAnswer({ id: 'c' }), + ]); + moveChoiceUp('b'); + expect(state.value.choices.map(a => a.id)).toEqual(['b', 'a', 'c']); + }); + + it('is a no-op when the choice is first', () => { + const { state, moveChoiceUp } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + moveChoiceUp('a'); + expect(state.value.choices.map(a => a.id)).toEqual(['a', 'b']); + }); + }); + + describe('moveChoiceDown()', () => { + it('swaps choice with the next one', () => { + const { state, moveChoiceDown } = setup([ + makeAnswer({ id: 'a' }), + makeAnswer({ id: 'b' }), + makeAnswer({ id: 'c' }), + ]); + moveChoiceDown('b'); + expect(state.value.choices.map(a => a.id)).toEqual(['a', 'c', 'b']); + }); + + it('is a no-op when the choice is last', () => { + const { state, moveChoiceDown } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + moveChoiceDown('b'); + expect(state.value.choices.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.choices.find(a => a.id === 'b').correct).toBe(true); + expect(state.value.choices.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.choices.find(a => a.id === 'b').correct).toBe(true); + expect(state.value.choices.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.choices.find(a => a.id === 'a').correct).toBe(false); + expect(state.value.choices.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 choice only', () => { + const { state, setChoiceContent } = setup([ + makeAnswer({ id: 'a', content: 'Old' }), + makeAnswer({ id: 'b', content: 'Unchanged' }), + ]); + setChoiceContent('a', 'New content'); + expect(state.value.choices.find(a => a.id === 'a').content).toBe('New content'); + expect(state.value.choices.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); + }); + }); +}); 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..f9619c45f2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js @@ -0,0 +1,169 @@ +import { ref, nextTick } 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: '', responseDeclarations: [''] }, + ), + 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 responseDeclarations are computed from buildXML', () => { + const descriptor = makeDescriptor({ + buildReturn: { bodyXml: '', responseDeclarations: ['', ''] }, + }); + const questionType = ref('singleSelect'); + + const { bodyXml, responseDeclarations } = useInteraction( + descriptor, + { bodyXml: '', responseDeclarations: [] }, + questionType, + ); + + expect(bodyXml.value).toBe(''); + expect(responseDeclarations.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}`, responseDeclarations: [] })), + 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'); + }); + + it('automatically runs validation (debounced) when state changes', async () => { + jest.useFakeTimers(); + const validateReturn = [{ code: 'SOME_ERROR' }]; + const descriptor = makeDescriptor({ validateReturn }); + const questionType = ref('singleSelect'); + + const { state, errors } = useInteraction( + descriptor, + { bodyXml: '', responseDeclarations: [] }, + questionType, + ); + + expect(errors.value).toEqual([]); + state.value = { prompt: 'updated' }; + await nextTick(); // flush Vue watcher queue + + // Before the debounce fires, errors should still be empty. + expect(errors.value).toEqual([]); + + // Fast-forward past the 400 ms debounce window. + jest.advanceTimersByTime(400); + + expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'singleSelect'); + expect(errors.value).toEqual(validateReturn); + + jest.useRealTimers(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js index 140ca8ad2c..7b34f003a0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js @@ -16,20 +16,22 @@ import { // for the component to mount before checking reactive values. // --------------------------------------------------------------------------- -function renderDescriptor(initialXml = null) { - const bodyXmlRef = ref(initialXml); +function renderDescriptor(initialXml = null, declarations = []) { + const interactionRef = ref( + initialXml ? { bodyXml: initialXml, responseDeclarations: declarations } : null, + ); let result; const TestWrapper = defineComponent({ setup() { - result = useInteractionDescriptor(bodyXmlRef); + result = useInteractionDescriptor(interactionRef); return {}; }, template: '
', }); render(TestWrapper, { routes: new VueRouter() }); - return { result, bodyXmlRef }; + return { result, interactionRef }; } // --------------------------------------------------------------------------- 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..661f902e7e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js @@ -0,0 +1,108 @@ +import { readonly } from 'vue'; +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) { + const base = useInteraction(choiceInteractionDescriptor, interactionBlock, questionType); + const { state } = base; + + // --------------------------------------------------------------------------- + // Structural mutations + // --------------------------------------------------------------------------- + + function addChoice() { + state.value = { + ...state.value, + choices: [ + ...state.value.choices, + { id: generateRandomSlug('choice'), content: '', correct: false }, + ], + }; + } + + function removeChoice(id) { + if (state.value.choices.length <= 1) return; + state.value = { + ...state.value, + choices: state.value.choices.filter(a => a.id !== id), + }; + } + + function moveChoiceUp(id) { + const choices = [...state.value.choices]; + const idx = choices.findIndex(a => a.id === id); + if (idx <= 0) return; + [choices[idx - 1], choices[idx]] = [choices[idx], choices[idx - 1]]; + state.value = { ...state.value, choices }; + } + + function moveChoiceDown(id) { + const choices = [...state.value.choices]; + const idx = choices.findIndex(a => a.id === id); + if (idx === -1 || idx >= choices.length - 1) return; + [choices[idx], choices[idx + 1]] = [choices[idx + 1], choices[idx]]; + state.value = { ...state.value, choices }; + } + + /** + * Toggle the correct flag for a single choice. + * + * singleSelect: clears all others and sets only the target to correct. + * multiSelect: toggles only the target choice's correct field. + */ + function toggleCorrectChoice(id) { + state.value = { + ...state.value, + choices: state.value.choices.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, + choices: state.value.choices.map(a => (a.id === id ? { ...a, content: html } : a)), + }; + } + + function setShuffle(val) { + state.value = { ...state.value, shuffle: val }; + } + + return { + ...base, + state: readonly(state), + addChoice, + removeChoice, + moveChoiceUp, + moveChoiceDown, + toggleCorrectChoice, + setPrompt, + setChoiceContent, + setShuffle, + }; +} 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..f9c9d64691 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js @@ -0,0 +1,63 @@ +import { ref, computed, watch, onUnmounted } from 'vue'; +import debounce from 'lodash/debounce'; + +/** + * 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. + * + * Validation runs immediately when called explicitly (e.g. when closing a + * panel), but is debounced when triggered by state changes so that errors + * only appear after the user pauses typing (400 ms), avoiding noisy + * inline error flicker on every keystroke. + * + * @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, + * responseDeclarations: 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 interaction = computed(() => { + if (!questionType.value) return { bodyXml: '', responseDeclarations: [] }; + return descriptor.buildXML(state.value, questionType.value); + }); + + const bodyXml = computed(() => interaction.value.bodyXml); + const responseDeclarations = computed(() => interaction.value.responseDeclarations); + + const errors = ref([]); + + /** Immediately validates and updates errors. Use this for explicit triggers (e.g. close). */ + function runValidation() { + errors.value = descriptor.validate(state.value, questionType.value); + } + + /** + * Debounced version used by the state watcher — waits 400 ms after the user + * stops typing before showing inline errors. + */ + const debouncedValidation = debounce(runValidation, 400); + + // Cancel any pending debounce when the component is torn down. + onUnmounted(() => debouncedValidation.cancel()); + + watch([state, questionType], debouncedValidation, { deep: true, immediate: true }); + + return { state, bodyXml, responseDeclarations, errors, runValidation }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js index 0c55db6a05..34e3e960cc 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js @@ -6,17 +6,10 @@ import { descriptors, registry, DEFAULT_INTERACTION } from '../interactions/inde * Composable that manages the question type and descriptor for a single * interaction block. * - * Design rationale (three separate type concepts): - * - The XML is parsed **once on mount** to infer the initial questionType. - * - `questionType` is a writable `ref` so the type-selector UI can change it - * without triggering a re-parse of the XML. - * - `descriptor` is a `computed` derived from `questionType`, using each - * descriptor's `questionTypes` array for the lookup. This means the - * rendered editor component reacts to user selections, not to XML changes. - * - * @param {import('vue').Ref} bodyXmlRef Ref to interaction bodyXml string. + * @param {import('vue').Ref} interactionRef + * Ref to the interaction block { bodyXml, responseDeclarations }. */ -export default function useInteractionDescriptor(bodyXmlRef) { +export default function useInteractionDescriptor(interactionRef) { /** Writable question type — set from XML on mount, then driven by UI selections. */ const questionType = ref(null); /** Any parse error message from the initial XML parse; null when clean. */ @@ -26,7 +19,7 @@ export default function useInteractionDescriptor(bodyXmlRef) { * Parses bodyXml and returns { descriptor, questionType } without touching * any reactive state — pure helper used only on mount. */ - function inferFromXml(xml) { + function inferFromXml(xml, declarations) { if (!xml) { return { descriptor: registry[DEFAULT_INTERACTION], questionType: null, error: null }; } @@ -36,7 +29,7 @@ export default function useInteractionDescriptor(bodyXmlRef) { const desc = descriptors.find(d => d.matches(interactionEl)) ?? registry[DEFAULT_INTERACTION]; return { descriptor: desc, - questionType: desc.getQuestionType(interactionEl) ?? null, + questionType: desc.getQuestionType(interactionEl, declarations) ?? null, error: null, }; } catch (e) { @@ -50,7 +43,10 @@ export default function useInteractionDescriptor(bodyXmlRef) { /** Parse XML once when the host component mounts to set the initial state. */ onMounted(() => { - const inferred = inferFromXml(bodyXmlRef.value); + const inferred = inferFromXml( + interactionRef.value?.bodyXml, + interactionRef.value?.responseDeclarations, + ); questionType.value = inferred.questionType; parseError.value = inferred.error; }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index 2dca90936c..459e58ec03 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -19,6 +19,11 @@ export const BaseType = Object.freeze({ URI: 'uri', }); +export const Orientation = Object.freeze({ + VERTICAL: 'vertical', + HORIZONTAL: 'horizontal', +}); + /** * There are three distinct type concepts used within the QTI architecture: * @@ -73,3 +78,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', + DUPLICATE_CHOICE_CONTENT: 'DUPLICATE_CHOICE_CONTENT', +}); 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)" >