Skip to content

Commit a9d178a

Browse files
authored
Merge pull request learningequality#6051 from Abhishek-Punhani/text-entry-interaction
feat(QTIEditor): add text-entry interaction support
2 parents d04e859 + f4dbd0e commit a9d178a

34 files changed

Lines changed: 2395 additions & 218 deletions

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

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -28,36 +28,8 @@
2828
<script>
2929
3030
import { ref, defineComponent } from 'vue';
31-
import { CHOICE_ITEM_XML, MULTI_CHOICE_ITEM_XML } from './qtiDemoData';
31+
import { INITIAL_ASSESSMENTS } from './qtiDemoData';
3232
import QTIEditor from 'shared/views/QTIEditor/index';
33-
import { AssessmentItemTypes } from 'shared/views/QTIEditor/constants';
34-
35-
/**
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
40-
*/
41-
const INITIAL_ASSESSMENTS = [
42-
{
43-
assessment_id: 'demo-item-1',
44-
type: AssessmentItemTypes.QTI,
45-
raw_data: CHOICE_ITEM_XML,
46-
},
47-
{
48-
assessment_id: 'demo-item-2',
49-
type: AssessmentItemTypes.QTI,
50-
raw_data: MULTI_CHOICE_ITEM_XML,
51-
},
52-
{
53-
assessment_id: 'demo-item-3',
54-
type: AssessmentItemTypes.QTI,
55-
},
56-
{
57-
assessment_id: 'demo-item-4',
58-
type: AssessmentItemTypes.QTI,
59-
},
60-
];
6133
6234
export default defineComponent({
6335
name: 'QTIDemoPage',

contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,103 @@ export const MULTI_CHOICE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
7777
</qti-item-body>
7878
</qti-assessment-item>`;
7979

80+
/**
81+
* Demo item 3: numeric text-entry — student types an acceptable number.
82+
*/
83+
export const NUMERIC_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
84+
<qti-assessment-item
85+
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
86+
identifier="item-numeric"
87+
title="Speed of light"
88+
adaptive="false"
89+
time-dependent="false"
90+
xml:lang="en"
91+
>
92+
<qti-response-declaration
93+
identifier="RESPONSE"
94+
cardinality="multiple"
95+
base-type="float"
96+
>
97+
<qti-correct-response>
98+
<qti-value>299792458</qti-value>
99+
<qti-value>3e8</qti-value>
100+
</qti-correct-response>
101+
</qti-response-declaration>
102+
103+
<qti-item-body>
104+
<div>
105+
<div><p>What is the speed of light in m/s? (enter one of the accepted values)</p></div>
106+
<p><qti-text-entry-interaction response-identifier="RESPONSE"/></p>
107+
</div>
108+
</qti-item-body>
109+
</qti-assessment-item>`;
110+
111+
/**
112+
* Demo item 4: textEntry — student types a string answer (case-sensitive option shown).
113+
*/
114+
export const TEXT_ENTRY_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
115+
<qti-assessment-item
116+
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
117+
identifier="item-text-entry"
118+
title="Chemical symbol for water"
119+
adaptive="false"
120+
time-dependent="false"
121+
xml:lang="en"
122+
>
123+
<qti-response-declaration
124+
identifier="RESPONSE"
125+
cardinality="single"
126+
base-type="string"
127+
>
128+
<qti-correct-response>
129+
<qti-value case-sensitive="true">H2O</qti-value>
130+
<qti-value>h2o</qti-value>
131+
<qti-value>H2o</qti-value>
132+
</qti-correct-response>
133+
</qti-response-declaration>
134+
135+
<qti-item-body>
136+
<div>
137+
<div><p>What is the chemical symbol for water?</p></div>
138+
<p><qti-text-entry-interaction response-identifier="RESPONSE" expected-length="10"/></p>
139+
</div>
140+
</qti-item-body>
141+
</qti-assessment-item>`;
142+
143+
/**
144+
* Demo item 5: freeResponse — open-ended, no correct answer.
145+
*/
146+
export const FREE_RESPONSE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
147+
<qti-assessment-item
148+
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
149+
identifier="item-free-response"
150+
title="Describe photosynthesis"
151+
adaptive="false"
152+
time-dependent="false"
153+
xml:lang="en"
154+
>
155+
<qti-response-declaration
156+
identifier="RESPONSE"
157+
cardinality="single"
158+
base-type="string"
159+
/>
160+
161+
<qti-item-body>
162+
<div>
163+
<div><p>Describe the process of photosynthesis in your own words.</p></div>
164+
<p><qti-text-entry-interaction response-identifier="RESPONSE" expected-length="50"/></p>
165+
</div>
166+
</qti-item-body>
167+
</qti-assessment-item>`;
168+
80169
/**
81170
* Hardcoded items covering different states:
82-
* - item-1: has raw_data (real QTI XML) → exercises the full load path
83-
* - item-2: no raw_data → shows placeholder (blank new item state)
84-
* - item-3: no raw_data → shows placeholder
171+
* - item-1: single-select choice interaction
172+
* - item-2: multi-select choice interaction
173+
* - item-numeric: numeric text-entry
174+
* - item-text-entry: string text-entry with case-sensitive answers
175+
* - item-free-response: free-response text-entry (no correct answer)
176+
* - item-blank: no raw_data → shows placeholder (blank new item state)
85177
*/
86178
export const INITIAL_ASSESSMENTS = [
87179
{
@@ -95,7 +187,22 @@ export const INITIAL_ASSESSMENTS = [
95187
raw_data: MULTI_CHOICE_ITEM_XML,
96188
},
97189
{
98-
assessment_id: 'demo-item-3',
190+
assessment_id: 'demo-item-numeric',
191+
type: AssessmentItemTypes.QTI,
192+
raw_data: NUMERIC_ITEM_XML,
193+
},
194+
{
195+
assessment_id: 'demo-item-text-entry',
196+
type: AssessmentItemTypes.QTI,
197+
raw_data: TEXT_ENTRY_ITEM_XML,
198+
},
199+
{
200+
assessment_id: 'demo-item-free-response',
201+
type: AssessmentItemTypes.QTI,
202+
raw_data: FREE_RESPONSE_ITEM_XML,
203+
},
204+
{
205+
assessment_id: 'demo-item-blank',
99206
type: AssessmentItemTypes.QTI,
100207
},
101208
];
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<template>
2+
3+
<KButton
4+
appearance="flat-button"
5+
:appearanceOverrides="buttonAppearanceOverrides"
6+
class="add-list-item-btn"
7+
@click="$emit('click')"
8+
>
9+
<div class="add-list-item-btn-content">
10+
<KIcon
11+
icon="plus"
12+
:color="$themePalette.blue.v_500"
13+
/>
14+
<span>{{ label }}</span>
15+
</div>
16+
</KButton>
17+
18+
</template>
19+
20+
21+
<script>
22+
23+
import { computed } from 'vue';
24+
import { themePalette } from 'kolibri-design-system/lib/styles/theme';
25+
26+
export default {
27+
name: 'AddListItemButton',
28+
setup() {
29+
const palette = themePalette();
30+
const buttonAppearanceOverrides = computed(() => ({
31+
backgroundColor: palette.blue.v_50,
32+
border: `1px dashed ${palette.blue.v_200}`,
33+
color: `${palette.blue.v_500} !important`,
34+
fontSize: '14px',
35+
fontWeight: '600',
36+
textTransform: 'none',
37+
':hover': {
38+
backgroundColor: palette.blue.v_100,
39+
},
40+
}));
41+
42+
return {
43+
buttonAppearanceOverrides,
44+
};
45+
},
46+
props: {
47+
label: {
48+
type: String,
49+
required: true,
50+
},
51+
},
52+
emits: ['click'],
53+
};
54+
55+
</script>
56+
57+
58+
<style scoped>
59+
60+
.add-list-item-btn {
61+
justify-content: center;
62+
width: 100%;
63+
padding: 11px 16px !important;
64+
margin-top: 10px;
65+
line-height: unset !important;
66+
border-radius: 4px !important;
67+
}
68+
69+
.add-list-item-btn-content {
70+
display: flex;
71+
gap: 10px;
72+
align-items: center;
73+
justify-content: center;
74+
}
75+
76+
</style>

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

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,6 @@ const renderSection = (props = {}) =>
1717
routes: new VueRouter(),
1818
});
1919

20-
// ---------------------------------------------------------------------------
21-
// Tests
22-
// ---------------------------------------------------------------------------
23-
2420
describe('InteractionSection', () => {
2521
describe('choice interaction', () => {
2622
it('renders the prompt from the XML via ChoiceInteractionEditor', () => {
@@ -43,11 +39,9 @@ describe('InteractionSection', () => {
4339
});
4440

4541
describe('parse error handling', () => {
46-
it('gracefully falls back to default interaction state when XML is malformed', () => {
42+
it('shows a parse error when XML is malformed', () => {
4743
renderSection({ interaction: interactionBlock('not-xml<{{') });
48-
// It should render exactly 1 choice fallback element
49-
const inputs = screen.queryAllByRole('radio').concat(screen.queryAllByRole('checkbox'));
50-
expect(inputs).toHaveLength(1);
44+
expect(screen.getByText('This question could not be loaded')).toBeInTheDocument();
5145
});
5246
});
5347

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import QTIItemEditor from '../index.vue';
44
import { qtiEditorStrings } from '../../../qtiEditorStrings';
55
import { AssessmentItemTypes } from '../../../constants';
66

7+
jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
8+
79
const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings;
810

911
const defaultProps = {

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

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -117,18 +117,18 @@
117117
*/
118118
const currentQuestionType = ref(null);
119119
120-
/**
121-
* Maps each QuestionType to its localized display label.
122-
* Add new entries here as more question types are introduced.
123-
*/
124-
const QUESTION_TYPE_LABELS = {
125-
[QuestionType.SINGLE_SELECT]: () => qtiEditorStrings.singleChoiceLabel$(),
126-
[QuestionType.MULTI_SELECT]: () => qtiEditorStrings.multipleChoiceLabel$(),
127-
};
128-
129-
const interactionTypeLabel = computed(
130-
() => QUESTION_TYPE_LABELS[currentQuestionType.value]?.() ?? unknownTypeLabel$(),
131-
);
120+
const interactionTypeLabel = computed(() => {
121+
const type = currentQuestionType.value;
122+
if (!type) return unknownTypeLabel$();
123+
const QUESTION_TYPE_LABELS = {
124+
[QuestionType.SINGLE_SELECT]: qtiEditorStrings.singleSelectLabel$,
125+
[QuestionType.MULTI_SELECT]: qtiEditorStrings.multiSelectLabel$,
126+
[QuestionType.NUMERIC]: qtiEditorStrings.numericLabel$,
127+
[QuestionType.TEXT_ENTRY]: qtiEditorStrings.textEntryLabel$,
128+
[QuestionType.FREE_RESPONSE]: qtiEditorStrings.freeResponseLabel$,
129+
};
130+
return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)();
131+
});
132132
133133
const questionNumberAndTypeLabel = computed(() =>
134134
questionNumberAndTypeLabel$({

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

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,11 @@ function makeBlock(choices, questionType = QuestionType.SINGLE_SELECT) {
3030
}
3131

3232
function setup(choices, questionType = QuestionType.SINGLE_SELECT) {
33-
const qt = ref(questionType);
33+
const questionTypeRef = ref(questionType);
3434
const block = makeBlock(choices, questionType);
35-
return { qt, ...useChoiceInteraction(block, qt) };
35+
return { questionTypeRef, ...useChoiceInteraction(block, questionTypeRef) };
3636
}
3737

38-
// ---------------------------------------------------------------------------
39-
// Tests
40-
// ---------------------------------------------------------------------------
41-
4238
describe('useChoiceInteraction', () => {
4339
describe('addChoice()', () => {
4440
it('appends a new choice to the list', () => {
@@ -118,33 +114,33 @@ describe('useChoiceInteraction', () => {
118114

119115
describe('toggleCorrectChoice()', () => {
120116
it('singleSelect: sets only the target as correct and clears others', () => {
121-
const { state, toggleCorrectChoice, qt } = setup([
117+
const { state, toggleCorrectChoice, questionTypeRef } = setup([
122118
makeAnswer({ id: 'a', correct: true }),
123119
makeAnswer({ id: 'b', correct: false }),
124120
]);
125-
qt.value = QuestionType.SINGLE_SELECT;
121+
questionTypeRef.value = QuestionType.SINGLE_SELECT;
126122
toggleCorrectChoice('b');
127123
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
128124
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false);
129125
});
130126

131127
it('multiSelect: toggles only the target, leaves others unchanged', () => {
132-
const { state, toggleCorrectChoice, qt } = setup(
128+
const { state, toggleCorrectChoice, questionTypeRef } = setup(
133129
[makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false })],
134130
QuestionType.MULTI_SELECT,
135131
);
136-
qt.value = QuestionType.MULTI_SELECT;
132+
questionTypeRef.value = QuestionType.MULTI_SELECT;
137133
toggleCorrectChoice('b');
138134
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
139135
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(true);
140136
});
141137

142138
it('multiSelect: toggles correct off when already correct', () => {
143-
const { state, toggleCorrectChoice, qt } = setup(
139+
const { state, toggleCorrectChoice, questionTypeRef } = setup(
144140
[makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })],
145141
QuestionType.MULTI_SELECT,
146142
);
147-
qt.value = QuestionType.MULTI_SELECT;
143+
questionTypeRef.value = QuestionType.MULTI_SELECT;
148144
toggleCorrectChoice('a');
149145
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false);
150146
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);

0 commit comments

Comments
 (0)