Skip to content

Commit 138d2b2

Browse files
refactor: standardize assessment item schema and rename preview toggle to showAnswers in QTIEditor
Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
1 parent 4554bc0 commit 138d2b2

16 files changed

Lines changed: 201 additions & 138 deletions

File tree

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

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
import { ref, defineComponent } from 'vue';
3131
import { CHOICE_ITEM_XML, MULTI_CHOICE_ITEM_XML } from './qtiDemoData';
3232
import QTIEditor from 'shared/views/QTIEditor/index';
33-
import { QtiInteraction } from 'shared/views/QTIEditor/constants';
33+
import { AssessmentItemTypes } from 'shared/views/QTIEditor/constants';
3434
3535
/**
3636
* Hardcoded items covering different states:
@@ -40,26 +40,22 @@
4040
*/
4141
const INITIAL_ASSESSMENTS = [
4242
{
43-
id: 'demo-item-1',
44-
type: QtiInteraction.CHOICE,
45-
title: 'Which planet is closest to the Sun?',
43+
assessment_id: 'demo-item-1',
44+
type: AssessmentItemTypes.QTI,
4645
raw_data: CHOICE_ITEM_XML,
4746
},
4847
{
49-
id: 'demo-item-2',
50-
type: QtiInteraction.CHOICE,
51-
title: 'Select all the prime numbers.',
48+
assessment_id: 'demo-item-2',
49+
type: AssessmentItemTypes.QTI,
5250
raw_data: MULTI_CHOICE_ITEM_XML,
5351
},
5452
{
55-
id: 'demo-item-3',
56-
type: QtiInteraction.EXTENDED_TEXT,
57-
title: 'Describe the water cycle in your own words.',
53+
assessment_id: 'demo-item-3',
54+
type: AssessmentItemTypes.QTI,
5855
},
5956
{
60-
id: 'demo-item-4',
61-
type: QtiInteraction.ORDER,
62-
title: 'Arrange these events in chronological order.',
57+
assessment_id: 'demo-item-4',
58+
type: AssessmentItemTypes.QTI,
6359
},
6460
];
6561

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { AssessmentItemTypes } from 'shared/views/QTIEditor/constants';
2+
13
/**
24
* Demo item 1: a real choice interaction XML so the full load path can
35
* be verified end-to-end (parseItem → useQtiItem → InteractionSection →
@@ -74,3 +76,26 @@ export const MULTI_CHOICE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
7476
</qti-choice-interaction>
7577
</qti-item-body>
7678
</qti-assessment-item>`;
79+
80+
/**
81+
* 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
85+
*/
86+
export const INITIAL_ASSESSMENTS = [
87+
{
88+
assessment_id: 'demo-item-1',
89+
type: AssessmentItemTypes.QTI,
90+
raw_data: CHOICE_ITEM_XML,
91+
},
92+
{
93+
assessment_id: 'demo-item-2',
94+
type: AssessmentItemTypes.QTI,
95+
raw_data: MULTI_CHOICE_ITEM_XML,
96+
},
97+
{
98+
assessment_id: 'demo-item-3',
99+
type: AssessmentItemTypes.QTI,
100+
},
101+
];

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import InteractionSection from '../index.vue';
55
import {
66
CHOICE_SINGLE_SELECT_XML,
77
UNKNOWN_INTERACTION_XML,
8-
mockInteractionBlock as block,
8+
mockInteractionBlock as interactionBlock,
99
} from '../../../utils/testingFixtures';
1010

1111
const renderSection = (props = {}) =>
@@ -21,26 +21,26 @@ const renderSection = (props = {}) =>
2121
describe('InteractionSection', () => {
2222
describe('choice interaction', () => {
2323
it('renders the prompt from the XML via ChoiceInteractionEditor', () => {
24-
renderSection({ block: block(CHOICE_SINGLE_SELECT_XML) });
24+
renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) });
2525
expect(screen.getByText('Which planet is closest to the Sun?')).toBeInTheDocument();
2626
});
2727

2828
it('renders radio buttons for a single-select choice interaction', () => {
29-
renderSection({ block: block(CHOICE_SINGLE_SELECT_XML) });
29+
renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) });
3030
const radios = screen.getAllByRole('radio');
3131
expect(radios).toHaveLength(3);
3232
});
3333

3434
it('renders the choice labels', () => {
35-
renderSection({ block: block(CHOICE_SINGLE_SELECT_XML) });
35+
renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) });
3636
expect(screen.getByText('Mercury')).toBeInTheDocument();
3737
expect(screen.getByText('Venus')).toBeInTheDocument();
3838
});
3939
});
4040

4141
describe('parse error handling', () => {
4242
it('shows a parse error message and no interaction when XML is malformed', () => {
43-
renderSection({ block: block('not-xml<{{') });
43+
renderSection({ interaction: interactionBlock('not-xml<{{') });
4444
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
4545
// At minimum no interactive elements render
4646
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
@@ -51,7 +51,9 @@ describe('InteractionSection', () => {
5151
describe('unknown interaction type', () => {
5252
it('falls back silently when the interaction tag is unrecognized', () => {
5353
// Should not throw — just renders the fallback component
54-
expect(() => renderSection({ block: block(UNKNOWN_INTERACTION_XML) })).not.toThrow();
54+
expect(() =>
55+
renderSection({ interaction: interactionBlock(UNKNOWN_INTERACTION_XML) }),
56+
).not.toThrow();
5557
});
5658
});
5759
});

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

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
v-else
1313
:key="descriptor.type"
1414
:questionType="questionType"
15-
:block="block"
15+
:interaction="interaction"
1616
:mode="mode"
17+
:showAnswers="showAnswers"
1718
/>
1819
</div>
1920

@@ -22,22 +23,30 @@
2223

2324
<script>
2425
25-
import { computed } from 'vue';
26+
import { computed, watch } from 'vue';
2627
import useInteractionDescriptor from '../../composables/useInteractionDescriptor';
2728
2829
export default {
2930
name: 'InteractionSection',
3031
31-
setup(props) {
32-
const bodyXmlRef = computed(() => props.block.bodyXml);
32+
setup(props, { emit }) {
33+
const bodyXmlRef = computed(() => props.interaction?.bodyXml);
3334
const { descriptor, questionType, parseError } = useInteractionDescriptor(bodyXmlRef);
3435
36+
watch(
37+
questionType,
38+
newType => {
39+
if (newType) emit('update:questionType', newType);
40+
},
41+
{ immediate: true },
42+
);
43+
3544
return { descriptor, questionType, parseError };
3645
},
3746
3847
props: {
3948
/** The raw XML block representing an interaction and its response declarations */
40-
block: {
49+
interaction: {
4150
type: Object,
4251
required: true,
4352
},
@@ -46,7 +55,14 @@
4655
type: String,
4756
default: 'view',
4857
},
58+
/** Whether to display correct answers (used in view mode previews) */
59+
showAnswers: {
60+
type: Boolean,
61+
default: false,
62+
},
4963
},
64+
65+
emits: ['update:questionType'],
5066
};
5167
5268
</script>

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const defaultProps = {
1515
index: 0,
1616
total: 5,
1717
mode: 'view',
18-
displayAnswersPreview: false,
18+
showAnswers: false,
1919
};
2020

2121
const renderComponent = (props = {}, slots = {}) => {
@@ -57,14 +57,14 @@ describe('QTIItemEditor', () => {
5757
});
5858
});
5959

60-
describe('displayAnswersPreview', () => {
61-
test('shows the card body in view mode when displayAnswersPreview is true', () => {
62-
renderComponent({ mode: 'view', displayAnswersPreview: true });
60+
describe('showAnswers', () => {
61+
test('shows the card body in view mode when showAnswers is true', () => {
62+
renderComponent({ mode: 'view', showAnswers: true });
6363
expect(screen.getByText(questionContentPlaceholder$())).toBeInTheDocument();
6464
});
6565

66-
test('does not show the close button even when displayAnswersPreview is true', () => {
67-
renderComponent({ mode: 'view', displayAnswersPreview: true });
66+
test('does not show the close button even when showAnswers is true', () => {
67+
renderComponent({ mode: 'view', showAnswers: true });
6868
expect(screen.queryByRole('button', { name: closeBtnLabel$() })).not.toBeInTheDocument();
6969
});
7070
});

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

Lines changed: 15 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@
2929
<div class="question-card-body">
3030
<InteractionSection
3131
v-if="interactions.length > 0"
32-
:block="interactions[0]"
32+
:interaction="interactions[0]"
3333
:mode="mode"
34-
:displayAnswersPreview="displayAnswersPreview"
34+
:showAnswers="showAnswers"
35+
@update:questionType="type => (currentQuestionType = type)"
3536
/>
3637
<p
3738
v-else
@@ -58,23 +59,12 @@
5859

5960
<script>
6061
61-
import { computed } from 'vue';
62+
import { computed, ref } from 'vue';
6263
import { qtiEditorStrings } from '../../qtiEditorStrings';
63-
import { QtiInteraction } from '../../constants';
64-
import useInteractionDescriptor from '../../composables/useInteractionDescriptor';
64+
import { AssessmentItemTypes, QuestionType } from '../../constants';
6565
import useQtiItem from '../../composables/useQtiItem';
6666
import InteractionSection from '../InteractionSection/index.vue';
6767
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).
70-
const INTERACTION_TYPE_STRING_KEY = {
71-
[QtiInteraction.CHOICE]: 'interactionTypeSingleChoice', // defaults to single choice if no XML yet
72-
[QtiInteraction.ORDER]: 'interactionTypeOrder',
73-
[QtiInteraction.MATCH]: 'interactionTypeMatch',
74-
[QtiInteraction.TEXT_ENTRY]: 'interactionTypeTextEntry',
75-
[QtiInteraction.EXTENDED_TEXT]: 'interactionTypeExtendedText',
76-
};
77-
7868
export default {
7969
name: 'QTIItemEditor',
8070
@@ -98,28 +88,16 @@
9888
}),
9989
);
10090
101-
const firstBlockXml = computed(() =>
102-
interactions.value.length > 0 ? interactions.value[0].bodyXml : null,
103-
);
104-
const { descriptor, questionType } = useInteractionDescriptor(firstBlockXml);
91+
const currentQuestionType = ref(props.item.type || AssessmentItemTypes.QTI);
10592
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-
*/
11293
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$();
94+
if (currentQuestionType.value === QuestionType.SINGLE_SELECT) {
95+
return qtiEditorStrings.interactionTypeSingleChoice$();
96+
}
97+
if (currentQuestionType.value === QuestionType.MULTI_SELECT) {
98+
return qtiEditorStrings.interactionTypeMultipleChoice$();
12099
}
121-
const typeKey = INTERACTION_TYPE_STRING_KEY[props.item.type];
122-
return typeKey ? qtiEditorStrings[`${typeKey}$`]() : interactionTypeUnknown$();
100+
return interactionTypeUnknown$();
123101
});
124102
125103
const questionNumberAndTypeLabel = computed(() =>
@@ -131,6 +109,7 @@
131109
);
132110
133111
return {
112+
currentQuestionType,
134113
interactions,
135114
questionNumberLabel,
136115
questionNumberAndTypeLabel,
@@ -141,7 +120,7 @@
141120
142121
props: {
143122
/**
144-
* Assessment item: { id, type (QtiInteraction value), title, raw_data? }
123+
* Assessment item: { assessment_id, type, raw_data? }
145124
* raw_data is the full QTI XML string; absent on blank newly-created items.
146125
*/
147126
item: {
@@ -165,7 +144,7 @@
165144
validator: val => ['view', 'edit'].includes(val),
166145
},
167146
/** Whether to show answer previews for closed items */
168-
displayAnswersPreview: {
147+
showAnswers: {
169148
type: Boolean,
170149
default: false,
171150
},

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { render } from '@testing-library/vue';
22
import { defineComponent, ref, nextTick } from 'vue';
33
import VueRouter from 'vue-router';
44
import useInteractionDescriptor from '../useInteractionDescriptor';
5-
import { QtiInteraction } from '../../constants';
5+
import { QtiInteraction, QuestionType } from '../../constants';
66

77
import {
88
CHOICE_SINGLE_SELECT_XML,
@@ -45,12 +45,12 @@ describe('useInteractionDescriptor', () => {
4545

4646
it('resolves questionType as singleSelect when max-choices is 1', () => {
4747
const { result } = renderDescriptor(CHOICE_SINGLE_SELECT_XML);
48-
expect(result.questionType.value).toBe('singleSelect');
48+
expect(result.questionType.value).toBe(QuestionType.SINGLE_SELECT);
4949
});
5050

5151
it('resolves questionType as multiSelect when max-choices > 1', () => {
5252
const { result } = renderDescriptor(CHOICE_MULTI_SELECT_XML);
53-
expect(result.questionType.value).toBe('multiSelect');
53+
expect(result.questionType.value).toBe(QuestionType.MULTI_SELECT);
5454
});
5555

5656
it('returns null parseError for valid XML', () => {
@@ -114,19 +114,19 @@ describe('useInteractionDescriptor', () => {
114114
bodyXmlRef.value = CHOICE_SINGLE_SELECT_XML;
115115
await nextTick();
116116

117-
expect(result.questionType.value).toBe('singleSelect');
117+
expect(result.questionType.value).toBe(QuestionType.SINGLE_SELECT);
118118
});
119119

120120
it('recomputes questionType when switching from single-select to multi-select', async () => {
121121
const { result, bodyXmlRef } = renderDescriptor(CHOICE_SINGLE_SELECT_XML);
122122
await nextTick();
123123

124-
expect(result.questionType.value).toBe('singleSelect');
124+
expect(result.questionType.value).toBe(QuestionType.SINGLE_SELECT);
125125

126126
bodyXmlRef.value = CHOICE_MULTI_SELECT_XML;
127127
await nextTick();
128128

129-
expect(result.questionType.value).toBe('multiSelect');
129+
expect(result.questionType.value).toBe(QuestionType.MULTI_SELECT);
130130
});
131131
});
132132
});

0 commit comments

Comments
 (0)