Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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,8 @@
:interaction="interaction"
:mode="mode"
:showAnswers="showAnswers"
@update:bodyXml="xml => $emit('update:bodyXml', xml)"
@update:responseDeclarations="decls => $emit('update:responseDeclarations', decls)"
/>
</div>

Expand Down Expand Up @@ -67,7 +69,7 @@
},
},

emits: ['update:questionType'],
emits: ['update:questionType', 'update:bodyXml', 'update:responseDeclarations'],
};

</script>
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
:mode="mode"
:showAnswers="showAnswers"
@update:questionType="type => (currentQuestionType = type)"
@update:bodyXml="onBodyXmlUpdate"
@update:responseDeclarations="onResponseDeclarationsUpdate"
/>
<p
v-else
Expand Down Expand Up @@ -63,14 +65,15 @@
import { qtiEditorStrings } from '../../qtiEditorStrings';
import { QuestionType } from '../../constants';
import useQtiItem from '../../composables/useQtiItem';
import { reassembleItemXml } from '../../serialization/parseItem';
import InteractionSection from '../InteractionSection/index.vue';

export default {
name: 'QTIItemEditor',

components: { InteractionSection },

setup(props) {
setup(props, { emit }) {
const {
questionNumberLabel$,
questionNumberAndTypeLabel$,
Expand All @@ -79,7 +82,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 +119,47 @@
}),
);

/**
* Track the current bodyXml and responseDeclarations for the interaction.
* Initialized from the parsed item; updated when the editor emits changes.
*/
const currentBodyXml = ref(
interactions.value.length > 0 ? interactions.value[0].bodyXml : '',
);
const currentResponseDeclarations = ref(
interactions.value.length > 0 ? interactions.value[0].responseDeclarations : [],
);

function onBodyXmlUpdate(xml) {
currentBodyXml.value = xml;
emitRawData();
}

function onResponseDeclarationsUpdate(decls) {
currentResponseDeclarations.value = decls;
emitRawData();
}

function emitRawData() {
const newRawData = reassembleItemXml({
identifier: identifier.value,
title: title.value,
language: language.value,
bodyXml: currentBodyXml.value,
responseDeclarations: currentResponseDeclarations.value,
});
emit('update:rawData', newRawData);
}

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

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

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

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

<p
v-if="show"
class="validation-message"
role="alert"
:style="{ color: $themePalette.red.v_700 }"
>
<slot></slot>
</p>

</template>


<script>

export default {
name: 'ValidationMessage',

props: {
/** Whether the message is currently visible. */
show: {
type: Boolean,
default: false,
},
},
};

</script>


<style scoped>

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

</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
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(answers, questionType = QuestionType.SINGLE_SELECT) {
const maxChoices = questionType === QuestionType.SINGLE_SELECT ? 1 : 2;
const correctIds = answers.filter(a => a.correct).map(a => a.id);

const bodyXml = `<qti-choice-interaction response-identifier="RESPONSE" max-choices="${maxChoices}">
${answers.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(answers, questionType = QuestionType.SINGLE_SELECT) {
const qt = ref(questionType);
const block = makeBlock(answers, questionType);
return { qt, ...useChoiceInteraction(block, qt) };
}

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

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

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

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

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

it('is a no-op when only one answer remains', () => {
const { state, removeChoice } = setup([makeAnswer({ id: 'a' })]);
removeChoice('a');
expect(state.value.answers).toHaveLength(1);
});
});

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

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

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

it('is a no-op when the answer is last', () => {
const { state, moveChoiceDown } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
moveChoiceDown('b');
expect(state.value.answers.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.answers.find(a => a.id === 'b').correct).toBe(true);
expect(state.value.answers.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.answers.find(a => a.id === 'b').correct).toBe(true);
expect(state.value.answers.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.answers.find(a => a.id === 'a').correct).toBe(false);
expect(state.value.answers.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 answer only', () => {
const { state, setChoiceContent } = setup([
makeAnswer({ id: 'a', content: 'Old' }),
makeAnswer({ id: 'b', content: 'Unchanged' }),
]);
setChoiceContent('a', 'New content');
expect(state.value.answers.find(a => a.id === 'a').content).toBe('New content');
expect(state.value.answers.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);
});
});

describe('setOrientation()', () => {
it('updates the orientation field', () => {
const { state, setOrientation } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
setOrientation('horizontal');
expect(state.value.orientation).toBe('horizontal');
});
});

describe('setMaxChoices()', () => {
it('updates the maxChoices field', () => {
const { state, setMaxChoices } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
setMaxChoices(3);
expect(state.value.maxChoices).toBe(3);
});
});
});
Loading
Loading