Skip to content
2 changes: 2 additions & 0 deletions .stylelintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ module.exports = {
*/
'selector-max-id': null, // This would require a major refactor
'csstree/validator': null, // this triggers issues with unknown at rules too.
// Allows uppercase, as in KDS theme variables like --tokens-fineLine.
'custom-property-pattern': '^([a-z][a-zA-Z0-9]*)(-[a-zA-Z0-9]+)*$',
'selector-pseudo-element-no-unknown': [
true,
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2479,11 +2479,6 @@ video {
[aria-disabled] {
cursor: default;
}
::selection {
Comment thread
rtibblesbot marked this conversation as resolved.
color: #000000;
text-shadow: none;
background-color: #b3d4fc;
}
.bottom-sheet-transition-enter,
.bottom-sheet-transition-leave-to {
-webkit-transform: translateY(100%);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ Prompts, choices, items and hints are edited in `TipTapEditor` with `format="htm
> In `parse`, read any field TipTap loads as HTML with `getContentHTML` (or `getPromptHTML`) from `serialization/xml.js`, never `innerHTML` or `XMLSerializer`. XML serialization self-closes empty elements (`<span data-latex="…"/>`); the HTML parser doesn't treat `/>` as closing, so everything after it is nested inside and lost.
>
> On build, pass the HTML to `buildXmlNode({ innerHTML })`, which parses it as HTML and re-creates it as XML.

> [!NOTE]
> A node view that shows itself as selected, like the inline choice chip, uses `--brand-secondary-v100`, the background of KDS's global `::selection`, with `--tokens-text` for its text.
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,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 @@ -51,6 +51,8 @@ const {
hintsLabel$,
associateLabel$,
matchLabel$,
inlineChoiceLabel$,
passageEditorLabel$,
questionNumberAndTypeLabel$,
unknownTypeLabel$,
responsePoolLabel$,
Expand Down Expand Up @@ -335,7 +337,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 @@ -730,6 +731,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 @@ -218,6 +218,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
Expand Up @@ -15,7 +15,7 @@
<p
v-else
class="validation-message"
role="alert"
:role="alert ? 'alert' : null"
>
<slot></slot>
</p>
Expand Down Expand Up @@ -55,6 +55,14 @@
type: Boolean,
default: false,
},
/**
* Whether an error is an alert, read out as it appears. Off when the component showing
* it announces its messages itself.
*/
alert: {
type: Boolean,
default: true,
},
},
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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('saves a cleared question as no question', () => {
const { state, bodyXml, setPrompt } = setup({
prompt: '<p>Fill in the blanks</p>',
passage: PASSAGE,
shuffle: false,
});
setPrompt('<p></p>');
expect(state.value.prompt).toBe('');
expect(bodyXml.value).not.toContain('data-studio-prompt');
});

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,45 @@
import { computed, readonly } from 'vue';
import { inlineChoiceInteractionDescriptor } from '../interactions/inlineChoice/Descriptor';
import { getDropdowns } from '../interactions/inlineChoice/parse';
import { hasRichTextContent } from '../utils/richText';
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));

// A cleared question is no question, rather than an empty paragraph saved into the body.
function setPrompt(html) {

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.

A cleared question is saved as <p data-studio-prompt=""/>. We should normalize it with hasRichTextContent.

state.value = { ...state.value, prompt: hasRichTextContent(html) ? 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 @@ -38,12 +38,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, #6204 removes gap match.
* shown read-only. TODO: #6204 removes gap match.
*/
export const HEADLESS_INTERACTIONS = Object.freeze([
QtiInteraction.INLINE_CHOICE,
QtiInteraction.GAP_MATCH,
]);
export const HEADLESS_INTERACTIONS = Object.freeze([QtiInteraction.GAP_MATCH]);

/**
* 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