Skip to content

Commit 7ef2d2c

Browse files
refactor: inject editor component during definition and add XML re-assembly to useQtiItem
Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
1 parent fb88661 commit 7ef2d2c

5 files changed

Lines changed: 92 additions & 52 deletions

File tree

contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@
6464
import { qtiEditorStrings } from '../../qtiEditorStrings';
6565
import { QuestionType } from '../../constants';
6666
import useQtiItem from '../../composables/useQtiItem';
67-
import { assembleItemXml } from '../../serialization/assembleItem';
6867
import InteractionSection from '../InteractionSection/index.vue';
6968
7069
export default {
@@ -81,7 +80,28 @@
8180
unknownTypeLabel$,
8281
} = qtiEditorStrings;
8382
84-
const { identifier, title, language, interactions } = useQtiItem(props.item.raw_data);
83+
/**
84+
* Track the current bodyXml and responseDeclarations for the interaction.
85+
* Initialised after parsing; updated atomically when the editor emits
86+
* update:interaction. Declared before useQtiItem so they can be passed in
87+
* and observed by the rawData computed inside the composable.
88+
*/
89+
const currentBodyXml = ref('');
90+
const currentResponseDeclarations = ref([]);
91+
92+
// Parse the item XML. rawData is a computed inside useQtiItem that
93+
// re-assembles the full XML whenever identifier/title/language or the
94+
// editor refs change — no need to duplicate assembleItemXml here.
95+
const { interactions, rawData } = useQtiItem(props.item.raw_data, {
96+
bodyXml: currentBodyXml,
97+
responseDeclarations: currentResponseDeclarations,
98+
});
99+
100+
// Seed the editor refs from the parsed interactions (first interaction only).
101+
if (interactions.value.length > 0) {
102+
currentBodyXml.value = interactions.value[0].bodyXml;
103+
currentResponseDeclarations.value = interactions.value[0].responseDeclarations;
104+
}
85105
86106
const questionNumberLabel = computed(() =>
87107
questionNumberLabel$({
@@ -118,33 +138,7 @@
118138
}),
119139
);
120140
121-
/**
122-
* Track the current bodyXml and responseDeclarations for the interaction.
123-
* Initialised from the parsed item; updated atomically when the editor
124-
* emits update:interaction.
125-
*
126-
* Note: We are currently using only the first interaction, but this
127-
* architecture is designed to be expansible to multiple interactions
128-
* per question in the future.
129-
*/
130-
const currentBodyXml = ref(
131-
interactions.value.length > 0 ? interactions.value[0].bodyXml : '',
132-
);
133-
const currentResponseDeclarations = ref(
134-
interactions.value.length > 0 ? interactions.value[0].responseDeclarations : [],
135-
);
136-
137-
const rawData = computed(() =>
138-
assembleItemXml({
139-
identifier: identifier.value,
140-
title: title.value,
141-
language: language.value,
142-
bodyXml: currentBodyXml.value,
143-
responseDeclarations: currentResponseDeclarations.value,
144-
}),
145-
);
146-
147-
// Emit only when the assembled XML actually changes after initial mount
141+
// Emit only when the assembled XML actually changes after initial mount.
148142
watch(rawData, newVal => {
149143
if (process.env.NODE_ENV === 'development') {
150144
// eslint-disable-next-line no-console
Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,37 @@
1-
import { ref } from 'vue';
1+
import { ref, computed } from 'vue';
22
import { parseItem } from '../serialization/parseItem';
3+
import { assembleItemXml } from '../serialization/assembleItem';
34

45
/**
56
* Composable that parses a raw QTI XML string once and exposes the
67
* structured item model as reactive refs.
78
*
8-
* Scope: read / parse only. No dirty tracking, no XML assembly, no emit-up.
9+
* Optionally accepts reactive `bodyXml` and `responseDeclarations` refs
10+
* (owned by the editor) and exposes a `rawData` computed that re-assembles
11+
* the full item XML whenever any of the five values change.
912
*
1013
* @param {string | null | undefined} rawData - Raw QTI XML string from item.raw_data
14+
* @param {{ bodyXml?: import('vue').Ref<string>,
15+
* responseDeclarations?: import('vue').Ref<string[]> }} [editorRefs]
1116
* @returns {{
1217
* identifier: import('vue').Ref<string>,
1318
* title: import('vue').Ref<string>,
1419
* language: import('vue').Ref<string>,
1520
* interactions: import('vue').Ref<Array<{ bodyXml: string, responseDeclarations: string[] }>>,
1621
* parseError: import('vue').Ref<string | null>,
22+
* rawData: import('vue').ComputedRef<string>,
1723
* }}
1824
*/
19-
export default function useQtiItem(rawData) {
25+
export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = {}) {
2026
const identifier = ref('');
2127
const title = ref('');
2228
const language = ref('');
2329
const interactions = ref([]);
2430
const parseError = ref(null);
2531

26-
if (rawData) {
32+
if (rawXml) {
2733
try {
28-
const model = parseItem(rawData);
34+
const model = parseItem(rawXml);
2935
identifier.value = model.identifier;
3036
title.value = model.title;
3137
language.value = model.language;
@@ -35,5 +41,20 @@ export default function useQtiItem(rawData) {
3541
}
3642
}
3743

38-
return { identifier, title, language, interactions, parseError };
44+
/**
45+
* Re-assembles the full QTI item XML whenever identifier, title, language,
46+
* bodyXml, or responseDeclarations change. Only available when the caller
47+
* passes in bodyXml and responseDeclarations refs.
48+
*/
49+
const rawData = computed(() =>
50+
assembleItemXml({
51+
identifier: identifier.value,
52+
title: title.value,
53+
language: language.value,
54+
bodyXml: bodyXml?.value ?? '',
55+
responseDeclarations: responseDeclarations?.value ?? [],
56+
}),
57+
);
58+
59+
return { identifier, title, language, interactions, parseError, rawData };
3960
}
Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import defineInteraction from '../defineInteraction';
22

3-
// A minimal valid descriptor with all required keys present.
3+
// A minimal valid descriptor with all required keys except editorComponent,
4+
// which is now always supplied as the second argument to defineInteraction.
45
const makeValidDescriptor = (overrides = {}) => ({
56
type: 'test',
67
placement: 'block',
78
questionTypes: [],
8-
editorComponent: {},
99
convertsFrom: [],
1010
matches: () => false,
1111
getQuestionType: () => null,
@@ -16,17 +16,27 @@ const makeValidDescriptor = (overrides = {}) => ({
1616
...overrides,
1717
});
1818

19+
const STUB_COMPONENT = {};
20+
1921
describe('defineInteraction', () => {
2022
it('returns the descriptor unchanged when all required keys are present', () => {
2123
const descriptor = makeValidDescriptor();
22-
expect(defineInteraction(descriptor)).toBe(descriptor);
24+
expect(defineInteraction(descriptor, STUB_COMPONENT)).toBe(descriptor);
25+
});
26+
27+
it('attaches the editorComponent from the second argument onto the descriptor', () => {
28+
const descriptor = makeValidDescriptor();
29+
const component = { name: 'MyEditor' };
30+
defineInteraction(descriptor, component);
31+
expect(descriptor.editorComponent).toBe(component);
2332
});
2433

25-
const REQUIRED_KEYS = [
34+
// Keys that must be present on the descriptor itself (editorComponent is injected
35+
// by defineInteraction from the second argument, so it is excluded here).
36+
const REQUIRED_DESCRIPTOR_KEYS = [
2637
'type',
2738
'placement',
2839
'questionTypes',
29-
'editorComponent',
3040
'convertsFrom',
3141
'matches',
3242
'getQuestionType',
@@ -36,24 +46,30 @@ describe('defineInteraction', () => {
3646
'validate',
3747
];
3848

39-
it.each(REQUIRED_KEYS)('throws when the required key "%s" is missing', key => {
49+
it.each(REQUIRED_DESCRIPTOR_KEYS)('throws when the required key "%s" is missing', key => {
4050
const descriptor = makeValidDescriptor();
4151
delete descriptor[key];
42-
expect(() => defineInteraction(descriptor)).toThrow(
52+
expect(() => defineInteraction(descriptor, STUB_COMPONENT)).toThrow(
4353
new RegExp(`missing required key "${key}"`, 'i'),
4454
);
4555
});
4656

57+
it('throws when editorComponent is not passed as the second argument', () => {
58+
const descriptor = makeValidDescriptor();
59+
// Calling with no second arg means editorComponent is undefined — still flagged.
60+
expect(() => defineInteraction(descriptor)).toThrow(/missing required key "editorComponent"/i);
61+
});
62+
4763
it('includes the descriptor type in the error message when type is present', () => {
4864
const descriptor = makeValidDescriptor({ type: 'myPlugin' });
49-
delete descriptor.editorComponent;
50-
expect(() => defineInteraction(descriptor)).toThrow(/myPlugin/);
65+
delete descriptor.buildXML; // delete a different key to trigger the error
66+
expect(() => defineInteraction(descriptor, STUB_COMPONENT)).toThrow(/myPlugin/);
5167
});
5268

5369
it('uses "(unknown)" in the error message when type is also missing', () => {
5470
const descriptor = makeValidDescriptor();
5571
delete descriptor.type;
56-
delete descriptor.editorComponent;
57-
expect(() => defineInteraction(descriptor)).toThrow(/\(unknown\)/);
72+
delete descriptor.buildXML;
73+
expect(() => defineInteraction(descriptor, STUB_COMPONENT)).toThrow(/\(unknown\)/);
5874
});
5975
});

contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,4 @@ import { choiceInteractionDescriptor } from './ChoiceInteractionDescriptor';
2020
* @property {string} orientation - From orientation attribute; default "vertical"
2121
*/
2222

23-
choiceInteractionDescriptor.editorComponent = ChoiceInteractionEditor;
24-
25-
export default defineInteraction(choiceInteractionDescriptor);
23+
export default defineInteraction(choiceInteractionDescriptor, ChoiceInteractionEditor);

contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,25 @@ const REQUIRED_KEYS = [
2020
* Validates that a descriptor has every required key and returns it unchanged.
2121
* Throws at call-time (i.e. module import time) if any key is absent.
2222
*
23+
* Pass the Vue editor component as the second argument to attach it to the
24+
* descriptor here rather than mutating the descriptor after construction.
25+
*
2326
* @template {object} T
2427
* @param {T} descriptor - The interaction descriptor to validate
25-
* @returns {T} The same descriptor, unmodified
28+
* @param {object} editorComponent - The Vue component that edits this interaction
29+
* @returns {T} The same descriptor, with editorComponent attached
2630
* @throws {Error} If any required key is missing from the descriptor
2731
*/
28-
export default function defineInteraction(descriptor) {
32+
export default function defineInteraction(descriptor, editorComponent) {
33+
// Attach editorComponent before validation so the required-key check can
34+
// confirm it is present even when the descriptor class does not set it.
35+
descriptor.editorComponent = editorComponent;
36+
2937
for (const key of REQUIRED_KEYS) {
30-
if (!(key in descriptor)) {
38+
// Use a truthiness check for editorComponent (a Vue component object) so
39+
// that passing `undefined` as the second argument is caught as missing.
40+
const isMissing = key === 'editorComponent' ? !descriptor[key] : !(key in descriptor);
41+
if (isMissing) {
3142
const name = descriptor.type ?? '(unknown)';
3243
throw new Error(`defineInteraction: missing required key "${key}" on descriptor "${name}"`);
3344
}

0 commit comments

Comments
 (0)