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 @@ -82,8 +82,10 @@ describe('validateQtiItem', () => {
expect(validateQtiItem(xml, { allowFreeResponse: false })).toEqual([]);
});

it('does not apply the editor rules to an interaction with no editor', () => {
expect(validateQtiItem(INLINE_CHOICE_ITEM_DOCUMENT)).toEqual([]);
it('runs the inline choice validator', () => {
expect(validateQtiItem(INLINE_CHOICE_ITEM_DOCUMENT)).toEqual([
{ code: ValidationError.NO_CORRECT_ANSWER, id: 'RESPONSE_2' },
]);
});

it('reports an item with no raw data at all', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,29 @@ describe('InteractionSection', () => {
});
});

describe('switching to inline choice', () => {
it('keeps the prompt as the question and shows the passage editor', async () => {
const { emitted } = render(SwitchableSection, { routes: new VueRouter() });
await nextTick();

await fireEvent.click(screen.getAllByText(tr.$tr('singleSelectLabel'))[0]);
await fireEvent.click(screen.getByText(tr.$tr('inlineChoiceLabel')));
await nextTick();

const switched = emitted()['wrapper-update'].at(-1)[0];
expect(switched.bodyXml).toContain('Which planet is closest to the Sun?');
expect(switched.bodyXml).toContain('<qti-inline-choice-interaction');
expect(switched.bodyXml).not.toContain('Mercury');
expect(
screen.getByRole('heading', { name: tr.$tr('questionOptionalLabel') }),
).toBeInTheDocument();
const passage = screen.getByRole('heading', { name: tr.$tr('passageEditorLabel') });
expect(
within(passage.parentElement).getByText(tr.$tr('passageEditorDescription')),
).toBeInTheDocument();
});
});

describe('unknown interaction type', () => {
it('renders nothing editable', () => {
renderSection({ interaction: interactionBlock(UNKNOWN_INTERACTION_XML), mode: 'edit' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const {
hintsLabel$,
associateLabel$,
matchLabel$,
inlineChoiceLabel$,
passageEditorLabel$,
questionNumberAndTypeLabel$,
unknownTypeLabel$,
responsePoolLabel$,
Expand Down Expand Up @@ -245,7 +247,6 @@ describe('QTIItemEditor', () => {
const publishableDocuments = {
'two interactions': MULTI_INTERACTION_ITEM_DOCUMENT,
'an interaction with no descriptor': UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
'an interaction with no editor': INLINE_CHOICE_ITEM_DOCUMENT,
'several blanks in one text entry': MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
'a stimulus beside a block interaction': CHOICE_ITEM_DOCUMENT_WITH_STIMULUS,
'a stimulus beside a hinted block interaction':
Expand Down Expand Up @@ -578,6 +579,41 @@ describe('QTIItemEditor', () => {
});
});

describe('inline choice interaction', () => {
const renderInlineChoiceItem = (props = {}) =>
renderComponent({
item: {
assessment_id: 'test-item-id',
type: AssessmentItemTypes.QTI,
raw_data: INLINE_CHOICE_ITEM_DOCUMENT,
},
...props,
});

test('names the inline choice question type', async () => {
renderInlineChoiceItem();
const heading = questionNumberAndTypeLabel$({
number: 1,
total: 5,
type: inlineChoiceLabel$(),
});
Comment thread
rtibblesbot marked this conversation as resolved.
expect(await screen.findByRole('heading', { name: heading })).toBeInTheDocument();
});

test('opens in the inline choice editor', async () => {
renderInlineChoiceItem({ mode: 'edit' });
expect(
await screen.findByRole('heading', { name: passageEditorLabel$() }),
).toBeInTheDocument();
expect(screen.queryByTestId('unsupportedMessage')).not.toBeInTheDocument();
});

test('marks a dropdown without a correct answer as incomplete', async () => {
renderInlineChoiceItem();
expect(await screen.findByTestId('incompleteIndicator')).toBeInTheDocument();
});
});

describe('toolbarActions slot', () => {
test('renders content injected into the toolbarActions slot', () => {
renderComponent({}, { toolbarActions: '<button>Edit</button>' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@
[QuestionType.ORDERING]: qtiEditorStrings.orderingLabel$,
[QuestionType.ASSOCIATE]: qtiEditorStrings.associateLabel$,
[QuestionType.MATCH]: qtiEditorStrings.matchLabel$,
[QuestionType.INLINE_CHOICE]: qtiEditorStrings.inlineChoiceLabel$,
};
return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { nextTick, ref } from 'vue';
import { useInlineChoiceInteraction } from '../useInlineChoiceInteraction';
import { inlineChoiceInteractionDescriptor } from '../../interactions/inlineChoice/Descriptor';
import { QuestionType, ValidationError } from '../../constants';
import { chip } from '../../interactions/inlineChoice/__tests__/renderPassage';

const PASSAGE = `<p>The ${chip('r1', [
['a', 'Moon'],
['b', 'Sun'],
])} rises</p>`;

function setup(state = { prompt: '', passage: PASSAGE, shuffle: false }) {
const questionType = ref(QuestionType.INLINE_CHOICE);
const block = inlineChoiceInteractionDescriptor.buildXML(state, QuestionType.INLINE_CHOICE);
return useInlineChoiceInteraction(block, questionType);
}

describe('useInlineChoiceInteraction', () => {
it('reads the dropdowns out of the passage', () => {
const { dropdowns } = setup();
expect(dropdowns.value).toEqual([
{
responseIdentifier: 'r1',
options: [
{ id: 'a', text: 'Moon' },
{ id: 'b', text: 'Sun' },
],
correctId: null,
},
]);
});

it('sets the question', () => {
const { state, setPrompt } = setup();
setPrompt('<p>Fill in the blanks</p>');
expect(state.value.prompt).toBe('<p>Fill in the blanks</p>');
});

it('sets the passage, and the dropdowns follow it', () => {
const { dropdowns, setPassage } = setup();
setPassage(`<p>${chip('r2', [['c', 'Earth']], 'c')}</p>`);
expect(dropdowns.value.map(d => d.responseIdentifier)).toEqual(['r2']);
});

it('writes Shuffle onto every dropdown in the saved XML', () => {
const { bodyXml, setShuffle } = setup({
prompt: '',
passage: `<p>${chip('r1', [['a', 'x']], 'a')} ${chip('r2', [['b', 'y']], 'b')}</p>`,
shuffle: false,
});
setShuffle(true);
const shuffles = [...bodyXml.value.matchAll(/shuffle="(\w+)"/g)].map(m => m[1]);
expect(shuffles).toEqual(['true', 'true']);
});

it('reads Shuffle back from a saved item', () => {
const { state } = setup({ prompt: '', passage: PASSAGE, shuffle: true });
expect(state.value.shuffle).toBe(true);
});

it('reports a passage with no dropdown', async () => {
const { errors, setPassage } = setup();
setPassage('<p>No dropdowns here</p>');
await nextTick();
expect(errors.value).toEqual([{ code: ValidationError.NO_INTERACTION }]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { computed, readonly } from 'vue';
import { inlineChoiceInteractionDescriptor } from '../interactions/inlineChoice/Descriptor';
import { getDropdowns } from '../interactions/inlineChoice/parse';
import { useInteraction } from './useInteraction';

/**
* Composable for the inline choice interaction editor.
*
* Extends useInteraction with the question-level mutations. A dropdown's options are not
* edited here: they live in the passage, and are changed through the passage editor's node
* commands so that each edit is one step of its history.
*
* @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock
* @param {import('vue').Ref<string|null>} questionType
*/
export function useInlineChoiceInteraction(interactionBlock, questionType) {
const base = useInteraction(inlineChoiceInteractionDescriptor, interactionBlock, questionType);
const { state } = base;

/** Every dropdown in the passage, in document order. */
const dropdowns = computed(() => getDropdowns(state.value.passage));

function setPrompt(html) {
state.value = { ...state.value, prompt: html };
}

function setPassage(html) {
state.value = { ...state.value, passage: html };
}

function setShuffle(val) {
state.value = { ...state.value, shuffle: val };
}

return {
...base,
state: readonly(state),
dropdowns,
setPrompt,
setPassage,
setShuffle,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ describe('isSupportedItem', () => {
['two choice interactions', interactionsOf(MULTI_INTERACTION_ITEM_DOCUMENT)],
['a choice and a text entry', interactionsOf(TWO_INTERACTIONS_DOCUMENT)],
['an interaction with no descriptor', interactionsOf(UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT)],
['an interaction with no editor', interactionsOf(INLINE_CHOICE_ITEM_DOCUMENT)],
['several text entries in one body', interactionsOf(MULTI_TEXT_ENTRY_ITEM_DOCUMENT)],
[
'an interaction whose question type cannot be read',
Expand All @@ -103,6 +102,14 @@ describe('isSupportedItem', () => {
['one choice interaction', VALID_CHOICE_ITEM_DOCUMENT],
['a match interaction', VALID_MATCH_ITEM_DOCUMENT],
['a text entry after its prompt', FREE_RESPONSE_ITEM_DOCUMENT],
['inline choice dropdowns in a passage', INLINE_CHOICE_ITEM_DOCUMENT],
[
'inline choice dropdowns after a question, across paragraphs',
INLINE_CHOICE_ITEM_DOCUMENT.replace(
'<qti-item-body>',
'<qti-item-body><p data-studio-prompt="">Fill in the blanks</p><p>Read this first.</p>',
),
],
])('accepts %s', (_, document) => {
expect(isSupported(document)).toBe(true);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ export const registry = Object.fromEntries(descriptors.map(d => [d.type, d]));

/**
* Interactions with a descriptor but no editor in index.js yet. Items holding one are
* shown read-only. TODO: #6182 removes inline choice.
* shown read-only.
*/
export const HEADLESS_INTERACTIONS = Object.freeze([QtiInteraction.INLINE_CHOICE]);
export const HEADLESS_INTERACTIONS = Object.freeze([]);

/**
* Inline interactions whose editor holds one per item body. A body with several would
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import TextEntryEditor from './textEntry/Editor.vue';
import OrderingEditor from './ordering/Editor.vue';
import AssociateEditor from './associate/Editor.vue';
import MatchEditor from './match/Editor.vue';
import InlineChoiceEditor from './inlineChoice/Editor.vue';

/**
* Entry point for the editor tree: the descriptors, plus the Vue component that edits each
Expand All @@ -19,6 +20,7 @@ export const editors = Object.freeze({
[QtiInteraction.ORDER]: OrderingEditor,
[QtiInteraction.ASSOCIATE]: AssociateEditor,
[QtiInteraction.MATCH]: MatchEditor,
[QtiInteraction.INLINE_CHOICE]: InlineChoiceEditor,
});

export { descriptors, registry } from './descriptors';
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,32 @@ class InlineChoiceInteractionDescriptor extends InteractionDescriptor {
});
}

getTypeOptions(tr) {
return [
{
value: QuestionType.INLINE_CHOICE,
label: tr.inlineChoiceLabel$(),
description: tr.inlineChoiceDescription$(),
},
];
}

matches(el) {
if (el.tagName.toLowerCase() === QtiInteraction.INLINE_CHOICE) return true;
return Boolean(el.querySelector(QtiInteraction.INLINE_CHOICE));
}

/**
* parse() reads every body: elements it wrote as the question go back to the question, and
* everything else is the passage. Another interaction in the body would be its own block,
* which isSupportedItem already rejects.
*
* @returns {boolean}
*/
isSupportedBody() {
Comment thread
rtibblesbot marked this conversation as resolved.
return true;
}

getQuestionType() {
return QuestionType.INLINE_CHOICE;
}
Expand Down
Loading
Loading