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..4f9edf856a 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)" /> @@ -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..a656cc91ee 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,12 @@ :mode="mode" :showAnswers="showAnswers" @update:questionType="type => (currentQuestionType = type)" + @update:interaction=" + ({ bodyXml, responseDeclarations }) => { + currentBodyXml.value = bodyXml; + currentResponseDeclarations.value = responseDeclarations; + } + " />
- 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 +77,7 @@
components: { InteractionSection },
- setup(props) {
+ setup(props, { emit }) {
const {
questionNumberLabel$,
questionNumberAndTypeLabel$,
@@ -79,7 +86,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 +123,33 @@
}),
);
+ /**
+ * Track the current bodyXml and responseDeclarations for the interaction.
+ * Initialised from the parsed item; updated atomically when the editor
+ * emits update:interaction.
+ */
+ 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 => {
+ emit('update:rawData', newVal);
+ });
+
return {
currentQuestionType,
interactions,
@@ -158,7 +192,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..dd8176c707
--- /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..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 = `
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..81d4649932 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js @@ -0,0 +1,167 @@ +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: '