Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
mockInteractionBlock as interactionBlock,
} from '../../../utils/testingFixtures';

jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');

const renderSection = (props = {}) =>
render(InteractionSection, {
props: { mode: 'edit', ...props },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
:interaction="interaction"
:mode="mode"
:showAnswers="showAnswers"
@update:interaction="interaction => $emit('update:interaction', interaction)"
/>
</div>

Expand All @@ -30,8 +31,8 @@
name: 'InteractionSection',

setup(props, { emit }) {
const bodyXmlRef = computed(() => props.interaction?.bodyXml);
const { descriptor, questionType, parseError } = useInteractionDescriptor(bodyXmlRef);
const interactionRef = computed(() => props.interaction);
const { descriptor, questionType, parseError } = useInteractionDescriptor(interactionRef);

watch(
questionType,
Expand Down Expand Up @@ -67,7 +68,7 @@
},
},

emits: ['update:questionType'],
emits: ['update:questionType', 'update:interaction'],
};

</script>
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
:mode="mode"
:showAnswers="showAnswers"
@update:questionType="type => (currentQuestionType = type)"
@update:interaction="onUpdateInteraction"
/>
<p
v-else
Expand All @@ -59,18 +60,19 @@

<script>

import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { qtiEditorStrings } from '../../qtiEditorStrings';
import { QuestionType } from '../../constants';
import useQtiItem from '../../composables/useQtiItem';
import { assembleItemXml } from '../../serialization/assembleItem';
import InteractionSection from '../InteractionSection/index.vue';

export default {
name: 'QTIItemEditor',

components: { InteractionSection },

setup(props) {
setup(props, { emit }) {
const {
questionNumberLabel$,
questionNumberAndTypeLabel$,
Expand All @@ -79,7 +81,7 @@
unknownTypeLabel$,
} = qtiEditorStrings;

const { interactions } = useQtiItem(props.item.raw_data);
const { identifier, title, language, interactions } = useQtiItem(props.item.raw_data);

const questionNumberLabel = computed(() =>
questionNumberLabel$({
Expand Down Expand Up @@ -116,13 +118,54 @@
}),
);

/**
* Track the current bodyXml and responseDeclarations for the interaction.
* Initialised from the parsed item; updated atomically when the editor
* emits update:interaction.
*
* Note: We are currently using only the first interaction, but this
* architecture is designed to be expansible to multiple interactions
* per question in the future.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a comment here that we are currently using only the first interaction, but can be expansible to multiple interactions per question in the future?

const currentBodyXml = ref(
interactions.value.length > 0 ? interactions.value[0].bodyXml : '',
);
const currentResponseDeclarations = ref(
interactions.value.length > 0 ? interactions.value[0].responseDeclarations : [],
);

const rawData = computed(() =>
assembleItemXml({
identifier: identifier.value,
title: title.value,
language: language.value,
bodyXml: currentBodyXml.value,
responseDeclarations: currentResponseDeclarations.value,
}),
);

// Emit only when the assembled XML actually changes after initial mount
watch(rawData, newVal => {
if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line no-console
console.log('[QTIItemEditor] assembled XML:\n', newVal);
}
emit('update:rawData', newVal);
});

function onUpdateInteraction({ bodyXml, responseDeclarations }) {
currentBodyXml.value = bodyXml;
currentResponseDeclarations.value = responseDeclarations;
}

return {
currentQuestionType,
interactions,
questionNumberLabel,
questionNumberAndTypeLabel,
closeBtnLabel$,
questionContentPlaceholder$,
onUpdateInteraction,
};
},

Expand Down Expand Up @@ -158,7 +201,7 @@
},
},

emits: ['close'],
emits: ['close', 'update:rawData'],
};

</script>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<template>

<div>
<p
class="validation-message"
role="alert"
:style="{ color: $themeTokens.error }"
>
<slot></slot>
</p>
</div>

</template>


<script>

export default {
name: 'ValidationMessage',
};

</script>


<style scoped>

.validation-message {
margin: 2px 0 0;
font-size: 14px;
line-height: 1.4;
}

</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { ref } from 'vue';
import { useChoiceInteraction } from '../useChoiceInteraction';
import { QuestionType } from '../../constants';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeAnswer(overrides = {}) {
return { id: 'choice_a', content: 'A', correct: false, fixed: false, ...overrides };
}

function makeBlock(choices, questionType = QuestionType.SINGLE_SELECT) {
const maxChoices = questionType === QuestionType.SINGLE_SELECT ? 1 : 2;
const correctIds = choices.filter(a => a.correct).map(a => a.id);

const bodyXml = `<qti-choice-interaction response-identifier="RESPONSE" max-choices="${maxChoices}">
${choices.map(a => `<qti-simple-choice identifier="${a.id}">${a.content}</qti-simple-choice>`).join('\n ')}
</qti-choice-interaction>`;

const declaration = `<qti-response-declaration identifier="RESPONSE"
cardinality="${questionType === QuestionType.SINGLE_SELECT ? 'single' : 'multiple'}"
base-type="identifier">
<qti-correct-response>
${correctIds.map(id => `<qti-value>${id}</qti-value>`).join('')}
</qti-correct-response>
</qti-response-declaration>`;

return { bodyXml, responseDeclarations: [declaration] };
}

function setup(choices, questionType = QuestionType.SINGLE_SELECT) {
const qt = ref(questionType);
const block = makeBlock(choices, questionType);
return { qt, ...useChoiceInteraction(block, qt) };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('useChoiceInteraction', () => {
describe('addChoice()', () => {
it('appends a new choice to the list', () => {
const { state, addChoice } = setup([
makeAnswer({ id: 'a', content: 'A' }),
makeAnswer({ id: 'b', content: 'B' }),
]);
addChoice();
expect(state.value.choices).toHaveLength(3);
});

it('new choice has a generated "choice_" identifier', () => {
const { state, addChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
addChoice();
const newAnswer = state.value.choices[2];
expect(newAnswer.id).toMatch(/^choice_[a-z0-9]{8}$/);
});

it('new choice has empty content and correct: false', () => {
const { state, addChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
addChoice();
const newAnswer = state.value.choices[2];
expect(newAnswer.content).toBe('');
expect(newAnswer.correct).toBe(false);
});
});

describe('removeChoice()', () => {
it('removes the choice with the given id', () => {
const { state, removeChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
removeChoice('a');
expect(state.value.choices.find(a => a.id === 'a')).toBeUndefined();
});

it('is a no-op when only one choice remains', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, for when we add a new question (i.e. assessment item) to the list, there should be at least one choice, too right?

const { state, removeChoice } = setup([makeAnswer({ id: 'a' })]);
removeChoice('a');
expect(state.value.choices).toHaveLength(1);
});
});

describe('moveChoiceUp()', () => {
it('swaps choice with the previous one', () => {
const { state, moveChoiceUp } = setup([
makeAnswer({ id: 'a' }),
makeAnswer({ id: 'b' }),
makeAnswer({ id: 'c' }),
]);
moveChoiceUp('b');
expect(state.value.choices.map(a => a.id)).toEqual(['b', 'a', 'c']);
});

it('is a no-op when the choice is first', () => {
const { state, moveChoiceUp } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
moveChoiceUp('a');
expect(state.value.choices.map(a => a.id)).toEqual(['a', 'b']);
});
});

describe('moveChoiceDown()', () => {
it('swaps choice with the next one', () => {
const { state, moveChoiceDown } = setup([
makeAnswer({ id: 'a' }),
makeAnswer({ id: 'b' }),
makeAnswer({ id: 'c' }),
]);
moveChoiceDown('b');
expect(state.value.choices.map(a => a.id)).toEqual(['a', 'c', 'b']);
});

it('is a no-op when the choice is last', () => {
const { state, moveChoiceDown } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
moveChoiceDown('b');
expect(state.value.choices.map(a => a.id)).toEqual(['a', 'b']);
});
});

describe('toggleCorrectChoice()', () => {
it('singleSelect: sets only the target as correct and clears others', () => {
const { state, toggleCorrectChoice, qt } = setup([
makeAnswer({ id: 'a', correct: true }),
makeAnswer({ id: 'b', correct: false }),
]);
qt.value = QuestionType.SINGLE_SELECT;
toggleCorrectChoice('b');
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false);
});

it('multiSelect: toggles only the target, leaves others unchanged', () => {
const { state, toggleCorrectChoice, qt } = setup(
[makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false })],
QuestionType.MULTI_SELECT,
);
qt.value = QuestionType.MULTI_SELECT;
toggleCorrectChoice('b');
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(true);
});

it('multiSelect: toggles correct off when already correct', () => {
const { state, toggleCorrectChoice, qt } = setup(
[makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })],
QuestionType.MULTI_SELECT,
);
qt.value = QuestionType.MULTI_SELECT;
toggleCorrectChoice('a');
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false);
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
});
});

describe('setPrompt()', () => {
it('updates the prompt field', () => {
const { state, setPrompt } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
setPrompt('<p>New prompt</p>');
expect(state.value.prompt).toBe('<p>New prompt</p>');
});
});

describe('setChoiceContent()', () => {
it('updates content for the target choice only', () => {
const { state, setChoiceContent } = setup([
makeAnswer({ id: 'a', content: 'Old' }),
makeAnswer({ id: 'b', content: 'Unchanged' }),
]);
setChoiceContent('a', 'New content');
expect(state.value.choices.find(a => a.id === 'a').content).toBe('New content');
expect(state.value.choices.find(a => a.id === 'b').content).toBe('Unchanged');
});
});

describe('setShuffle()', () => {
it('updates the shuffle flag', () => {
const { state, setShuffle } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
setShuffle(true);
expect(state.value.shuffle).toBe(true);
});
});
});
Loading
Loading