Skip to content

Commit 4554bc0

Browse files
feat: implement QTI interaction registry, descriptor validation, and XML parsing logic for the QTI Editor.
Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
1 parent 648224a commit 4554bc0

20 files changed

Lines changed: 1209 additions & 35 deletions

File tree

contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
<div>
44
<div style="padding: 16px 24px 0">
55
<div
6-
style="
7-
padding: 16px;
8-
color: #2196f3;
9-
background-color: transparent;
10-
border: 1px solid #2196f3;
11-
border-radius: 4px;
12-
"
6+
:style="{
7+
padding: '16px',
8+
color: $themePalette.blue.v_600,
9+
backgroundColor: 'transparent',
10+
border: `1px solid ${$themePalette.blue.v_600}`,
11+
borderRadius: '4px',
12+
}"
1313
>
1414
<strong>QTI Editor — Dev Demo</strong>
1515
&nbsp;Hardcoded items. Changes are local only and not persisted.
@@ -28,26 +28,36 @@
2828
<script>
2929
3030
import { ref, defineComponent } from 'vue';
31+
import { CHOICE_ITEM_XML, MULTI_CHOICE_ITEM_XML } from './qtiDemoData';
3132
import QTIEditor from 'shared/views/QTIEditor/index';
3233
import { QtiInteraction } from 'shared/views/QTIEditor/constants';
3334
3435
/**
35-
* Hardcoded items covering three interaction types so the closed-card
36-
* type label can be visually verified.
36+
* Hardcoded items covering different states:
37+
* - item-1: has raw_data (real QTI XML) → exercises the full load path
38+
* - item-2: no raw_data → shows placeholder (blank new item state)
39+
* - item-3: no raw_data → shows placeholder
3740
*/
3841
const INITIAL_ASSESSMENTS = [
3942
{
4043
id: 'demo-item-1',
4144
type: QtiInteraction.CHOICE,
4245
title: 'Which planet is closest to the Sun?',
46+
raw_data: CHOICE_ITEM_XML,
4347
},
4448
{
4549
id: 'demo-item-2',
50+
type: QtiInteraction.CHOICE,
51+
title: 'Select all the prime numbers.',
52+
raw_data: MULTI_CHOICE_ITEM_XML,
53+
},
54+
{
55+
id: 'demo-item-3',
4656
type: QtiInteraction.EXTENDED_TEXT,
4757
title: 'Describe the water cycle in your own words.',
4858
},
4959
{
50-
id: 'demo-item-3',
60+
id: 'demo-item-4',
5161
type: QtiInteraction.ORDER,
5262
title: 'Arrange these events in chronological order.',
5363
},
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Demo item 1: a real choice interaction XML so the full load path can
3+
* be verified end-to-end (parseItem → useQtiItem → InteractionSection →
4+
* ChoiceInteractionEditor).
5+
*/
6+
export const CHOICE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
7+
<qti-assessment-item
8+
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
9+
identifier="item-1"
10+
title="Which planet is closest to the Sun?"
11+
adaptive="false"
12+
time-dependent="false"
13+
xml:lang="en"
14+
>
15+
<qti-response-declaration
16+
identifier="RESPONSE"
17+
cardinality="single"
18+
base-type="identifier"
19+
>
20+
<qti-correct-response>
21+
<qti-value>mercury</qti-value>
22+
</qti-correct-response>
23+
</qti-response-declaration>
24+
25+
<qti-item-body>
26+
<qti-choice-interaction
27+
response-identifier="RESPONSE"
28+
max-choices="1"
29+
>
30+
<qti-prompt>Which planet is closest to the Sun?</qti-prompt>
31+
<qti-simple-choice identifier="mercury">Mercury</qti-simple-choice>
32+
<qti-simple-choice identifier="venus">Venus</qti-simple-choice>
33+
<qti-simple-choice identifier="earth">Earth</qti-simple-choice>
34+
<qti-simple-choice identifier="mars">Mars</qti-simple-choice>
35+
</qti-choice-interaction>
36+
</qti-item-body>
37+
</qti-assessment-item>`;
38+
39+
/**
40+
* Demo item 2: a multi-select choice interaction XML (max-choices > 1).
41+
*/
42+
export const MULTI_CHOICE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
43+
<qti-assessment-item
44+
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
45+
identifier="item-2"
46+
title="Select all the prime numbers."
47+
adaptive="false"
48+
time-dependent="false"
49+
xml:lang="en"
50+
>
51+
<qti-response-declaration
52+
identifier="RESPONSE"
53+
cardinality="multiple"
54+
base-type="identifier"
55+
>
56+
<qti-correct-response>
57+
<qti-value>two</qti-value>
58+
<qti-value>three</qti-value>
59+
<qti-value>five</qti-value>
60+
</qti-correct-response>
61+
</qti-response-declaration>
62+
63+
<qti-item-body>
64+
<qti-choice-interaction
65+
response-identifier="RESPONSE"
66+
max-choices="4"
67+
>
68+
<qti-prompt>Select all the prime numbers.</qti-prompt>
69+
<qti-simple-choice identifier="one">1</qti-simple-choice>
70+
<qti-simple-choice identifier="two">2</qti-simple-choice>
71+
<qti-simple-choice identifier="three">3</qti-simple-choice>
72+
<qti-simple-choice identifier="four">4</qti-simple-choice>
73+
<qti-simple-choice identifier="five">5</qti-simple-choice>
74+
</qti-choice-interaction>
75+
</qti-item-body>
76+
</qti-assessment-item>`;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { render, screen } from '@testing-library/vue';
2+
import VueRouter from 'vue-router';
3+
import InteractionSection from '../index.vue';
4+
5+
import {
6+
CHOICE_SINGLE_SELECT_XML,
7+
UNKNOWN_INTERACTION_XML,
8+
mockInteractionBlock as block,
9+
} from '../../../utils/testingFixtures';
10+
11+
const renderSection = (props = {}) =>
12+
render(InteractionSection, {
13+
props: { mode: 'edit', ...props },
14+
routes: new VueRouter(),
15+
});
16+
17+
// ---------------------------------------------------------------------------
18+
// Tests
19+
// ---------------------------------------------------------------------------
20+
21+
describe('InteractionSection', () => {
22+
describe('choice interaction', () => {
23+
it('renders the prompt from the XML via ChoiceInteractionEditor', () => {
24+
renderSection({ block: block(CHOICE_SINGLE_SELECT_XML) });
25+
expect(screen.getByText('Which planet is closest to the Sun?')).toBeInTheDocument();
26+
});
27+
28+
it('renders radio buttons for a single-select choice interaction', () => {
29+
renderSection({ block: block(CHOICE_SINGLE_SELECT_XML) });
30+
const radios = screen.getAllByRole('radio');
31+
expect(radios).toHaveLength(3);
32+
});
33+
34+
it('renders the choice labels', () => {
35+
renderSection({ block: block(CHOICE_SINGLE_SELECT_XML) });
36+
expect(screen.getByText('Mercury')).toBeInTheDocument();
37+
expect(screen.getByText('Venus')).toBeInTheDocument();
38+
});
39+
});
40+
41+
describe('parse error handling', () => {
42+
it('shows a parse error message and no interaction when XML is malformed', () => {
43+
renderSection({ block: block('not-xml<{{') });
44+
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
45+
// At minimum no interactive elements render
46+
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
47+
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
48+
});
49+
});
50+
51+
describe('unknown interaction type', () => {
52+
it('falls back silently when the interaction tag is unrecognized', () => {
53+
// Should not throw — just renders the fallback component
54+
expect(() => renderSection({ block: block(UNKNOWN_INTERACTION_XML) })).not.toThrow();
55+
});
56+
});
57+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<template>
2+
3+
<div>
4+
<p
5+
v-if="parseError"
6+
:style="{ color: $themePalette.red.v_700, margin: 0 }"
7+
>
8+
{{ parseError }}
9+
</p>
10+
<component
11+
:is="descriptor.editorComponent"
12+
v-else
13+
:key="descriptor.type"
14+
:questionType="questionType"
15+
:block="block"
16+
:mode="mode"
17+
/>
18+
</div>
19+
20+
</template>
21+
22+
23+
<script>
24+
25+
import { computed } from 'vue';
26+
import useInteractionDescriptor from '../../composables/useInteractionDescriptor';
27+
28+
export default {
29+
name: 'InteractionSection',
30+
31+
setup(props) {
32+
const bodyXmlRef = computed(() => props.block.bodyXml);
33+
const { descriptor, questionType, parseError } = useInteractionDescriptor(bodyXmlRef);
34+
35+
return { descriptor, questionType, parseError };
36+
},
37+
38+
props: {
39+
/** The raw XML block representing an interaction and its response declarations */
40+
block: {
41+
type: Object,
42+
required: true,
43+
},
44+
/** View or edit mode */
45+
mode: {
46+
type: String,
47+
default: 'view',
48+
},
49+
},
50+
};
51+
52+
</script>

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ const renderComponent = (props = {}, slots = {}) => {
2828

2929
describe('QTIItemEditor', () => {
3030
describe('view mode', () => {
31-
test('does not show the card body', () => {
31+
test('shows the card body (placeholder) even in view mode', () => {
3232
renderComponent({ mode: 'view' });
33-
expect(screen.queryByText(questionContentPlaceholder$())).not.toBeInTheDocument();
33+
expect(screen.getByText(questionContentPlaceholder$())).toBeInTheDocument();
3434
});
3535

3636
test('does not show the close button', () => {

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

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,17 @@
2626
</div>
2727
</div>
2828

29-
<div
30-
v-if="mode === 'edit' || displayAnswersPreview"
31-
class="question-card-body"
32-
>
33-
<p :style="{ color: $themePalette.grey.v_500, margin: 0, fontStyle: 'italic' }">
29+
<div class="question-card-body">
30+
<InteractionSection
31+
v-if="interactions.length > 0"
32+
:block="interactions[0]"
33+
:mode="mode"
34+
:displayAnswersPreview="displayAnswersPreview"
35+
/>
36+
<p
37+
v-else
38+
:style="{ color: $themePalette.grey.v_500, margin: 0, fontStyle: 'italic' }"
39+
>
3440
{{ questionContentPlaceholder$() }}
3541
</p>
3642
</div>
@@ -55,10 +61,14 @@
5561
import { computed } from 'vue';
5662
import { qtiEditorStrings } from '../../qtiEditorStrings';
5763
import { QtiInteraction } from '../../constants';
64+
import useInteractionDescriptor from '../../composables/useInteractionDescriptor';
65+
import useQtiItem from '../../composables/useQtiItem';
66+
import InteractionSection from '../InteractionSection/index.vue';
5867
59-
// QTI XML element name → i18n string key, used to build closed-card labels.
68+
// QTI interaction tag name → i18n string key, used for closed-card labels
69+
// on items that have no raw_data yet (blank new items).
6070
const INTERACTION_TYPE_STRING_KEY = {
61-
[QtiInteraction.CHOICE]: 'interactionTypeChoice',
71+
[QtiInteraction.CHOICE]: 'interactionTypeSingleChoice', // defaults to single choice if no XML yet
6272
[QtiInteraction.ORDER]: 'interactionTypeOrder',
6373
[QtiInteraction.MATCH]: 'interactionTypeMatch',
6474
[QtiInteraction.TEXT_ENTRY]: 'interactionTypeTextEntry',
@@ -68,6 +78,8 @@
6878
export default {
6979
name: 'QTIItemEditor',
7080
81+
components: { InteractionSection },
82+
7183
setup(props) {
7284
const {
7385
questionNumberLabel$,
@@ -77,24 +89,49 @@
7789
interactionTypeUnknown$,
7890
} = qtiEditorStrings;
7991
92+
const { interactions } = useQtiItem(props.item.raw_data);
93+
8094
const questionNumberLabel = computed(() =>
8195
questionNumberLabel$({
8296
number: props.index + 1,
8397
total: props.total,
8498
}),
8599
);
86100
87-
const questionNumberAndTypeLabel = computed(() => {
101+
const firstBlockXml = computed(() =>
102+
interactions.value.length > 0 ? interactions.value[0].bodyXml : null,
103+
);
104+
const { descriptor, questionType } = useInteractionDescriptor(firstBlockXml);
105+
106+
/**
107+
* Derives the type label for the closed-card header.
108+
* When raw_data is present: parses the first interaction's bodyXml and uses
109+
* the matching descriptor's label — this is the source of truth from the XML.
110+
* When raw_data is absent (blank new items): falls back to item.type enum lookup.
111+
*/
112+
const interactionTypeLabel = computed(() => {
113+
if (firstBlockXml.value) {
114+
if (descriptor.value?.type === QtiInteraction.CHOICE) {
115+
return questionType.value === 'singleSelect'
116+
? qtiEditorStrings.interactionTypeSingleChoice$()
117+
: qtiEditorStrings.interactionTypeMultipleChoice$();
118+
}
119+
return descriptor.value ? descriptor.value.label : interactionTypeUnknown$();
120+
}
88121
const typeKey = INTERACTION_TYPE_STRING_KEY[props.item.type];
89-
const typeLabel = typeKey ? qtiEditorStrings[`${typeKey}$`]() : interactionTypeUnknown$();
90-
return questionNumberAndTypeLabel$({
122+
return typeKey ? qtiEditorStrings[`${typeKey}$`]() : interactionTypeUnknown$();
123+
});
124+
125+
const questionNumberAndTypeLabel = computed(() =>
126+
questionNumberAndTypeLabel$({
91127
number: props.index + 1,
92128
total: props.total,
93-
type: typeLabel,
94-
});
95-
});
129+
type: interactionTypeLabel.value,
130+
}),
131+
);
96132
97133
return {
134+
interactions,
98135
questionNumberLabel,
99136
questionNumberAndTypeLabel,
100137
closeBtnLabel$,
@@ -103,7 +140,10 @@
103140
},
104141
105142
props: {
106-
/** Assessment item: { id, type (QtiInteraction value), title } */
143+
/**
144+
* Assessment item: { id, type (QtiInteraction value), title, raw_data? }
145+
* raw_data is the full QTI XML string; absent on blank newly-created items.
146+
*/
107147
item: {
108148
type: Object,
109149
required: true,
@@ -124,7 +164,7 @@
124164
default: 'view',
125165
validator: val => ['view', 'edit'].includes(val),
126166
},
127-
/** Whether to show answers previews for closed items */
167+
/** Whether to show answer previews for closed items */
128168
displayAnswersPreview: {
129169
type: Boolean,
130170
default: false,

0 commit comments

Comments
 (0)