Skip to content

Commit b385729

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 cf14a5d commit b385729

17 files changed

Lines changed: 145 additions & 276 deletions

File tree

contentcuration/contentcuration/frontend/shared/app.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ import { Workbox, messageSW } from 'workbox-window';
109109
import KThemePlugin from 'kolibri-design-system/lib/KThemePlugin';
110110
import trackInputModality from 'kolibri-design-system/lib/styles/trackInputModality';
111111

112+
import Teleport from 'vue2-teleport';
112113
import AnalyticsPlugin from './analytics/plugin';
113114
import { theme, icons } from 'shared/vuetify';
114115

@@ -122,7 +123,6 @@ import ActionLink from 'shared/views/ActionLink';
122123
import Icon from 'shared/views/Icon';
123124
import BaseMenu from 'shared/views/BaseMenu.vue';
124125
import Divider from 'shared/views/Divider';
125-
import Teleport from 'shared/views/QTIEditor/components/Teleport.vue';
126126
import { initializeDB, resetDB } from 'shared/data';
127127
import { Session, injectVuexStore } from 'shared/data/resources';
128128

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

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,36 @@
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+
<Teleport
12+
v-if="teleportTarget"
13+
:to="teleportTarget"
14+
>
15+
<QuestionTypeSelector
16+
v-if="mode === 'edit'"
17+
:questionType="questionType"
18+
:questionTypeOptions="typeOptions"
19+
:settingsTargetId="settingsTargetId"
20+
@update:questionType="
21+
newType => {
22+
questionType = newType;
23+
$emit('update:questionType', newType);
24+
}
25+
"
26+
/>
27+
</Teleport>
28+
29+
<component
30+
:is="descriptor.editorComponent"
31+
:key="descriptor.type"
32+
:questionType="questionType"
33+
:interaction="interaction"
34+
:mode="mode"
35+
:showAnswers="showAnswers"
36+
:teleportTarget="`#${settingsTargetId}`"
37+
@update:interaction="interaction => $emit('update:interaction', interaction)"
38+
/>
39+
</div>
2140
</div>
2241

2342
</template>
@@ -27,13 +46,19 @@
2746
2847
import { computed, watch } from 'vue';
2948
import useInteractionDescriptor from '../../composables/useInteractionDescriptor';
49+
import QuestionTypeSelector from '../QuestionTypeSelector/index.vue';
3050
3151
export default {
3252
name: 'InteractionSection',
3353
54+
components: {
55+
QuestionTypeSelector,
56+
},
57+
3458
setup(props, { emit }) {
3559
const interactionRef = computed(() => props.interaction);
36-
const { descriptor, questionType, parseError } = useInteractionDescriptor(interactionRef);
60+
const { descriptor, questionType, typeOptions, parseError } =
61+
useInteractionDescriptor(interactionRef);
3762
3863
watch(
3964
questionType,
@@ -43,7 +68,14 @@
4368
{ immediate: true },
4469
);
4570
46-
return { descriptor, questionType, parseError };
71+
const settingsTargetId = computed(() => {
72+
if (props.teleportTarget && props.teleportTarget.startsWith('#')) {
73+
return `${props.teleportTarget.substring(1)}-answer-settings`;
74+
}
75+
return 'qti-interaction-settings';
76+
});
77+
78+
return { descriptor, questionType, typeOptions, parseError, settingsTargetId };
4779
},
4880
4981
props: {

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

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { render, screen, fireEvent } from '@testing-library/vue';
22
import VueRouter from 'vue-router';
3-
import QuestionSettingsHeader from '../index.vue';
3+
import QuestionTypeSelector from '../index.vue';
44
import { QuestionType } from '../../../constants';
55
import { qtiEditorStrings as tr } from '../../../qtiEditorStrings';
66

@@ -30,22 +30,17 @@ const defaultProps = {
3030
};
3131

3232
const renderHeader = (props = {}) =>
33-
render(QuestionSettingsHeader, {
33+
render(QuestionTypeSelector, {
3434
props: { ...defaultProps, ...props },
3535
routes: new VueRouter(),
3636
});
3737

38-
describe('QuestionSettingsHeader', () => {
38+
describe('QuestionTypeSelector', () => {
3939
it('renders the type meta-label in edit mode', () => {
4040
renderHeader();
4141
expect(screen.getByText(tr.$tr('typeLabel'))).toBeInTheDocument();
4242
});
4343

44-
it('does not render in view mode', () => {
45-
renderHeader({ mode: 'view' });
46-
expect(screen.queryByText(tr.$tr('typeLabel'))).not.toBeInTheDocument();
47-
});
48-
4944
it('renders a KSelect with the selected option label (not raw enum)', () => {
5045
renderHeader();
5146
expect(screen.getByText(tr.$tr('singleSelectLabel'))).toBeInTheDocument();
@@ -79,18 +74,6 @@ describe('QuestionSettingsHeader', () => {
7974
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
8075
});
8176

82-
it('renders answer settings slot content when provided', () => {
83-
render(QuestionSettingsHeader, {
84-
props: defaultProps,
85-
slots: {
86-
answerSettings: '<div>Answer Settings Content</div>',
87-
},
88-
routes: new VueRouter(),
89-
});
90-
91-
expect(screen.getByText('Answer Settings Content')).toBeInTheDocument();
92-
});
93-
9477
it('disables selector when only one option available', () => {
9578
renderHeader({
9679
questionTypeOptions: [defaultProps.questionTypeOptions[0]],

contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionSettingsHeader/index.vue renamed to contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
<template>
22

33
<div
4-
v-if="mode === 'edit'"
5-
class="question-settings-header"
4+
class="question-type-selector"
65
:class="{ 'small-screen': windowIsSmall }"
76
:style="{ borderBottom: `1px solid ${$themeTokens.fineLine}` }"
87
>
@@ -47,11 +46,9 @@
4746
</div>
4847

4948
<div
50-
v-if="$slots.answerSettings"
49+
:id="settingsTargetId"
5150
class="answer-settings-group"
52-
>
53-
<slot name="answerSettings"></slot>
54-
</div>
51+
></div>
5552

5653
<KModal
5754
v-if="showTypeInfoModal"
@@ -97,7 +94,7 @@
9794
import { qtiEditorStrings } from '../../qtiEditorStrings';
9895
9996
export default {
100-
name: 'QuestionSettingsHeader',
97+
name: 'QuestionTypeSelector',
10198
10299
setup(props) {
103100
const { windowIsSmall } = useKResponsiveWindow();
@@ -142,10 +139,10 @@
142139
typeof opt.description === 'string',
143140
),
144141
},
145-
mode: {
142+
143+
settingsTargetId: {
146144
type: String,
147-
required: true,
148-
validator: val => ['view', 'edit'].includes(val),
145+
default: 'qti-interaction-settings',
149146
},
150147
},
151148
@@ -157,7 +154,7 @@
157154

158155
<style lang="scss" scoped>
159156
160-
.question-settings-header {
157+
.question-type-selector {
161158
display: flex;
162159
flex-wrap: wrap;
163160
gap: 5px;

contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/Teleport.vue

Lines changed: 0 additions & 41 deletions
This file was deleted.

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

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -206,13 +206,10 @@ describe('useChoiceInteraction', () => {
206206
);
207207

208208
const parser = new DOMParser();
209-
const doc = parser.parseFromString(
210-
'<html><body>' + bodyXml.value + '</body></html>',
211-
'text/html',
212-
);
209+
const doc = parser.parseFromString(bodyXml.value, 'text/xml');
213210
const interaction = doc.querySelector('qti-choice-interaction');
214211

215-
expect(interaction).toHaveAttribute('max-choices', '2');
212+
expect(interaction?.getAttribute('max-choices')).toBe('2');
216213
});
217214

218215
it('when showAnswerCount is false, max-choices is 0', () => {
@@ -228,13 +225,10 @@ describe('useChoiceInteraction', () => {
228225
setShowAnswerCount(false);
229226

230227
const parser = new DOMParser();
231-
const doc = parser.parseFromString(
232-
'<html><body>' + bodyXml.value + '</body></html>',
233-
'text/html',
234-
);
228+
const doc = parser.parseFromString(bodyXml.value, 'text/xml');
235229
const interaction = doc.querySelector('qti-choice-interaction');
236230

237-
expect(interaction).toHaveAttribute('max-choices', '0');
231+
expect(interaction?.getAttribute('max-choices')).toBe('0');
238232
});
239233

240234
it('updates automatically when correct answers change and showAnswerCount is true', () => {
@@ -249,20 +243,17 @@ describe('useChoiceInteraction', () => {
249243

250244
// Initially 1 correct answer
251245
let parser = new DOMParser();
252-
let doc = parser.parseFromString(
253-
'<html><body>' + bodyXml.value + '</body></html>',
254-
'text/html',
255-
);
246+
let doc = parser.parseFromString(bodyXml.value, 'text/xml');
256247
let interaction = doc.querySelector('qti-choice-interaction');
257-
expect(interaction).toHaveAttribute('max-choices', '1');
248+
expect(interaction?.getAttribute('max-choices')).toBe('1');
258249

259250
// Toggle second answer correct
260251
toggleCorrectChoice('b');
261252

262253
parser = new DOMParser();
263-
doc = parser.parseFromString('<html><body>' + bodyXml.value + '</body></html>', 'text/html');
254+
doc = parser.parseFromString(bodyXml.value, 'text/xml');
264255
interaction = doc.querySelector('qti-choice-interaction');
265-
expect(interaction).toHaveAttribute('max-choices', '2');
256+
expect(interaction?.getAttribute('max-choices')).toBe('2');
266257
});
267258
});
268259
});

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,9 @@ export default function useInteractionDescriptor(interactionRef) {
6767
registry[DEFAULT_INTERACTION],
6868
);
6969

70-
return { descriptor, questionType, parseError };
70+
const typeOptions = computed(() => {
71+
return descriptors.flatMap(d => d.getTypeOptions?.(qtiEditorStrings) ?? []);
72+
});
73+
74+
return { descriptor, questionType, typeOptions, parseError };
7175
}

contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,21 @@ export class ChoiceInteractionDescriptor {
1515
this.convertsFrom = [];
1616
}
1717

18+
getTypeOptions(tr) {
19+
return [
20+
{
21+
value: QuestionType.SINGLE_SELECT,
22+
label: tr.singleSelectLabel$(),
23+
description: tr.singleChoiceDescription$(),
24+
},
25+
{
26+
value: QuestionType.MULTI_SELECT,
27+
label: tr.multiSelectLabel$(),
28+
description: tr.multipleSelectionDescription$(),
29+
},
30+
];
31+
}
32+
1833
/** @param {Element} el */
1934
matches(el) {
2035
return el.tagName.toLowerCase() === QtiInteraction.CHOICE;

0 commit comments

Comments
 (0)