Skip to content

Commit 2dcf2b2

Browse files
refactor: decouple QTI interaction descriptors from XML parsing by introducing a writable questionType state and updating interaction descriptor lookups.
Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
1 parent 138d2b2 commit 2dcf2b2

11 files changed

Lines changed: 152 additions & 99 deletions

File tree

contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { render, screen } from '@testing-library/vue';
2+
import { nextTick } from 'vue';
23
import VueRouter from 'vue-router';
34
import InteractionSection from '../index.vue';
45

@@ -25,8 +26,9 @@ describe('InteractionSection', () => {
2526
expect(screen.getByText('Which planet is closest to the Sun?')).toBeInTheDocument();
2627
});
2728

28-
it('renders radio buttons for a single-select choice interaction', () => {
29+
it('renders radio buttons for a single-select choice interaction', async () => {
2930
renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) });
31+
await nextTick();
3032
const radios = screen.getAllByRole('radio');
3133
expect(radios).toHaveLength(3);
3234
});

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,20 @@
4545
},
4646
4747
props: {
48-
/** The raw XML block representing an interaction and its response declarations */
48+
/**
49+
* The raw interaction block.
50+
* Expected shape: { bodyXml: string, responseDeclarations: string[] }
51+
*/
4952
interaction: {
5053
type: Object,
5154
required: true,
55+
validator: val => typeof val.bodyXml === 'string',
5256
},
5357
/** View or edit mode */
5458
mode: {
5559
type: String,
5660
default: 'view',
61+
validator: val => ['view', 'edit'].includes(val),
5762
},
5863
/** Whether to display correct answers (used in view mode previews) */
5964
showAnswers: {

contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@ import { render, screen, fireEvent } from '@testing-library/vue';
22
import VueRouter from 'vue-router';
33
import QTIItemEditor from '../index.vue';
44
import { qtiEditorStrings } from '../../../qtiEditorStrings';
5-
import { QtiInteraction } from '../../../constants';
5+
import { AssessmentItemTypes } from '../../../constants';
66

77
const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings;
88

99
const defaultProps = {
1010
item: {
11-
id: 'test-item-id',
12-
type: QtiInteraction.CHOICE,
13-
title: 'Test Choice Interaction',
11+
assessment_id: 'test-item-id',
12+
type: AssessmentItemTypes.QTI,
1413
},
1514
index: 0,
1615
total: 5,

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

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
6262
import { computed, ref } from 'vue';
6363
import { qtiEditorStrings } from '../../qtiEditorStrings';
64-
import { AssessmentItemTypes, QuestionType } from '../../constants';
64+
import { QuestionType } from '../../constants';
6565
import useQtiItem from '../../composables/useQtiItem';
6666
import InteractionSection from '../InteractionSection/index.vue';
6767
@@ -76,7 +76,7 @@
7676
questionNumberAndTypeLabel$,
7777
closeBtnLabel$,
7878
questionContentPlaceholder$,
79-
interactionTypeUnknown$,
79+
unknownTypeLabel$,
8080
} = qtiEditorStrings;
8181
8282
const { interactions } = useQtiItem(props.item.raw_data);
@@ -88,17 +88,25 @@
8888
}),
8989
);
9090
91-
const currentQuestionType = ref(props.item.type || AssessmentItemTypes.QTI);
92-
93-
const interactionTypeLabel = computed(() => {
94-
if (currentQuestionType.value === QuestionType.SINGLE_SELECT) {
95-
return qtiEditorStrings.interactionTypeSingleChoice$();
96-
}
97-
if (currentQuestionType.value === QuestionType.MULTI_SELECT) {
98-
return qtiEditorStrings.interactionTypeMultipleChoice$();
99-
}
100-
return interactionTypeUnknown$();
101-
});
91+
/**
92+
* Tracks the current question type (a QuestionType value).
93+
* Initialized to null — populated via the update:questionType event
94+
* emitted by InteractionSection once the XML is parsed on mount.
95+
*/
96+
const currentQuestionType = ref(null);
97+
98+
/**
99+
* Maps each QuestionType to its localized display label.
100+
* Add new entries here as more question types are introduced.
101+
*/
102+
const QUESTION_TYPE_LABELS = {
103+
[QuestionType.SINGLE_SELECT]: () => qtiEditorStrings.singleChoiceLabel$(),
104+
[QuestionType.MULTI_SELECT]: () => qtiEditorStrings.multipleChoiceLabel$(),
105+
};
106+
107+
const interactionTypeLabel = computed(
108+
() => QUESTION_TYPE_LABELS[currentQuestionType.value]?.() ?? unknownTypeLabel$(),
109+
);
102110
103111
const questionNumberAndTypeLabel = computed(() =>
104112
questionNumberAndTypeLabel$({

contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import {
1111
} from '../../utils/testingFixtures';
1212

1313
// ---------------------------------------------------------------------------
14-
// Helper: renders a wrapper component that runs the composable inside setup()
15-
// Returns { result, bodyXmlRef } — result holds the reactive return value,
16-
// bodyXmlRef can be mutated to test reactivity.
14+
// Helper: renders a wrapper component that runs the composable inside setup().
15+
// Because questionType is now set on mount (not as a computed), we must wait
16+
// for the component to mount before checking reactive values.
1717
// ---------------------------------------------------------------------------
1818

1919
function renderDescriptor(initialXml = null) {
@@ -38,94 +38,97 @@ function renderDescriptor(initialXml = null) {
3838

3939
describe('useInteractionDescriptor', () => {
4040
describe('with a valid choice interaction', () => {
41-
it('resolves the Choice descriptor by its type', () => {
41+
it('resolves the Choice descriptor by its type', async () => {
4242
const { result } = renderDescriptor(CHOICE_SINGLE_SELECT_XML);
43+
await nextTick();
4344
expect(result.descriptor.value.type).toBe(QtiInteraction.CHOICE);
4445
});
4546

46-
it('resolves questionType as singleSelect when max-choices is 1', () => {
47+
it('resolves questionType as singleSelect when max-choices is 1', async () => {
4748
const { result } = renderDescriptor(CHOICE_SINGLE_SELECT_XML);
49+
await nextTick();
4850
expect(result.questionType.value).toBe(QuestionType.SINGLE_SELECT);
4951
});
5052

51-
it('resolves questionType as multiSelect when max-choices > 1', () => {
53+
it('resolves questionType as multiSelect when max-choices > 1', async () => {
5254
const { result } = renderDescriptor(CHOICE_MULTI_SELECT_XML);
55+
await nextTick();
5356
expect(result.questionType.value).toBe(QuestionType.MULTI_SELECT);
5457
});
5558

56-
it('returns null parseError for valid XML', () => {
59+
it('returns null parseError for valid XML', async () => {
5760
const { result } = renderDescriptor(CHOICE_SINGLE_SELECT_XML);
61+
await nextTick();
5862
expect(result.parseError.value).toBeNull();
5963
});
6064
});
6165

6266
describe('with an unrecognized interaction type', () => {
63-
it('falls back to the default descriptor without a parse error', () => {
67+
it('falls back to the default descriptor without a parse error', async () => {
6468
const { result } = renderDescriptor(UNKNOWN_INTERACTION_XML);
69+
await nextTick();
6570
expect(result.parseError.value).toBeNull();
6671
});
6772

68-
it('still returns a defined fallback descriptor', () => {
73+
it('still returns a defined fallback descriptor', async () => {
6974
const { result } = renderDescriptor(UNKNOWN_INTERACTION_XML);
75+
await nextTick();
7076
expect(result.descriptor.value).toBeDefined();
7177
expect(typeof result.descriptor.value.matches).toBe('function');
7278
});
7379
});
7480

7581
describe('with a null or empty bodyXmlRef', () => {
76-
it('returns a defined descriptor when bodyXmlRef is null', () => {
82+
it('returns the default descriptor when bodyXmlRef is null', async () => {
7783
const { result } = renderDescriptor(null);
84+
await nextTick();
7885
expect(result.descriptor.value).toBeDefined();
7986
expect(result.parseError.value).toBeNull();
8087
});
8188

82-
it('returns null questionType when bodyXmlRef is null', () => {
89+
it('returns null questionType when bodyXmlRef is null', async () => {
8390
const { result } = renderDescriptor(null);
91+
await nextTick();
8492
expect(result.questionType.value).toBeNull();
8593
});
8694

87-
it('returns a defined descriptor when bodyXmlRef is an empty string', () => {
95+
it('returns the default descriptor when bodyXmlRef is an empty string', async () => {
8896
const { result } = renderDescriptor('');
97+
await nextTick();
8998
expect(result.descriptor.value).toBeDefined();
9099
expect(result.parseError.value).toBeNull();
91100
});
92101
});
93102

94103
describe('with malformed XML', () => {
95-
it('returns a non-null parseError', () => {
104+
it('returns a non-null parseError', async () => {
96105
const { result } = renderDescriptor('<unclosed');
106+
await nextTick();
97107
expect(result.parseError.value).not.toBeNull();
98108
expect(typeof result.parseError.value).toBe('string');
99109
});
100110

101-
it('still returns a defined fallback descriptor on parse error', () => {
111+
it('still returns a defined fallback descriptor on parse error', async () => {
102112
const { result } = renderDescriptor('<bad xml!!{');
113+
await nextTick();
103114
expect(result.descriptor.value).toBeDefined();
104115
});
105116
});
106117

107-
describe('reactivity', () => {
108-
it('recomputes questionType when bodyXmlRef changes from null to single-select', async () => {
109-
const { result, bodyXmlRef } = renderDescriptor(null);
110-
await nextTick();
111-
112-
expect(result.questionType.value).toBeNull();
113-
114-
bodyXmlRef.value = CHOICE_SINGLE_SELECT_XML;
115-
await nextTick();
116-
117-
expect(result.questionType.value).toBe(QuestionType.SINGLE_SELECT);
118-
});
119-
120-
it('recomputes questionType when switching from single-select to multi-select', async () => {
121-
const { result, bodyXmlRef } = renderDescriptor(CHOICE_SINGLE_SELECT_XML);
118+
describe('questionType as a writable ref', () => {
119+
it('descriptor recomputes when questionType is changed directly', async () => {
120+
const { result } = renderDescriptor(CHOICE_SINGLE_SELECT_XML);
122121
await nextTick();
123122

124123
expect(result.questionType.value).toBe(QuestionType.SINGLE_SELECT);
124+
expect(result.descriptor.value.type).toBe(QtiInteraction.CHOICE);
125125

126-
bodyXmlRef.value = CHOICE_MULTI_SELECT_XML;
126+
// Simulate user switching question type via the selector
127+
result.questionType.value = QuestionType.MULTI_SELECT;
127128
await nextTick();
128129

130+
// Still the same descriptor (choice handles both), but questionType changed
131+
expect(result.descriptor.value.type).toBe(QtiInteraction.CHOICE);
129132
expect(result.questionType.value).toBe(QuestionType.MULTI_SELECT);
130133
});
131134
});
Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,71 @@
1-
import { computed } from 'vue';
1+
import { computed, ref, onMounted } from 'vue';
22
import { parseXML } from '../serialization/parseItem';
33
import { descriptors, registry, DEFAULT_INTERACTION } from '../interactions/index';
44

55
/**
6-
* Composable that analyzes a QTI interaction block's XML and resolves the
7-
* appropriate plugin descriptor and sub-question type.
6+
* Composable that manages the question type and descriptor for a single
7+
* interaction block.
88
*
9-
* @param {import('vue').Ref<string>} bodyXmlRef Ref to interaction's bodyXml string
9+
* Design rationale (three separate type concepts):
10+
* - The XML is parsed **once on mount** to infer the initial questionType.
11+
* - `questionType` is a writable `ref` so the type-selector UI can change it
12+
* without triggering a re-parse of the XML.
13+
* - `descriptor` is a `computed` derived from `questionType`, using each
14+
* descriptor's `questionTypes` array for the lookup. This means the
15+
* rendered editor component reacts to user selections, not to XML changes.
16+
*
17+
* @param {import('vue').Ref<string|null>} bodyXmlRef Ref to interaction bodyXml string.
1018
*/
1119
export default function useInteractionDescriptor(bodyXmlRef) {
12-
const parsed = computed(() => {
13-
if (!bodyXmlRef.value) {
14-
return {
15-
error: null,
16-
descriptor: registry[DEFAULT_INTERACTION],
17-
questionType: null,
18-
};
19-
}
20+
/** Writable question type — set from XML on mount, then driven by UI selections. */
21+
const questionType = ref(null);
22+
/** Any parse error message from the initial XML parse; null when clean. */
23+
const parseError = ref(null);
2024

25+
/**
26+
* Parses bodyXml and returns { descriptor, questionType } without touching
27+
* any reactive state — pure helper used only on mount.
28+
*/
29+
function inferFromXml(xml) {
30+
if (!xml) {
31+
return { descriptor: registry[DEFAULT_INTERACTION], questionType: null, error: null };
32+
}
2133
try {
22-
// bodyXml is the full <qti-*-interaction> element — its root IS the interaction.
23-
const doc = parseXML(bodyXmlRef.value);
34+
const doc = parseXML(xml);
2435
const interactionEl = doc.documentElement;
25-
26-
const descriptor =
27-
descriptors.find(d => d.matches(interactionEl)) ?? registry[DEFAULT_INTERACTION];
28-
const questionType = descriptor.getQuestionType(interactionEl) ?? null;
29-
36+
const desc = descriptors.find(d => d.matches(interactionEl)) ?? registry[DEFAULT_INTERACTION];
3037
return {
38+
descriptor: desc,
39+
questionType: desc.getQuestionType(interactionEl) ?? null,
3140
error: null,
32-
descriptor,
33-
questionType,
3441
};
3542
} catch (e) {
3643
return {
37-
error: e.message,
3844
descriptor: registry[DEFAULT_INTERACTION],
3945
questionType: null,
46+
error: e.message,
4047
};
4148
}
49+
}
50+
51+
/** Parse XML once when the host component mounts to set the initial state. */
52+
onMounted(() => {
53+
const inferred = inferFromXml(bodyXmlRef.value);
54+
questionType.value = inferred.questionType;
55+
parseError.value = inferred.error;
4256
});
4357

44-
return {
45-
descriptor: computed(() => parsed.value.descriptor),
46-
questionType: computed(() => parsed.value.questionType),
47-
parseError: computed(() => parsed.value.error),
48-
};
58+
/**
59+
* Descriptor is derived from the current questionType ref.
60+
* Uses each descriptor's `questionTypes` array so the lookup stays accurate
61+
* when the user changes the question type via the selector.
62+
* Falls back to the default descriptor when no match is found.
63+
*/
64+
const descriptor = computed(
65+
() =>
66+
descriptors.find(d => d.questionTypes.includes(questionType.value)) ??
67+
registry[DEFAULT_INTERACTION],
68+
);
69+
70+
return { descriptor, questionType, parseError };
4971
}

contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,22 @@ export const QtiInteraction = Object.freeze({
5454

5555
export const QTI_INTERACTION_TAGS = Object.freeze(Object.values(QtiInteraction));
5656

57+
/**
58+
* Assessment item types as stored in the database.
59+
* Within the QTI Editor, all authored items will have type QTI.
60+
* The other legacy values are kept here for reference but are handled
61+
* by the broader Studio assessment system, not by this editor.
62+
*/
5763
export const AssessmentItemTypes = Object.freeze({
58-
SINGLE_SELECTION: 'single_selection',
59-
MULTIPLE_SELECTION: 'multiple_selection',
60-
TRUE_FALSE: 'true_false',
61-
INPUT_QUESTION: 'input_question',
62-
PERSEUS_QUESTION: 'perseus_question',
63-
FREE_RESPONSE: 'free_response',
6464
QTI: 'qti',
6565
});
6666

67+
/**
68+
* UI-facing question type values — what the type selector shows to authors.
69+
* These are distinct from AssessmentItemTypes (database) and QtiInteraction (XML tags).
70+
* One QtiInteraction can map to multiple QuestionTypes (e.g. choice → singleSelect | multiSelect).
71+
*/
6772
export const QuestionType = Object.freeze({
68-
SINGLE_SELECT: 'single_selection',
69-
MULTI_SELECT: 'multiple_selection',
73+
SINGLE_SELECT: 'singleSelect',
74+
MULTI_SELECT: 'multiSelect',
7075
});

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import defineInteraction from '../defineInteraction';
44
const makeValidDescriptor = (overrides = {}) => ({
55
type: 'test',
66
placement: 'block',
7+
questionTypes: [],
78
editorComponent: {},
89
convertsFrom: [],
910
matches: () => false,
@@ -22,6 +23,7 @@ describe('defineInteraction', () => {
2223
const REQUIRED_KEYS = [
2324
'type',
2425
'placement',
26+
'questionTypes',
2527
'editorComponent',
2628
'convertsFrom',
2729
'matches',

0 commit comments

Comments
 (0)