Skip to content

Commit 02230b4

Browse files
authored
Merge pull request #6059 from Abhishek-Punhani/question-selector
feat: add AnswerSettings and QuestionSettingsHeader components
2 parents 93d3acd + 5030c92 commit 02230b4

21 files changed

Lines changed: 1214 additions & 97 deletions

File tree

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

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
1-
import { render, screen } from '@testing-library/vue';
1+
import { render, screen, fireEvent, within } from '@testing-library/vue';
22
import { nextTick } from 'vue';
33
import VueRouter from 'vue-router';
44
import InteractionSection from '../index.vue';
5+
import { qtiEditorStrings as tr } from '../../../qtiEditorStrings';
56

67
import {
78
CHOICE_SINGLE_SELECT_XML,
9+
CHOICE_MULTI_SELECT_XML,
810
UNKNOWN_INTERACTION_XML,
911
mockInteractionBlock as interactionBlock,
1012
} from '../../../utils/testingFixtures';
1113

1214
jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
15+
jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
16+
const { ref } = require('vue');
17+
return {
18+
__esModule: true,
19+
default: () => ({ windowIsSmall: ref(false) }),
20+
};
21+
});
1322

1423
const renderSection = (props = {}) =>
1524
render(InteractionSection, {
@@ -36,12 +45,72 @@ describe('InteractionSection', () => {
3645
expect(screen.getByText('Mercury')).toBeInTheDocument();
3746
expect(screen.getByText('Venus')).toBeInTheDocument();
3847
});
48+
49+
it('teleports answer settings into the question type selector header', async () => {
50+
renderSection({ interaction: interactionBlock(CHOICE_MULTI_SELECT_XML) });
51+
await nextTick();
52+
const targetDiv = document.querySelector('.answer-settings-group');
53+
expect(targetDiv).toBeInTheDocument();
54+
expect(
55+
within(targetDiv).getByRole('checkbox', { name: tr.$tr('shuffleAnswersLabel') }),
56+
).toBeInTheDocument();
57+
});
3958
});
4059

4160
describe('parse error handling', () => {
4261
it('shows a parse error when XML is malformed', () => {
4362
renderSection({ interaction: interactionBlock('not-xml<{{') });
44-
expect(screen.getByText('This question could not be loaded')).toBeInTheDocument();
63+
expect(screen.getByText(tr.$tr('errorParsingQuestion'))).toBeInTheDocument();
64+
});
65+
});
66+
67+
describe('type switching', () => {
68+
it('preserves the prompt but resets choices when switching from choice to text-entry', async () => {
69+
const Wrapper = {
70+
components: { InteractionSection },
71+
template: `
72+
<InteractionSection
73+
mode="edit"
74+
:interaction="interactionBlock"
75+
@update:interaction="onUpdate"
76+
/>
77+
`,
78+
data() {
79+
return {
80+
interactionBlock: {
81+
bodyXml: CHOICE_SINGLE_SELECT_XML,
82+
responseDeclarations: [],
83+
},
84+
};
85+
},
86+
methods: {
87+
onUpdate(val) {
88+
this.interactionBlock = val;
89+
this.$emit('wrapper-update', val);
90+
},
91+
},
92+
};
93+
94+
const { emitted } = render(Wrapper, {
95+
routes: new VueRouter(),
96+
});
97+
98+
await nextTick();
99+
100+
const selectedOption = screen.getAllByText(tr.$tr('singleSelectLabel'))[0];
101+
await fireEvent.click(selectedOption);
102+
103+
const textEntryOption = screen.getByText(tr.$tr('textEntryLabel'));
104+
await fireEvent.click(textEntryOption);
105+
106+
await nextTick();
107+
108+
const emits = emitted()['wrapper-update'];
109+
const switchXml = emits.at(-1)[0].bodyXml;
110+
111+
expect(switchXml).toContain('Which planet is closest to the Sun?');
112+
expect(switchXml).toContain('<qti-text-entry-interaction');
113+
expect(switchXml).not.toContain('Mercury');
45114
});
46115
});
47116

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

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +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-
@update:interaction="interaction => $emit('update:interaction', interaction)"
19-
/>
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>
2029
</div>
2130

2231
</template>
@@ -26,10 +35,17 @@
2635
2736
import { computed, watch } from 'vue';
2837
import useInteractionDescriptor from '../../composables/useInteractionDescriptor';
38+
import QuestionTypeSelector from '../QuestionTypeSelector/index.vue';
39+
import { generateRandomSlug } from '../../utils/generateRandomSlug';
40+
import { descriptors } from '../../interactions';
2941
3042
export default {
3143
name: 'InteractionSection',
3244
45+
components: {
46+
QuestionTypeSelector,
47+
},
48+
3349
setup(props, { emit }) {
3450
const interactionRef = computed(() => props.interaction);
3551
const { descriptor, questionType, parseError } = useInteractionDescriptor(interactionRef);
@@ -42,7 +58,37 @@
4258
{ immediate: true },
4359
);
4460
45-
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+
};
4692
},
4793
4894
props: {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ import { qtiEditorStrings } from '../../../qtiEditorStrings';
55
import { AssessmentItemTypes } from '../../../constants';
66

77
jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
8+
jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
9+
const { ref } = require('vue');
10+
return {
11+
__esModule: true,
12+
default: () => ({ windowIsSmall: ref(false) }),
13+
};
14+
});
815

916
const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings;
1017

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
<div class="question-card-body">
3030
<InteractionSection
3131
v-if="interactions.length > 0"
32-
:interaction="interactions[0]"
32+
:interaction="currentInteraction"
3333
:mode="mode"
3434
:showAnswers="showAnswers"
3535
@update:questionType="type => (currentQuestionType = type)"
@@ -103,6 +103,11 @@
103103
currentResponseDeclarations.value = interactions.value[0].responseDeclarations;
104104
}
105105
106+
const currentInteraction = computed(() => ({
107+
bodyXml: currentBodyXml.value,
108+
responseDeclarations: currentResponseDeclarations.value,
109+
}));
110+
106111
const questionNumberLabel = computed(() =>
107112
questionNumberLabel$({
108113
number: props.index + 1,
@@ -155,6 +160,7 @@
155160
return {
156161
currentQuestionType,
157162
interactions,
163+
currentInteraction,
158164
questionNumberLabel,
159165
questionNumberAndTypeLabel,
160166
closeBtnLabel$,
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)