Skip to content

Commit adeb9ce

Browse files
refactor: replace custom Teleport component with vue2-teleport and integrate question type selection into QTI editor sections
Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
1 parent 99773af commit adeb9ce

22 files changed

Lines changed: 457 additions & 501 deletions

File tree

contentcuration/contentcuration/frontend/shared/app.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,6 @@ import ActionLink from 'shared/views/ActionLink';
122122
import Icon from 'shared/views/Icon';
123123
import BaseMenu from 'shared/views/BaseMenu.vue';
124124
import Divider from 'shared/views/Divider';
125-
import Teleport from 'shared/views/QTIEditor/components/Teleport.vue';
126125
import { initializeDB, resetDB } from 'shared/data';
127126
import { Session, injectVuexStore } from 'shared/data/resources';
128127

@@ -260,7 +259,6 @@ Vue.component('ActionLink', ActionLink);
260259
Vue.component('BaseMenu', BaseMenu);
261260
Vue.component('Divider', Divider);
262261
Vue.component('Icon', Icon);
263-
Vue.component('Teleport', Teleport);
264262

265263
function initiateServiceWorker() {
266264
// Second conditional must be removed if you are doing dev work on the service

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
2121

2222
const renderSection = (props = {}) =>
2323
render(InteractionSection, {
24-
props: { mode: 'edit', ...props },
24+
props: { mode: 'edit', targetId: 'test-target', ...props },
2525
routes: new VueRouter(),
2626
});
2727

@@ -53,6 +53,35 @@ describe('InteractionSection', () => {
5353
});
5454
});
5555

56+
describe('type switching', () => {
57+
it('preserves the prompt but resets choices when switching from choice to text-entry', async () => {
58+
const { emitted } = render(InteractionSection, {
59+
props: {
60+
mode: 'edit',
61+
interaction: {
62+
bodyXml: CHOICE_SINGLE_SELECT_XML,
63+
responseDeclarations: [],
64+
},
65+
},
66+
routes: new VueRouter(),
67+
});
68+
69+
await nextTick();
70+
71+
// QuestionTypeSelector emits update:questionType when type changes
72+
const selector = screen.getByRole('group', { name: tr.$tr('typeLabel') });
73+
expect(selector).toBeInTheDocument();
74+
75+
// The emitted interaction when the component mounts should contain the prompt
76+
const initialEmits = emitted()['update:interaction'] || [];
77+
expect(initialEmits.length).toBeGreaterThan(0);
78+
const initialXml = initialEmits[initialEmits.length - 1][0].bodyXml;
79+
expect(initialXml).toContain('Which planet is closest to the Sun?');
80+
// Choices should be present in initial XML
81+
expect(initialXml).toContain('Mercury');
82+
});
83+
});
84+
5685
describe('unknown interaction type', () => {
5786
it('falls back silently when the interaction tag is unrecognized', () => {
5887
// Should not throw — just renders the fallback component

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

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,25 @@
77
>
88
{{ parseError }}
99
</p>
10-
<component
11-
:is="descriptor.editorComponent"
12-
v-else
13-
:key="descriptor.type"
14-
:questionType="questionType"
15-
:interaction="interaction"
16-
:mode="mode"
17-
:showAnswers="showAnswers"
18-
:teleportTarget="teleportTarget"
19-
@update:interaction="interaction => $emit('update:interaction', interaction)"
20-
/>
10+
<div v-else>
11+
<QuestionTypeSelector
12+
v-if="mode === 'edit'"
13+
:questionType="questionType"
14+
:settingsTargetId="settingsTargetId"
15+
@update:questionType="onUpdateQuestionType"
16+
/>
17+
18+
<component
19+
:is="descriptor.editorComponent"
20+
:key="descriptor.type"
21+
:questionType="questionType"
22+
:interaction="interaction"
23+
:mode="mode"
24+
:showAnswers="showAnswers"
25+
:teleportTargetId="settingsTargetId"
26+
@update:interaction="onUpdateInteraction"
27+
/>
28+
</div>
2129
</div>
2230

2331
</template>
@@ -27,10 +35,17 @@
2735
2836
import { computed, watch } from 'vue';
2937
import useInteractionDescriptor from '../../composables/useInteractionDescriptor';
38+
import QuestionTypeSelector from '../QuestionTypeSelector/index.vue';
39+
import { generateRandomSlug } from '../../utils/generateRandomSlug';
40+
import { descriptors } from '../../interactions';
3041
3142
export default {
3243
name: 'InteractionSection',
3344
45+
components: {
46+
QuestionTypeSelector,
47+
},
48+
3449
setup(props, { emit }) {
3550
const interactionRef = computed(() => props.interaction);
3651
const { descriptor, questionType, parseError } = useInteractionDescriptor(interactionRef);
@@ -43,7 +58,37 @@
4358
{ immediate: true },
4459
);
4560
46-
return { descriptor, questionType, parseError };
61+
const onUpdateQuestionType = newType => {
62+
const newDescriptor = descriptors.find(d => d.questionTypes.includes(newType));
63+
if (newDescriptor && newDescriptor !== descriptor.value) {
64+
const oldState = descriptor.value.parse(
65+
props.interaction.bodyXml,
66+
props.interaction.responseDeclarations,
67+
);
68+
const freshState = newDescriptor.parse('', []);
69+
const withPrompt = { ...freshState, prompt: oldState.prompt ?? '' };
70+
const newInteraction = newDescriptor.buildXML(withPrompt, newType);
71+
emit('update:interaction', newInteraction);
72+
}
73+
74+
questionType.value = newType;
75+
emit('update:questionType', newType);
76+
};
77+
78+
const onUpdateInteraction = updatedInteraction => {
79+
emit('update:interaction', updatedInteraction);
80+
};
81+
82+
const settingsTargetId = generateRandomSlug('answer-settings');
83+
84+
return {
85+
descriptor,
86+
questionType,
87+
parseError,
88+
onUpdateQuestionType,
89+
onUpdateInteraction,
90+
settingsTargetId,
91+
};
4792
},
4893
4994
props: {
@@ -67,10 +112,6 @@
67112
type: Boolean,
68113
default: false,
69114
},
70-
teleportTarget: {
71-
type: String,
72-
default: '',
73-
},
74115
},
75116
76117
emits: ['update:questionType', 'update:interaction'],

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

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

29-
<div :id="`qti-question-settings-${index}`"></div>
30-
3129
<div class="question-card-body">
3230
<InteractionSection
3331
v-if="interactions.length > 0"
34-
:interaction="interactions[0]"
32+
:interaction="currentInteraction"
3533
:mode="mode"
3634
:showAnswers="showAnswers"
37-
:teleportTarget="`#qti-question-settings-${index}`"
3835
@update:questionType="type => (currentQuestionType = type)"
3936
@update:interaction="onUpdateInteraction"
4037
/>
@@ -106,6 +103,11 @@
106103
currentResponseDeclarations.value = interactions.value[0].responseDeclarations;
107104
}
108105
106+
const currentInteraction = computed(() => ({
107+
bodyXml: currentBodyXml.value,
108+
responseDeclarations: currentResponseDeclarations.value,
109+
}));
110+
109111
const questionNumberLabel = computed(() =>
110112
questionNumberLabel$({
111113
number: props.index + 1,
@@ -158,6 +160,7 @@
158160
return {
159161
currentQuestionType,
160162
interactions,
163+
currentInteraction,
161164
questionNumberLabel,
162165
questionNumberAndTypeLabel,
163166
closeBtnLabel$,

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

Lines changed: 0 additions & 103 deletions
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { render, screen, fireEvent, within } from '@testing-library/vue';
2+
import VueRouter from 'vue-router';
3+
import QuestionTypeSelector from '../index.vue';
4+
import { QuestionType } from '../../../constants';
5+
import { qtiEditorStrings as tr } from '../../../qtiEditorStrings';
6+
7+
const defaultProps = {
8+
questionType: QuestionType.SINGLE_SELECT,
9+
settingsTargetId: 'test-settings-target',
10+
};
11+
12+
const renderHeader = (props = {}) =>
13+
render(QuestionTypeSelector, {
14+
props: { ...defaultProps, ...props },
15+
routes: new VueRouter(),
16+
});
17+
18+
describe('QuestionTypeSelector', () => {
19+
it('renders the type meta-label in edit mode', () => {
20+
renderHeader();
21+
expect(screen.getByText(tr.$tr('typeLabel'))).toBeInTheDocument();
22+
});
23+
24+
it('renders a KSelect with the selected option label (not raw enum)', () => {
25+
renderHeader();
26+
expect(screen.getByText(tr.$tr('singleSelectLabel'))).toBeInTheDocument();
27+
expect(screen.queryByText(QuestionType.SINGLE_SELECT)).not.toBeInTheDocument();
28+
});
29+
30+
it('renders the selected type label inside the type group', () => {
31+
renderHeader();
32+
const group = screen.getByRole('group', { name: tr.$tr('typeLabel') });
33+
expect(within(group).getByText(tr.$tr('singleSelectLabel'))).toBeInTheDocument();
34+
});
35+
36+
it('opens type info modal when info button clicked', async () => {
37+
renderHeader();
38+
39+
const helpButton = screen.getByRole('button', { name: tr.$tr('responseTypeInfoTitle') });
40+
await fireEvent.click(helpButton);
41+
42+
expect(screen.getByRole('dialog')).toBeInTheDocument();
43+
expect(screen.getByText(tr.$tr('singleChoiceDescription'))).toBeInTheDocument();
44+
expect(screen.getByText(tr.$tr('multipleSelectionDescription'))).toBeInTheDocument();
45+
});
46+
47+
it('closes type info modal when Close button clicked', async () => {
48+
renderHeader();
49+
50+
await fireEvent.click(screen.getByRole('button', { name: tr.$tr('responseTypeInfoTitle') }));
51+
expect(screen.getByRole('dialog')).toBeInTheDocument();
52+
53+
await fireEvent.click(screen.getByRole('button', { name: tr.$tr('closeBtnLabel') }));
54+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
55+
});
56+
57+
it('emits update:questionType when a new type is selected', async () => {
58+
const { emitted } = renderHeader();
59+
60+
// Click the currently selected option to open the dropdown
61+
await fireEvent.click(screen.getByText(tr.$tr('singleSelectLabel')));
62+
63+
// Click the new option from the dropdown menu
64+
await fireEvent.click(screen.getByText(tr.$tr('multiSelectLabel')));
65+
66+
expect(emitted()['update:questionType']).toBeTruthy();
67+
expect(emitted()['update:questionType'][0]).toEqual([QuestionType.MULTI_SELECT]);
68+
});
69+
});

0 commit comments

Comments
 (0)