diff --git a/.stylelintrc.js b/.stylelintrc.js
index 9b7b1077f7..84cbb1dc6c 100644
--- a/.stylelintrc.js
+++ b/.stylelintrc.js
@@ -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,
{
diff --git a/contentcuration/contentcuration/frontend/shared/styles/vuetify.scss b/contentcuration/contentcuration/frontend/shared/styles/vuetify.scss
index 393ac10d48..92283c441d 100644
--- a/contentcuration/contentcuration/frontend/shared/styles/vuetify.scss
+++ b/contentcuration/contentcuration/frontend/shared/styles/vuetify.scss
@@ -2479,11 +2479,6 @@ video {
[aria-disabled] {
cursor: default;
}
-::selection {
- color: #000000;
- text-shadow: none;
- background-color: #b3d4fc;
-}
.bottom-sheet-transition-enter,
.bottom-sheet-transition-leave-to {
-webkit-transform: translateY(100%);
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md
index a5047b8b9c..06f8615e01 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md
@@ -9,6 +9,8 @@ Edits an exercise's assessment items stored as [QTI 3](https://www.imsglobal.org
4. The interaction's `Editor.vue` (`interactions/index.js`) edits a plain state object through `composables/useInteraction.js`: `descriptor.parse` → state → `descriptor.buildXML` → `descriptor.validate`.
5. `useQtiItem` rebuilds the full item with `serialization/assembleItem.js`; `QTIItemEditor` emits `update:rawData`. `validateItem.js` `isEditableItem` shows read-only any item holding content outside the body that this rebuild doesn't write or regenerate.
+Changing a question's type goes through `conversion/`: the old descriptor exports its state, the new one imports it, and whatever the change would remove or change is listed in `components/ChangeQuestionTypeModal` for the author to confirm first.
+
`validateItem.js` `validateQtiItem` runs the same parse and validation headless, without the Vue editors.
> [!IMPORTANT]
@@ -19,15 +21,17 @@ Edits an exercise's assessment items stored as [QTI 3](https://www.imsglobal.org
- `Descriptor.js` — subclass of `InteractionDescriptor`: matches the element, picks the question type, delegates to `parse.js` and `validation.js`.
- `parse.js` — XML ↔ state.
- `validation.js` — state → error codes.
+ - `convert.js` — state ↔ the neutral facets a question type change carries (answers, pairs, a passage).
- `Editor.vue` — the UI; absent for headless interactions (`HEADLESS_INTERACTIONS`).
- `interactions/descriptors.js` — descriptor registry, free of `.vue` imports. `interactions/index.js` adds the editors.
- `serialization/` — item-level parse/assemble, hints (`qti-catalog-info`), XML helpers (`xml.js`), response declarations (`qti/`).
- `components/` — shared UI: hints, chip lists, toolbars, question type selector.
### Adding an interaction
-1. Create `interactions//` with `Descriptor.js`, `parse.js`, `validation.js` and `Editor.vue`.
+1. Create `interactions//` with `Descriptor.js`, `parse.js`, `validation.js`, `convert.js` and `Editor.vue`.
2. Register the descriptor in `interactions/descriptors.js` and the editor in `interactions/index.js`.
3. Add its question types to `constants.js` and strings to `qtiEditorStrings.js`.
+4. Override `getConversionFormat`, `exportContent` and `importContent` with `convert.js`, so its answers carry over when an author changes the question's type, and add a filled question of each type to `conversion/__tests__/questions.js`. Until it does, a type change from it keeps only the question without telling the author, which is why the conversion tests fail without it.
### Rich text fields
Prompts, choices, items and hints are edited in `TipTapEditor` with `format="html"`.
@@ -36,3 +40,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 (``); 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.
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
index a92c128d69..cbd4769568 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
@@ -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', () => {
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ChangeQuestionTypeModal/__tests__/ChangeQuestionTypeModal.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ChangeQuestionTypeModal/__tests__/ChangeQuestionTypeModal.spec.js
new file mode 100644
index 0000000000..2478037f54
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ChangeQuestionTypeModal/__tests__/ChangeQuestionTypeModal.spec.js
@@ -0,0 +1,71 @@
+import { render, screen, within } from '@testing-library/vue';
+import userEvent from '@testing-library/user-event';
+import VueRouter from 'vue-router';
+import ChangeQuestionTypeModal from '../index.vue';
+import { ConversionLoss } from '../../../constants';
+import { qtiEditorStrings as tr } from '../../../qtiEditorStrings';
+
+const renderModal = (props = {}) =>
+ render(ChangeQuestionTypeModal, {
+ props: {
+ questionTypeLabel: 'Numeric input',
+ losses: [ConversionLoss.INCORRECT_ANSWERS_REMOVED, ConversionLoss.FORMATTING_REMOVED],
+ ...props,
+ },
+ routes: new VueRouter(),
+ });
+
+describe('ChangeQuestionTypeModal', () => {
+ it('asks to confirm the change, naming the new type', () => {
+ renderModal();
+ const dialog = screen.getByRole('dialog');
+ expect(within(dialog).getByText(tr.$tr('changeQuestionTypeTitle'))).toBeInTheDocument();
+ expect(
+ within(dialog).getByText(tr.$tr('changeQuestionTypeIntro', { type: 'Numeric input' })),
+ ).toBeInTheDocument();
+ });
+
+ it('lists what the change would remove or change, one sentence each', () => {
+ renderModal();
+ const items = screen.getAllByRole('listitem').map(item => item.textContent.trim());
+ expect(items).toEqual([tr.$tr('incorrectAnswersRemovedLoss'), tr.$tr('formattingRemovedLoss')]);
+ });
+
+ it('shows each blank of a folded passage as it will appear in the question', () => {
+ renderModal({ losses: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION] });
+ expect(screen.getByRole('listitem')).toHaveTextContent(/"_____"/);
+ });
+
+ it.each(Object.values(ConversionLoss))('has a sentence for %s', loss => {
+ renderModal({ losses: [loss] });
+ expect(screen.getByRole('listitem').textContent.trim()).not.toBe('');
+ });
+
+ it('confirms with the Change button', async () => {
+ const user = userEvent.setup();
+ const { emitted } = renderModal();
+ await user.click(screen.getByRole('button', { name: tr.$tr('changeQuestionTypeAction') }));
+ expect(emitted().submit).toHaveLength(1);
+ expect(emitted().cancel).toBeUndefined();
+ });
+
+ it('cancels with the Cancel button', async () => {
+ const user = userEvent.setup();
+ const { emitted } = renderModal();
+ await user.click(screen.getByRole('button', { name: tr.$tr('cancelAction') }));
+ expect(emitted().cancel).toHaveLength(1);
+ expect(emitted().submit).toBeUndefined();
+ });
+
+ it('can be used with the keyboard only', async () => {
+ const user = userEvent.setup();
+ const { emitted } = renderModal();
+ await user.keyboard('{Escape}');
+ expect(emitted().cancel).toHaveLength(1);
+
+ const change = screen.getByRole('button', { name: tr.$tr('changeQuestionTypeAction') });
+ change.focus();
+ await user.keyboard('{Enter}');
+ expect(emitted().submit).toHaveLength(1);
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ChangeQuestionTypeModal/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ChangeQuestionTypeModal/index.vue
new file mode 100644
index 0000000000..4f1a286cf7
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ChangeQuestionTypeModal/index.vue
@@ -0,0 +1,99 @@
+
+
+
+ {{ changeQuestionTypeIntro$({ type: questionTypeLabel }) }}
+
+ -
+ {{ lossMessages[loss]({ blank: BLANK_TEXT }) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js
index 71643822a7..3a5f57a967 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js
@@ -1,4 +1,5 @@
-import { render, screen, fireEvent, within } from '@testing-library/vue';
+import { render, screen, within, fireEvent } from '@testing-library/vue';
+import userEvent from '@testing-library/user-event';
import { nextTick } from 'vue';
import VueRouter from 'vue-router';
import InteractionSection from '../index.vue';
@@ -9,7 +10,12 @@ import {
CHOICE_MULTI_SELECT_XML,
UNKNOWN_INTERACTION_XML,
MATCH_THREE_SETS_XML,
+ CHOICE_SINGLE_DECL_XML,
+ TEXT_ENTRY_BODY_XML,
+ TEXT_ENTRY_NUMERIC_DECL_XML,
+ TEXT_ENTRY_STRING_DECL_XML,
mockInteractionBlock as interactionBlock,
+ mockInteractionBlockWithDecl,
} from '../../../utils/testingFixtures';
jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
@@ -77,20 +83,26 @@ describe('InteractionSection', () => {
// Feeds each emitted interaction back in, as the item editor does.
const SwitchableSection = {
components: { InteractionSection },
+ props: {
+ initial: {
+ type: Object,
+ default: () => interactionBlock(CHOICE_SINGLE_SELECT_XML),
+ },
+ language: {
+ type: String,
+ default: '',
+ },
+ },
template: `
`,
data() {
- return {
- interactionBlock: {
- bodyXml: CHOICE_SINGLE_SELECT_XML,
- responseDeclarations: [],
- },
- };
+ return { interactionBlock: this.initial };
},
methods: {
onUpdate(val) {
@@ -100,43 +112,190 @@ describe('InteractionSection', () => {
},
};
+ const renderSwitchable = (initial, { language } = {}) =>
+ render(SwitchableSection, {
+ props: { ...(initial && { initial }), ...(language !== undefined && { language }) },
+ routes: new VueRouter(),
+ });
+
+ const selector = () => screen.getByRole('group', { name: tr.$tr('typeLabel') });
+ const shownType = () => selector().querySelector('.select-value-text').textContent.trim();
+
+ async function pickType(user, toLabel) {
+ await user.click(selector().querySelector('.select-value-text'));
+ await user.click(screen.getAllByText(tr.$tr(toLabel)).at(-1));
+ await nextTick();
+ }
+
+ // Mercury, marked correct, with Venus and Earth.
+ const markedSingleSelect = () =>
+ mockInteractionBlockWithDecl(CHOICE_SINGLE_SELECT_XML, CHOICE_SINGLE_DECL_XML);
+
describe('type switching', () => {
- it('preserves the prompt but resets choices when switching from choice to text-entry', async () => {
- const { emitted } = render(SwitchableSection, {
- routes: new VueRouter(),
- });
+ it('switches straight away when nothing would be lost', async () => {
+ const user = userEvent.setup();
+ const { emitted } = renderSwitchable(markedSingleSelect());
+ await nextTick();
+
+ await pickType(user, 'multiSelectLabel');
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ const switched = emitted()['wrapper-update'].at(-1)[0];
+ expect(switched.responseDeclarations[0]).toContain('cardinality="multiple"');
+ const answers = screen.getByRole('group', { name: tr.$tr('answersLabel') });
+ const checkboxes = within(answers).getAllByRole('checkbox');
+ expect(checkboxes).toHaveLength(3);
+ expect(checkboxes[0]).toBeChecked();
+ });
+
+ it('asks first when the change would remove part of the question', async () => {
+ const user = userEvent.setup();
+ renderSwitchable(markedSingleSelect());
+ await nextTick();
+
+ await pickType(user, 'textEntryLabel');
+
+ const dialog = screen.getByRole('dialog');
+ expect(within(dialog).getByText(tr.$tr('incorrectAnswersRemovedLoss'))).toBeInTheDocument();
+ expect(
+ within(dialog).getByText(
+ tr.$tr('changeQuestionTypeIntro', { type: tr.$tr('textEntryLabel') }),
+ ),
+ ).toBeInTheDocument();
+ });
+ it('converts the question once the author confirms', async () => {
+ const user = userEvent.setup();
+ const { emitted } = renderSwitchable(markedSingleSelect());
await nextTick();
- const selectedOption = screen.getAllByText(tr.$tr('singleSelectLabel'))[0];
- await fireEvent.click(selectedOption);
+ await pickType(user, 'textEntryLabel');
+ await user.click(screen.getByRole('button', { name: tr.$tr('changeQuestionTypeAction') }));
+ await nextTick();
- const textEntryOption = screen.getByText(tr.$tr('textEntryLabel'));
- await fireEvent.click(textEntryOption);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ const switched = emitted()['wrapper-update'].at(-1)[0];
+ expect(switched.bodyXml).toContain('Which planet is closest to the Sun?');
+ expect(switched.bodyXml).toContain(' {
+ const user = userEvent.setup();
+ const { emitted } = renderSwitchable(markedSingleSelect());
await nextTick();
+ const before = emitted()['wrapper-update']?.length ?? 0;
- const emits = emitted()['wrapper-update'];
- const switchXml = emits.at(-1)[0].bodyXml;
+ await pickType(user, 'textEntryLabel');
+ await user.click(screen.getByRole('button', { name: tr.$tr('cancelAction') }));
+ await nextTick();
- expect(switchXml).toContain('Which planet is closest to the Sun?');
- expect(switchXml).toContain(' {
+ const user = userEvent.setup();
+ renderSwitchable(markedSingleSelect());
+ await nextTick();
+
+ await pickType(user, 'textEntryLabel');
+ await user.click(screen.getByRole('button', { name: tr.$tr('cancelAction') }));
+ await nextTick();
+ await pickType(user, 'textEntryLabel');
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ describe('focus', () => {
+ // A rich text editor focusing itself does so in a timeout and then an animation frame, as
+ // TipTap does, while the mock would at once. Timed like the real one, an editor remounted
+ // for the new type that took focus again would show here, after the select.
+ beforeEach(() => {
+ const focus = HTMLTextAreaElement.prototype.focus;
+ jest.spyOn(HTMLTextAreaElement.prototype, 'focus').mockImplementation(function () {
+ setTimeout(() => requestAnimationFrame(() => focus.call(this)));
+ });
+ });
+
+ afterEach(() => {
+ HTMLTextAreaElement.prototype.focus.mockRestore();
+ });
+
+ const settle = () =>
+ new Promise(resolve => setTimeout(() => requestAnimationFrame(() => setTimeout(resolve))));
+
+ it('stays on the type selector after a switch with nothing to confirm', async () => {
+ const user = userEvent.setup();
+ renderSwitchable(markedSingleSelect());
+ await nextTick();
+
+ // From choice to free response, then on to text entry, which loses nothing.
+ await pickType(user, 'freeResponseLabel');
+ await user.click(screen.getByRole('button', { name: tr.$tr('changeQuestionTypeAction') }));
+ await settle();
+ await pickType(user, 'textEntryLabel');
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ await settle();
+
+ expect(selector()).toContainElement(document.activeElement);
+ });
+
+ it.each([
+ ['confirming', 'changeQuestionTypeAction'],
+ ['cancelling', 'cancelAction'],
+ ])('goes back to the type selector after %s', async (_, button) => {
+ const user = userEvent.setup();
+ renderSwitchable(markedSingleSelect());
+ await nextTick();
+
+ await pickType(user, 'textEntryLabel');
+ await user.click(screen.getByRole('button', { name: tr.$tr(button) }));
+ await settle();
+
+ expect(selector()).toContainElement(document.activeElement);
+ });
+ });
+
+ it('converts what the author has just typed', async () => {
+ const user = userEvent.setup();
+ const { emitted } = renderSwitchable(markedSingleSelect());
+ await nextTick();
+
+ await user.click(
+ screen.getByRole('button', { name: tr.$tr('editAnswerOptionLabel', { number: 1 }) }),
+ );
+ const mercury = screen.getByDisplayValue('Mercury');
+ await user.clear(mercury);
+ await user.type(mercury, 'Mercury, the smallest planet');
+ await pickType(user, 'multiSelectLabel');
+
+ expect(emitted()['wrapper-update'].at(-1)[0].bodyXml).toContain(
+ 'Mercury, the smallest planet',
+ );
});
- });
- describe('switching to match', () => {
it('produces a match item that reopens in the match editor', async () => {
- const { emitted, unmount } = render(SwitchableSection, { routes: new VueRouter() });
+ const user = userEvent.setup();
+ const { emitted, unmount } = renderSwitchable();
await nextTick();
- await fireEvent.click(screen.getAllByText(tr.$tr('singleSelectLabel'))[0]);
- await fireEvent.click(screen.getByText(tr.$tr('matchLabel')));
+ await pickType(user, 'matchLabel');
+ expect(
+ within(screen.getByRole('dialog')).getByText(tr.$tr('answersRemovedLoss')),
+ ).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: tr.$tr('changeQuestionTypeAction') }));
await nextTick();
const switched = emitted()['wrapper-update'].at(-1)[0];
expect(switched.bodyXml).toContain('Which planet is closest to the Sun?');
expect(switched.bodyXml.match(/ {
});
});
+ describe('switching to inline choice', () => {
+ it('keeps the prompt as the question and shows the passage editor', async () => {
+ const user = userEvent.setup();
+ const { emitted } = renderSwitchable();
+ await nextTick();
+
+ await pickType(user, 'inlineChoiceLabel');
+ await user.click(screen.getByRole('button', { name: tr.$tr('changeQuestionTypeAction') }));
+ 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(' {
+ const numericAnswers = () =>
+ screen.queryAllByRole('textbox', { name: tr.$tr('answerValuePlaceholder') });
+ const latestDeclaration = emitted =>
+ emitted()['wrapper-update'].at(-1)[0].responseDeclarations[0];
+ const numeric = stored =>
+ mockInteractionBlockWithDecl(
+ TEXT_ENTRY_BODY_XML,
+ TEXT_ENTRY_NUMERIC_DECL_XML.replace('42', stored),
+ );
+ const textEntry = text =>
+ mockInteractionBlockWithDecl(
+ TEXT_ENTRY_BODY_XML,
+ TEXT_ENTRY_STRING_DECL_XML.replace('H2O', text),
+ );
+
+ it.each([
+ ['fr', '1234.5'],
+ ['en', '1.50'],
+ ['en', '0012'],
+ ['ar-EG', '30'],
+ ['ar-EG', '-3.14'],
+ ['fr', '1.2.3'],
+ ['he', '-0.5'],
+ ['sv', '-0.5'],
+ ['ar-EG', '-0.5'],
+ ])('gives text entry a numeric answer as it was shown in %s (%s)', async (language, stored) => {
+ const user = userEvent.setup();
+ const { emitted } = renderSwitchable(numeric(stored), { language });
+ await nextTick();
+ const shown = numericAnswers()[0].value;
+ await pickType(user, 'textEntryLabel');
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(latestDeclaration(emitted)).toContain(`${shown}`);
+ });
+
+ it.each([
+ ['fr', '11,2'],
+ ['de', '1.234,5'],
+ ['en', '1.5'],
+ ])(
+ 'gives back a text entry answer in %s (%s) after switching to numeric and back',
+ async (language, text) => {
+ const user = userEvent.setup();
+ const { emitted } = renderSwitchable(textEntry(text), { language });
+ await nextTick();
+ await pickType(user, 'numericLabel');
+ await nextTick();
+ const shown = numericAnswers()[0].value;
+ await pickType(user, 'textEntryLabel');
+ expect(latestDeclaration(emitted)).toContain(`${shown}`);
+ },
+ );
+
+ it.each([
+ ['fr', '1,5', '1,5', '1.5'],
+ ['fr', '1 234,5', '1234,5', '1234.5'],
+ // Read as xsd:double first, as German would read it as 15.
+ ['de', '1.5', '1,5', '1.5'],
+ ])(
+ 'reads a text entry answer in %s (%s) when switched to numeric',
+ async (language, text, shown, stored) => {
+ const user = userEvent.setup();
+ const { emitted } = renderSwitchable(textEntry(text), { language });
+ await nextTick();
+ await pickType(user, 'numericLabel');
+ await nextTick();
+ expect(numericAnswers()[0]).toHaveValue(shown);
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(latestDeclaration(emitted)).toContain(`${stored}`);
+ },
+ );
+
+ it.each([
+ ['de', '1.5', '1,5'],
+ ['es', '1234.5', '1234,5'],
+ ['fr', '1234.5', '1234,5'],
+ ['ar-EG', '30', '٣٠'],
+ ])(
+ 'shows a stored answer in %s again after switching to text entry and back (%s)',
+ async (language, stored, shown) => {
+ const user = userEvent.setup();
+ const { emitted } = renderSwitchable(numeric(stored), { language });
+ await nextTick();
+ await pickType(user, 'textEntryLabel');
+ await pickType(user, 'numericLabel');
+ await nextTick();
+ expect(numericAnswers()[0]).toHaveValue(shown);
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(latestDeclaration(emitted)).toContain(`${stored}`);
+ },
+ );
+
+ it.each([
+ ['fr', '1,5'],
+ ['en', '1,234'],
+ ['de', '1.500'],
+ ['', ' 5 '],
+ ['fa', 'می\u200cخواهم'],
+ ['en', 'a\u00adb'],
+ ])(
+ 'gives text entry a typed numeric answer as it is shown in "%s" (%s)',
+ async (language, typed) => {
+ const user = userEvent.setup();
+ const { emitted } = renderSwitchable(numeric('42'), { language });
+ await nextTick();
+ const [input] = numericAnswers();
+ await fireEvent.input(input, { target: { value: typed } });
+ // Leaving the answer shows it as stored, written in the language.
+ await fireEvent.blur(input);
+ const shown = input.value;
+ await pickType(user, 'textEntryLabel');
+ expect(latestDeclaration(emitted)).toContain(`${shown.trim()}`);
+ },
+ );
+ });
+
describe('unknown interaction type', () => {
it('renders nothing editable', () => {
renderSection({ interaction: interactionBlock(UNKNOWN_INTERACTION_XML), mode: 'edit' });
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue
index 81a97ccf4a..6a014f5ee6 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue
@@ -10,15 +10,21 @@
+
$emit('update:errors', errors)"
/>
+
+
@@ -36,17 +50,22 @@
',
+ distractors: [],
+ };
+ expect(exportGapMatchContent(state).passage.blanks).toEqual([{ answer: 'Sun', options: [] }]);
+ });
+
+ it('imports each blank as a gap holding its answer, and an unanswered one as an empty gap', () => {
+ const state = importGapMatchContent({
+ prompt: 'Q
',
+ passage: passageFacet([
+ { answer: 'Sun', options: [] },
+ { answer: null, options: [] },
+ ]),
+ });
+ expect(state.prompt).toBe('Q
');
+ expect(getGaps(state.passage).map(({ answer }) => answer)).toEqual(['Sun', '']);
+ expect(state.distractors).toEqual([]);
+ });
+
+ it('pools every blank’s options with the distractors, each once and none an answer', () => {
+ const state = importGapMatchContent({
+ prompt: '',
+ passage: passageFacet(
+ [
+ { answer: 'Sun', options: ['Moon', 'Star'] },
+ { answer: 'Moon', options: ['Sun', 'Star', 'Comet'] },
+ ],
+ ['Comet', 'Planet', ''],
+ ),
+ });
+ expect(state.distractors.map(({ content }) => content)).toEqual(['Star', 'Comet', 'Planet']);
+ });
+
+ it('keeps the question alone when there is no passage to import', () => {
+ expect(importGapMatchContent({ prompt: 'Q
' })).toEqual({
+ responseIdentifier: 'RESPONSE',
+ prompt: 'Q
',
+ passage: '',
+ distractors: [],
+ });
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/convert.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/convert.js
new file mode 100644
index 0000000000..c8c7f7032a
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/convert.js
@@ -0,0 +1,84 @@
+import flatMap from 'lodash/flatMap';
+import { ContentLevel } from '../../constants';
+import { getContentHTML, parseHTMLBody } from '../../serialization/xml';
+import { hasRichTextContent } from '../../utils/richText';
+import { BLANK_ATTR } from '../../conversion/passage';
+import { GAP, _defaultState, newDistractor, readGap, writeGap } from './parse';
+import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey';
+
+/**
+ * @returns {import('../../conversion').ConversionFormat}
+ */
+export function getGapMatchConversionFormat() {
+ return { passage: { contentLevel: ContentLevel.INLINE } };
+}
+
+/**
+ * Each gap becomes a blank, its content the blank's answer. A gap has no options of its own:
+ * every wrong answer is in the shared pool of distractors.
+ *
+ * @param {import('./parse').GapMatchState} state
+ * @returns {import('../../conversion').ConvertibleContent}
+ */
+export function exportGapMatchContent(state) {
+ const body = parseHTMLBody(state.passage);
+ const blanks = [...body.querySelectorAll(GAP)].map((el, index) => {
+ const { answer } = readGap(el);
+ const placeholder = body.ownerDocument.createElement('span');
+ placeholder.setAttribute(BLANK_ATTR, String(index));
+ el.replaceWith(placeholder);
+ return { answer: hasRichTextContent(answer) ? answer : null, options: [] };
+ });
+ return {
+ prompt: state.prompt,
+ passage: {
+ html: getContentHTML(body),
+ blanks,
+ distractors: state.distractors.map(({ content }) => content),
+ contentLevel: ContentLevel.INLINE,
+ // Learners always see the pool shuffled; otherwise the answers would come in order.
+ shuffle: true,
+ },
+ };
+}
+
+/**
+ * Each blank becomes a gap holding its answer. Every blank's own options join the shared
+ * pool, which is what a gap match learner chooses from. The conversion repeats an option
+ * offered in several dropdowns, or one that is another blank's answer, so those repeats are
+ * dropped rather than left for validation to flag.
+ *
+ * @param {import('../../conversion').ConvertibleContent} content
+ * @returns {import('./parse').GapMatchState}
+ */
+export function importGapMatchContent({ prompt, passage }) {
+ const state = _defaultState();
+ if (!passage) {
+ return { ...state, prompt };
+ }
+ const body = parseHTMLBody(passage.html);
+ for (const placeholder of body.querySelectorAll(`[${BLANK_ATTR}]`)) {
+ const blank = passage.blanks[Number(placeholder.getAttribute(BLANK_ATTR))];
+ if (!blank) {
+ placeholder.remove();
+ continue;
+ }
+ placeholder.replaceWith(writeGap(body.ownerDocument, blank.answer ?? ''));
+ }
+
+ const answers = passage.blanks.map(({ answer }) => answer).filter(hasRichTextContent);
+ const offered = new Set(answers.map(richTextComparisonKey));
+ const distractors = [];
+ for (const content of [
+ ...flatMap(passage.blanks, ({ options }) => options),
+ ...passage.distractors,
+ ]) {
+ const key = richTextComparisonKey(content);
+ if (hasRichTextContent(content) && !offered.has(key)) {
+ offered.add(key);
+ distractors.push(newDistractor(content));
+ }
+ }
+
+ return { ...state, prompt, passage: getContentHTML(body), distractors };
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/parse.js
index ad5df9e584..f7cce96d59 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/parse.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/parse.js
@@ -5,6 +5,7 @@ import {
getContentHTML,
getPromptHTML,
isContentNode,
+ parseHTMLBody,
parseXML,
serializeAsHtml,
wrapInlineRuns,
@@ -17,7 +18,7 @@ import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/ut
const serializer = new XMLSerializer();
-const GAP = 'qti-gap';
+export const GAP = 'qti-gap';
const GAP_TEXT = 'qti-gap-text';
const PROMPT = 'qti-prompt';
const CORRECT_ATTR = 'data-studio-correct';
@@ -46,10 +47,45 @@ const SENTINEL_ID = 'studio_sentinel';
* @property {string} answer - HTML of the gap's correct answer; '' when blank
*/
-function defaultState() {
+export function _defaultState() {
return { responseIdentifier: RESPONSE_IDENTIFIER, prompt: '', passage: '', distractors: [] };
}
+/**
+ * A new distractor, with a fresh identifier.
+ *
+ * @param {string} [content]
+ * @returns {GapMatchDistractor}
+ */
+export const newDistractor = (content = '') => ({ id: generateRandomSlug('choice'), content });
+
+/**
+ * @param {Element} el - A `` as the passage holds it
+ * @returns {GapMatchGap}
+ */
+export function readGap(el) {
+ return {
+ id: el.getAttribute('identifier'),
+ answerId: el.getAttribute(CORRECT_ATTR),
+ answer: getContentHTML(el),
+ };
+}
+
+/**
+ * A new gap, with a fresh identifier, as the passage holds it: holding its answer, which the
+ * build writes out as its gap text. The reverse of readGap.
+ *
+ * @param {Document} doc - The document of the passage it goes in
+ * @param {string} answer - HTML of its correct answer; '' for a blank gap
+ * @returns {Element}
+ */
+export function writeGap(doc, answer) {
+ const gap = doc.createElement(GAP);
+ gap.setAttribute('identifier', generateRandomSlug('gap'));
+ gap.append(...parseHTMLBody(answer).childNodes);
+ return gap;
+}
+
/**
* Read every gap out of the passage, in document order.
*
@@ -58,11 +94,7 @@ function defaultState() {
*/
export function getGaps(passage) {
const doc = parseXML(passage || '', 'text/html');
- return [...doc.querySelectorAll(GAP)].map(el => ({
- id: el.getAttribute('identifier'),
- answerId: el.getAttribute(CORRECT_ATTR),
- answer: getContentHTML(el),
- }));
+ return [...doc.querySelectorAll(GAP)].map(readGap);
}
/**
@@ -77,13 +109,13 @@ export function getGaps(passage) {
* @returns {GapMatchState}
*/
export function parseGapMatchInteraction(bodyXml, responseDeclarations) {
- if (!bodyXml) return defaultState();
+ if (!bodyXml) return _defaultState();
let root;
try {
root = parseXML(bodyXml).documentElement;
} catch {
- return defaultState();
+ return _defaultState();
}
const contentNodes = [...root.childNodes].filter(isContentNode);
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js
index 312242e5ea..29658a23ce 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js
@@ -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
@@ -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';
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Descriptor.js
index fba7e53f64..108ff2be16 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Descriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Descriptor.js
@@ -1,6 +1,11 @@
import { QtiInteraction, QuestionType, BaseType, Cardinality, Placement } from '../../constants';
import { InteractionDescriptor } from '../InteractionDescriptor';
import { parseInlineChoiceInteraction, buildInlineChoiceInteractionXML } from './parse';
+import {
+ exportInlineChoiceContent,
+ getInlineChoiceConversionFormat,
+ importInlineChoiceContent,
+} from './convert';
import { validateInlineChoiceInteraction } from './validation';
/**
@@ -18,11 +23,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() {
+ return true;
+ }
+
getQuestionType() {
return QuestionType.INLINE_CHOICE;
}
@@ -61,6 +87,29 @@ class InlineChoiceInteractionDescriptor extends InteractionDescriptor {
validate(state) {
return validateInlineChoiceInteraction(state);
}
+
+ /**
+ * @returns {import('../../conversion').ConversionFormat}
+ */
+ getConversionFormat() {
+ return getInlineChoiceConversionFormat();
+ }
+
+ /**
+ * @param {InlineChoiceState} state
+ * @returns {import('../../conversion').ConvertibleContent}
+ */
+ exportContent(state) {
+ return exportInlineChoiceContent(state);
+ }
+
+ /**
+ * @param {import('../../conversion').ConvertibleContent} content
+ * @returns {InlineChoiceState}
+ */
+ importContent(content) {
+ return importInlineChoiceContent(content);
+ }
}
export const inlineChoiceInteractionDescriptor = new InlineChoiceInteractionDescriptor();
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Editor.vue
new file mode 100644
index 0000000000..c45db96dd7
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Editor.vue
@@ -0,0 +1,471 @@
+
+
+
+
+
+
+
+
+
+
+ {{ mode === 'edit' ? questionOptionalLabel$() : questionLabel$() }}
+
+
+
+
+
+
+
+
+
+ {{ errorInlineChoiceOptionProblems$() }}
+
+
+ {{ errorNoInlineChoice$() }}
+
+
+ {{ mode === 'edit' ? passageEditorLabel$() : passageLabel$() }}
+
+
+ {{ passageEditorDescription$() }}
+
+
+
+
+
+
+
+
+ passage.updateDropdown(openDropdown.responseIdentifier, change)"
+ @undo="passage.undo"
+ @redo="passage.redo"
+ @close="closeOptions"
+ />
+
+
+
+
+
+
+
+
+
+
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js
index dff171dba1..b236da4a4e 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js
@@ -1,8 +1,8 @@
// The resolver does not read package `exports` subpaths.
/* eslint-disable import/no-unresolved */
import { isHistoryTransaction } from '@tiptap/pm/history';
-import { Fragment, Slice } from '@tiptap/pm/model';
-import { NodeSelection, Plugin } from '@tiptap/pm/state';
+import { DOMSerializer, Fragment, Slice } from '@tiptap/pm/model';
+import { Plugin } from '@tiptap/pm/state';
/* eslint-enable import/no-unresolved */
import {
Node,
@@ -14,11 +14,19 @@ import {
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import { generateRandomSlug } from '../../utils/generateRandomSlug';
import InlineChoiceChip from './components/InlineChoiceChip';
-import { CORRECT_ATTR, DROPDOWN, OPTION, readDropdown } from './parse';
+import {
+ COPIED_DROPDOWN,
+ CORRECT_ATTR,
+ DROPDOWN,
+ OPTION,
+ copiedDropdown,
+ readCopiedDropdown,
+ readDropdown,
+} from './parse';
const NODE_NAME = 'inlineChoice';
-const isChip = node => node.type.name === NODE_NAME;
+export const isChip = node => node.type.name === NODE_NAME;
export const findChip = (doc, responseIdentifier) =>
findChildren(
@@ -70,6 +78,35 @@ function trimmedRange({ doc, selection }) {
return { from, to };
}
+/**
+ * Backspace and Delete next to a chip remove it, as one step of history. ProseMirror leaves
+ * these keys to the browser beside an inline node that cannot be selected, and browsers differ
+ * on what they do beside an uneditable element.
+ */
+function deleteChip(view, event) {
+ const { state } = view;
+ const { selection } = state;
+ if (!selection.empty || event.altKey || event.ctrlKey || event.metaKey) return false;
+ const { $head } = selection;
+ const backward = event.key === 'Backspace';
+ const next = backward ? $head.nodeBefore : $head.nodeAfter;
+ if (!next || !isChip(next)) return false;
+ const from = backward ? $head.pos - next.nodeSize : $head.pos;
+ view.dispatch(state.tr.delete(from, from + next.nodeSize).scrollIntoView());
+ return true;
+}
+
+/**
+ * A chip read from HTML holding no options, such as a copy whose dropdown cannot be read, gets
+ * one: QTI requires one in every dropdown, and every save is checked against it. It holds the
+ * element's text, which in a copy is the correct answer; the missing id is filled in on insert.
+ */
+function readStoredChip(el) {
+ const dropdown = readDropdown(el);
+ if (dropdown.options.length) return dropdown;
+ return { ...dropdown, options: [{ id: '', text: el.textContent.trim() }] };
+}
+
function reidentify(fragment, taken) {
const children = [];
fragment.forEach(child => {
@@ -91,6 +128,9 @@ export const InlineChoiceNode = Node.create({
group: 'inline',
inline: true,
atom: true,
+ // The caret moves past a chip rather than selecting it: a chip is not a place to type, and its
+ // own button is how the keyboard reaches its options.
+ selectable: false,
addAttributes() {
return {
@@ -101,7 +141,11 @@ export const InlineChoiceNode = Node.create({
},
parseHTML() {
- return [{ tag: DROPDOWN, getAttrs: readDropdown }];
+ // A copied chip first, so it is not read as the stored form, which it also matches.
+ return [
+ { tag: COPIED_DROPDOWN, getAttrs: readCopiedDropdown },
+ { tag: DROPDOWN, getAttrs: readStoredChip },
+ ];
},
renderHTML({ node }) {
@@ -115,6 +159,10 @@ export const InlineChoiceNode = Node.create({
];
},
+ // No `renderText`: TipTap's typing shortcuts read text through it, so a chip read as its answer
+ // could start one (a `-` answer at the start of a paragraph and a space make a list), and be
+ // deleted as the characters typed for it. Plain text leaves a chip out; copied HTML has it.
+
addCommands() {
return {
// The selected text becomes the chip's correct option. With no text, or with an inline
@@ -157,12 +205,8 @@ export const InlineChoiceNode = Node.create({
}
if (correctId !== undefined) attrs.correctId = correctId;
if (!attrs.options.some(option => option.id === attrs.correctId)) attrs.correctId = null;
- if (dispatch) {
- const wasSelected = tr.selection.node && tr.selection.from === found.pos;
- // Replaces the chip, so history groups quick successive edits as it does typing.
- tr.setNodeMarkup(found.pos, undefined, attrs);
- if (wasSelected) tr.setSelection(NodeSelection.create(tr.doc, found.pos));
- }
+ // Replaces the chip, so history groups quick successive edits as it does typing.
+ if (dispatch) tr.setNodeMarkup(found.pos, undefined, attrs);
return true;
},
};
@@ -173,9 +217,20 @@ export const InlineChoiceNode = Node.create({
},
addProseMirrorPlugins() {
+ const serializer = DOMSerializer.fromSchema(this.editor.schema);
+ // Copies write a chip as `copiedDropdown`, which another passage reads back whole and other
+ // editors as its correct answer.
+ const clipboardSerializer = new DOMSerializer(
+ { ...serializer.nodes, [this.name]: node => copiedDropdown(node.attrs) },
+ serializer.marks,
+ );
return [
new Plugin({
props: {
+ clipboardSerializer,
+ handleKeyDown(view, event) {
+ return ['Backspace', 'Delete'].includes(event.key) && deleteChip(view, event);
+ },
// A copy must not share ids with the original; a cut-paste has already removed them.
transformPasted(slice, view) {
// Whether a drag moves or copies is only known at drop; see `appendTransaction`.
@@ -186,8 +241,9 @@ export const InlineChoiceNode = Node.create({
return new Slice(reidentify(slice.content, taken), slice.openStart, slice.openEnd);
},
},
- // Catches inserts `transformPasted` does not see, such as a drop. An inserted chip whose
- // ids are still in use elsewhere was copied, not moved.
+ // Catches inserts `transformPasted` does not see, such as a drop, or the toolbar's Paste,
+ // which inserts through `insertContent`. An inserted chip whose ids are still in use
+ // elsewhere was copied, not moved.
appendTransaction(transactions, oldState, newState) {
// Undo and redo only restore states this plugin already accepted.
if (transactions.every(isHistoryTransaction)) return null;
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/Editor.spec.js
new file mode 100644
index 0000000000..0402c4c112
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/Editor.spec.js
@@ -0,0 +1,1022 @@
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/vue';
+import userEvent from '@testing-library/user-event';
+import { nextTick } from 'vue';
+import VueRouter from 'vue-router';
+import InlineChoiceEditor from '../Editor.vue';
+import { inlineChoiceInteractionDescriptor as descriptor } from '../Descriptor';
+import { getDropdowns } from '../parse';
+import { QuestionType } from '../../../constants';
+import { qtiEditorStrings as tr } from '../../../qtiEditorStrings';
+import { renderFocused } from '../../__tests__/renderFocused';
+import { CHIP, chip } from './renderPassage';
+import { dragSortStrings as dragTr } from 'shared/views/dragSort/dragSortStrings';
+import { getTipTapEditorStrings } from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditorStrings';
+import { stubProseMirrorLayout } from 'shared/utils/testing';
+
+// jsdom defines `ontouchstart`, which would put the editor in its touch layout.
+jest.mock('shared/utils/browserInfo', () => ({ isTouchDevice: false }));
+jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
+ const { ref } = require('vue');
+ return {
+ __esModule: true,
+ default: () => ({ windowIsSmall: ref(false) }),
+ };
+});
+// `useDraggableUniverse` destructures this composable, so the automock has to return a
+// usable object rather than undefined.
+const mockSendAssertiveMessage = jest.fn();
+const mockSendPoliteMessage = jest.fn();
+jest.mock('kolibri-design-system/lib/composables/useKLiveRegion', () => ({
+ __esModule: true,
+ default: () => ({
+ sendPoliteMessage: mockSendPoliteMessage,
+ sendAssertiveMessage: mockSendAssertiveMessage,
+ }),
+}));
+
+const PASSAGE = `The Earth ${chip('r1', [
+ ['sun', 'Sun'],
+ ['moon', 'Moon'],
+ ['stars', 'Stars'],
+])} orbits, and ${chip(
+ 'r2',
+ [
+ ['revolves', 'revolves'],
+ ['circles', 'circles'],
+ ],
+ 'revolves',
+)} too.
`;
+
+const interactionOf = state =>
+ descriptor.buildXML(
+ { prompt: 'Pick the words
', passage: PASSAGE, shuffle: false, ...state },
+ QuestionType.INLINE_CHOICE,
+ );
+
+let teleportTarget;
+
+beforeEach(() => {
+ teleportTarget = document.createElement('div');
+ teleportTarget.id = 'settings-target';
+ document.body.appendChild(teleportTarget);
+});
+
+afterEach(() => {
+ teleportTarget.remove();
+});
+
+async function renderEditor(props = {}) {
+ const user = userEvent.setup();
+ const rendered = render(InlineChoiceEditor, {
+ props: {
+ interaction: interactionOf(),
+ questionType: QuestionType.INLINE_CHOICE,
+ mode: 'edit',
+ teleportTargetId: 'settings-target',
+ ...props,
+ },
+ routes: new VueRouter(),
+ });
+ if (props.mode === 'view') {
+ await waitFor(() =>
+ expect(screen.getAllByRole('img', { name: /answer dropdown/i })).not.toHaveLength(0),
+ );
+ } else {
+ // The card opens on its question; the chips take a click once the passage is open.
+ await user.click(await screen.findByRole('button', { name: tr.editPassageLabel$() }));
+ await waitFor(() => expect(screen.getAllByRole('button', CHIP)).not.toHaveLength(0));
+ }
+ return { ...rendered, user };
+}
+
+const chips = () => screen.getAllByRole('button', CHIP);
+const dialog = () => screen.getByRole('dialog');
+const optionInputs = () => within(dialog()).getAllByRole('textbox', { name: /^Option \d+$/ });
+const optionLabel = number => tr.inlineChoiceOptionLabel$({ number });
+/** The part of the editor under the heading with that name: its field, help and errors. */
+const section = name => screen.getByRole('heading', { name }).parentElement;
+
+/** The dropdowns of the last interaction the editor emitted, as saved and read back. */
+function savedDropdowns(emitted) {
+ const [interaction] = emitted['update:interaction'].at(-1);
+ const state = descriptor.parse(interaction.bodyXml, interaction.responseDeclarations);
+ return getDropdowns(state.passage);
+}
+
+async function openChip(user, index) {
+ await user.click(chips()[index]);
+ return screen.findByRole('dialog');
+}
+
+describe('InlineChoiceEditor', () => {
+ stubProseMirrorLayout();
+
+ // jsdom lays nothing out, so every element would read as hidden to the focus helpers.
+ let offsetParent;
+ beforeAll(() => {
+ offsetParent = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetParent');
+ Object.defineProperty(HTMLElement.prototype, 'offsetParent', {
+ configurable: true,
+ get() {
+ return this.parentNode;
+ },
+ });
+ });
+ afterAll(() => {
+ Object.defineProperty(HTMLElement.prototype, 'offsetParent', offsetParent);
+ });
+
+ it('shows the optional question and the passage editor with its helper text', async () => {
+ await renderEditor();
+ expect(
+ within(section(tr.questionOptionalLabel$())).getByText('Pick the words'),
+ ).toBeInTheDocument();
+ const passage = within(section(tr.passageEditorLabel$()));
+ expect(passage.getByText(tr.passageEditorDescription$())).toBeInTheDocument();
+ expect(passage.getByRole('button', { name: tr.insertInlineChoice$() })).toBeInTheDocument();
+ expect(passage.getAllByRole('button', CHIP)).toHaveLength(2);
+ });
+
+ it('describes the passage editor with its helper text', async () => {
+ await renderEditor();
+ const passage = section(tr.passageEditorLabel$()).querySelector('.ProseMirror');
+ expect(passage).toHaveAccessibleDescription(tr.passageEditorDescription$());
+ });
+
+ it('opens on the question, focused, with the passage closed, when its card opens', async () => {
+ renderFocused(InlineChoiceEditor, {
+ props: {
+ interaction: interactionOf(),
+ questionType: QuestionType.INLINE_CHOICE,
+ mode: 'edit',
+ teleportTargetId: 'settings-target',
+ },
+ routes: new VueRouter(),
+ });
+ const editPassage = await screen.findByRole('button', { name: tr.editPassageLabel$() });
+ await waitFor(() =>
+ expect(screen.getByText('Pick the words').closest('.ProseMirror')).toHaveFocus(),
+ );
+ expect(
+ screen.queryByRole('button', { name: tr.insertInlineChoice$() }),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', CHIP)).not.toBeInTheDocument();
+ expect(editPassage).toBeInTheDocument();
+ });
+
+ it('leaves the question closed, and focus where it was, when it only mounts', async () => {
+ render(InlineChoiceEditor, {
+ props: {
+ interaction: interactionOf(),
+ questionType: QuestionType.INLINE_CHOICE,
+ mode: 'edit',
+ teleportTargetId: 'settings-target',
+ },
+ routes: new VueRouter(),
+ });
+ await screen.findByRole('button', { name: tr.editPassageLabel$() });
+ expect(
+ await screen.findByRole('button', { name: tr.editQuestionLabel$() }),
+ ).toBeInTheDocument();
+ expect(document.body).toHaveFocus();
+ });
+
+ it('shows no options panel until a chip is opened', async () => {
+ await renderEditor();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ describe('opening a chip', () => {
+ it('shows its options in a modal dialog named after it', async () => {
+ const { user } = await renderEditor();
+ const panel = await openChip(user, 0);
+
+ expect(panel).toHaveAttribute('aria-modal', 'true');
+ expect(panel).toHaveAccessibleName(tr.inlineChoiceOptionsDialogLabel$({ number: 1 }));
+ expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Moon', 'Stars']);
+ expect(chips()[0]).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('moves focus into its options list', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+ await waitFor(() => expect(optionInputs()[0]).toHaveFocus());
+ });
+
+ it('opens with Enter on the chip', async () => {
+ const { user } = await renderEditor();
+ // The passage opens focused; wait for that before moving to the chip.
+ await waitFor(() => expect(document.activeElement).toHaveClass('ProseMirror'));
+ chips()[1].focus();
+ await user.keyboard('{Enter}');
+ await screen.findByRole('dialog');
+ expect(optionInputs().map(input => input.value)).toEqual(['revolves', 'circles']);
+ });
+
+ it('shows the other chip’s options when another chip is opened', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+ await openChip(user, 1);
+
+ expect(screen.getAllByRole('dialog')).toHaveLength(1);
+ expect(dialog()).toHaveAccessibleName(tr.inlineChoiceOptionsDialogLabel$({ number: 2 }));
+ expect(optionInputs().map(input => input.value)).toEqual(['revolves', 'circles']);
+ });
+ });
+
+ describe('editing options', () => {
+ it('edits an option’s text, which the chip label and the saved item follow', async () => {
+ const { emitted, user } = await renderEditor();
+ await openChip(user, 1);
+ const [input] = optionInputs();
+ await user.clear(input);
+ await user.type(input, 'spins');
+
+ expect(input).toHaveValue('spins');
+ expect(chips()[1]).toHaveTextContent('spins');
+ expect(savedDropdowns(emitted())[1].options).toEqual([
+ { id: 'revolves', text: 'spins' },
+ { id: 'circles', text: 'circles' },
+ ]);
+ });
+
+ it('adds an option, updating the chip count, and focuses it', async () => {
+ const { emitted, user } = await renderEditor();
+ await openChip(user, 1);
+ await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() }));
+
+ expect(optionInputs()).toHaveLength(3);
+ await waitFor(() => expect(optionInputs()[2]).toHaveFocus());
+ await user.keyboard('rotates');
+ expect(chips()[1]).toHaveTextContent(/^3/);
+ expect(savedDropdowns(emitted())[1].options.map(o => o.text)).toEqual([
+ 'revolves',
+ 'circles',
+ 'rotates',
+ ]);
+ });
+
+ it('removes an option', async () => {
+ const { emitted, user } = await renderEditor();
+ await openChip(user, 0);
+ await user.click(
+ screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number: 2 }) }),
+ );
+
+ expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Stars']);
+ // The chip needs a correct answer, so its count is in its name, beside the error icon.
+ expect(chips()[0]).toHaveAccessibleName(/2 options/);
+ expect(savedDropdowns(emitted())[0].options.map(o => o.text)).toEqual(['Sun', 'Stars']);
+ });
+
+ it('keeps focus in the panel on the option that takes a removed one’s place', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+ const remove = number =>
+ screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number }) });
+
+ remove(2).focus();
+ await user.keyboard('{Enter}');
+ await waitFor(() => expect(optionInputs()[1]).toHaveFocus());
+ expect(optionInputs()[1]).toHaveValue('Stars');
+
+ remove(2).focus();
+ await user.keyboard('{Enter}');
+ await waitFor(() => expect(optionInputs()[0]).toHaveFocus());
+ expect(optionInputs()[0]).toHaveValue('Sun');
+ });
+
+ it('disables removing a chip’s only option', async () => {
+ const { user } = await renderEditor({
+ interaction: interactionOf({
+ passage: `The ${chip('r1', [['sun', 'Sun']], 'sun')}
`,
+ }),
+ });
+ await openChip(user, 0);
+ const remove = screen.getByRole('button', {
+ name: tr.deleteInlineChoiceOptionBtn$({ number: 1 }),
+ });
+ expect(remove).toBeDisabled();
+ });
+
+ it('marks exactly one option correct, and the chip shows it', async () => {
+ const { emitted, user } = await renderEditor();
+ await openChip(user, 0);
+ const radio = number =>
+ screen.getByRole('radio', { name: tr.markInlineChoiceOptionCorrect$({ number }) });
+
+ await user.click(radio(2));
+ expect(radio(2)).toBeChecked();
+ expect(chips()[0]).toHaveTextContent('Moon');
+
+ await user.click(radio(3));
+ expect(radio(2)).not.toBeChecked();
+ expect(radio(3)).toBeChecked();
+ expect(chips()[0]).toHaveTextContent('Stars');
+ expect(savedDropdowns(emitted())[0].correctId).toBe('stars');
+ });
+
+ it('keeps the correct mark on the same option when it moves', async () => {
+ const { emitted, user } = await renderEditor();
+ await openChip(user, 1);
+ await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() }));
+ await user.keyboard('rotates');
+ await user.click(
+ screen.getByRole('button', {
+ name: dragTr.$tr('moveItemDownLabel', { item: optionLabel(1) }),
+ }),
+ );
+
+ expect(optionInputs().map(input => input.value)).toEqual(['circles', 'revolves', 'rotates']);
+ expect(
+ screen.getByRole('radio', { name: tr.markInlineChoiceOptionCorrect$({ number: 2 }) }),
+ ).toBeChecked();
+ const [, dropdown] = savedDropdowns(emitted());
+ expect(dropdown.options.map(o => o.text)).toEqual(['circles', 'revolves', 'rotates']);
+ expect(dropdown.correctId).toBe('revolves');
+ });
+
+ it('leaves the chip with no correct answer when the correct option is removed', async () => {
+ const { emitted, user } = await renderEditor();
+ await openChip(user, 1);
+ await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() }));
+ await user.click(
+ screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number: 1 }) }),
+ );
+
+ expect(chips()[1]).toHaveTextContent(tr.errorNoCorrectAnswer$());
+ expect(savedDropdowns(emitted())[1].correctId).toBeNull();
+ });
+
+ it('restores the list when the edit is undone in the passage', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+ await user.click(
+ screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number: 1 }) }),
+ );
+ expect(optionInputs().map(input => input.value)).toEqual(['Moon', 'Stars']);
+
+ // A press on the passage's toolbar closes the panel.
+ const { undo$, redo$ } = getTipTapEditorStrings();
+ await user.click(screen.getByRole('button', { name: undo$() }));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ await openChip(user, 0);
+ expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Moon', 'Stars']);
+
+ await user.click(screen.getByRole('button', { name: redo$() }));
+ await openChip(user, 0);
+ expect(optionInputs().map(input => input.value)).toEqual(['Moon', 'Stars']);
+ });
+
+ it('undoes and redoes an option’s text with the passage’s history alone', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 1);
+ const [input] = optionInputs();
+ await user.type(input, 's');
+ expect(input).toHaveValue('revolvess');
+
+ // What the browser's Edit menu sends; true when the field's own history was kept out.
+ async function historyInput(inputType) {
+ const event = new InputEvent('beforeinput', { inputType, cancelable: true });
+ await fireEvent(input, event);
+ return event.defaultPrevented;
+ }
+ expect(await historyInput('historyUndo')).toBe(true);
+ expect(input).toHaveValue('revolves');
+ expect(chips()[1]).toHaveTextContent('revolves');
+ expect(input).toHaveFocus();
+
+ // The undo was not recorded as an edit, for a second one to type it back.
+ await historyInput('historyUndo');
+ expect(input).toHaveValue('revolves');
+
+ expect(await historyInput('historyRedo')).toBe(true);
+ expect(input).toHaveValue('revolvess');
+ });
+
+ // The field's own redo never has anything to redo, as its undo never runs, so the browser
+ // sends no `historyRedo` for these.
+ it.each([
+ ['Ctrl+Shift+Z', '{Control>}{Shift>}z{/Shift}{/Control}'],
+ ['Ctrl+Y', '{Control>}y{/Control}'],
+ ])('redoes an option’s text with %s', async (_, redoKeys) => {
+ const { user } = await renderEditor();
+ await openChip(user, 1);
+ const [input] = optionInputs();
+ await user.type(input, 's');
+
+ await user.keyboard('{Control>}z{/Control}');
+ expect(input).toHaveValue('revolves');
+ await user.keyboard(redoKeys);
+ expect(input).toHaveValue('revolvess');
+ expect(chips()[1]).toHaveTextContent('revolvess');
+ });
+
+ it('keeps focus in the panel when an undo takes away the focused option', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 1);
+ await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() }));
+ await waitFor(() => expect(optionInputs()[2]).toHaveFocus());
+
+ await user.keyboard('{Control>}z{/Control}');
+ expect(optionInputs().map(input => input.value)).toEqual(['revolves', 'circles']);
+ await waitFor(() => expect(optionInputs()[1]).toHaveFocus());
+
+ // Still in the panel, Escape still closes it.
+ await user.keyboard('{Escape}');
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('keeps focus in the panel when a redo takes away the focused option', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+ await user.click(
+ screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number: 2 }) }),
+ );
+ await user.keyboard('{Control>}z{/Control}');
+ expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Moon', 'Stars']);
+ optionInputs()[1].focus();
+
+ await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}');
+ expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Stars']);
+ await waitFor(() => expect(optionInputs()[1]).toHaveFocus());
+ });
+
+ it('switches to the chip an undo or redo changed, for the step to be seen', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 1);
+ await user.type(optionInputs()[0], 's');
+ await user.keyboard('{Escape}');
+ await openChip(user, 0);
+ await waitFor(() => expect(optionInputs()[0]).toHaveFocus());
+ const dialogOf = number =>
+ screen.getByRole('dialog', { name: tr.inlineChoiceOptionsDialogLabel$({ number }) });
+
+ await user.keyboard('{Control>}z{/Control}');
+ await waitFor(() => expect(dialogOf(2)).toContainElement(document.activeElement));
+ expect(optionInputs().map(input => input.value)).toEqual(['revolves', 'circles']);
+
+ await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}');
+ expect(optionInputs().map(input => input.value)).toEqual(['revolvess', 'circles']);
+ expect(dialogOf(2)).toContainElement(document.activeElement);
+ });
+
+ it('undoes and redoes from the panel’s other controls', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+ const radio = screen.getByRole('radio', {
+ name: tr.markInlineChoiceOptionCorrect$({ number: 2 }),
+ });
+ await user.click(radio);
+ await user.keyboard('{Control>}z{/Control}');
+ expect(radio).not.toBeChecked();
+ await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}');
+ expect(radio).toBeChecked();
+
+ await user.click(
+ screen.getByRole('button', {
+ name: dragTr.$tr('moveItemDownLabel', { item: optionLabel(1) }),
+ }),
+ );
+ expect(optionInputs().map(input => input.value)).toEqual(['Moon', 'Sun', 'Stars']);
+ await user.keyboard('{Control>}z{/Control}');
+ expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Moon', 'Stars']);
+ });
+
+ it('keeps focus in the panel when a redo takes away the focused remove button', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+ const remove = () =>
+ screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number: 2 }) });
+ await user.click(remove());
+ await user.keyboard('{Control>}z{/Control}');
+ expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Moon', 'Stars']);
+ remove().focus();
+
+ await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}');
+ expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Stars']);
+ await waitFor(() => expect(optionInputs()[1]).toHaveFocus());
+ });
+ });
+
+ describe('Shuffle', () => {
+ const shuffle = () => screen.getByRole('checkbox', { name: tr.shuffleAnswersLabel$() });
+ const moveDown = () =>
+ screen.queryByRole('button', {
+ name: dragTr.$tr('moveItemDownLabel', { item: optionLabel(1) }),
+ });
+
+ it('is off for a new question', async () => {
+ render(InlineChoiceEditor, {
+ props: {
+ interaction: descriptor.buildXML(descriptor.parse('', []), QuestionType.INLINE_CHOICE),
+ questionType: QuestionType.INLINE_CHOICE,
+ mode: 'edit',
+ teleportTargetId: 'settings-target',
+ },
+ routes: new VueRouter(),
+ });
+ expect(
+ await screen.findByRole('checkbox', { name: tr.shuffleAnswersLabel$() }),
+ ).not.toBeChecked();
+ });
+
+ it('hides the reorder controls while on, and shows them again when off', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+ expect(moveDown()).toBeInTheDocument();
+
+ await user.click(shuffle());
+ expect(moveDown()).not.toBeInTheDocument();
+
+ await user.click(shuffle());
+ expect(moveDown()).toBeInTheDocument();
+ });
+
+ it('is saved on every dropdown, and read back', async () => {
+ const { emitted, user } = await renderEditor();
+ await user.click(shuffle());
+
+ const [interaction] = emitted()['update:interaction'].at(-1);
+ expect([...interaction.bodyXml.matchAll(/shuffle="(\w+)"/g)].map(m => m[1])).toEqual([
+ 'true',
+ 'true',
+ ]);
+ expect(descriptor.parse(interaction.bodyXml, interaction.responseDeclarations).shuffle).toBe(
+ true,
+ );
+ });
+ });
+
+ describe('focus', () => {
+ const closeButton = () => screen.getByRole('button', { name: tr.closeInlineChoiceOptions$() });
+ const addOptionButton = () =>
+ screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() });
+
+ it('wraps from its last control to its first with Tab', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+ addOptionButton().focus();
+
+ await user.tab();
+ expect(closeButton()).toHaveFocus();
+ });
+
+ // The panel opens with focus put on its first option, not tabbed in.
+ it('wraps from its first control to its last with Shift+Tab, from the start', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+ await waitFor(() => expect(optionInputs()[0]).toHaveFocus());
+ closeButton().focus();
+
+ await user.tab({ shift: true });
+ expect(addOptionButton()).toHaveFocus();
+
+ closeButton().focus();
+ await user.tab({ shift: true });
+ expect(addOptionButton()).toHaveFocus();
+ });
+
+ it('returns to the chip when Escape closes the panel', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+ await waitFor(() => expect(optionInputs()[0]).toHaveFocus());
+
+ await user.keyboard('{Escape}');
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ await waitFor(() => expect(chips()[0]).toHaveFocus());
+ expect(chips()[0]).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('closes with Escape after a click on a part of the panel that takes no focus', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+ await user.click(within(dialog()).getByText(tr.inlineChoiceOptionsLabel$()));
+ expect(dialog()).toContainElement(document.activeElement);
+
+ await user.keyboard('{Escape}');
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ await waitFor(() => expect(chips()[0]).toHaveFocus());
+ });
+
+ it('returns to the chip when the close control closes the panel', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 1);
+ await user.click(screen.getByRole('button', { name: tr.closeInlineChoiceOptions$() }));
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ await waitFor(() => expect(chips()[1]).toHaveFocus());
+ });
+ });
+
+ it('closes the panel when its chip leaves the passage', async () => {
+ const { user } = await renderEditor();
+ // Inserting a chip opens it; undoing the insert takes it away.
+ await user.click(screen.getByRole('button', { name: tr.insertInlineChoice$() }));
+ await waitFor(() => expect(chips()).toHaveLength(3));
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ // From the keyboard, as a click in the passage would close the panel by itself.
+ const editor = section(tr.passageEditorLabel$()).querySelector('.ProseMirror');
+ editor.focus();
+ await fireEvent.keyDown(editor, { key: 'z', keyCode: 90, ctrlKey: true });
+ expect(chips()).toHaveLength(2);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('closes the panel on a click in the passage, for the passage to be edited', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+ const text = section(tr.passageEditorLabel$()).querySelector('.ProseMirror p');
+ // ProseMirror finds where a click lands from its coordinates, which jsdom lacks.
+ document.elementFromPoint = () => text;
+ try {
+ await user.click(text);
+ } finally {
+ delete document.elementFromPoint;
+ }
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(chips()[0]).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ describe('on a click outside the editor', () => {
+ let outside;
+ beforeEach(() => {
+ outside = document.createElement('button');
+ outside.textContent = 'Outside';
+ document.body.append(outside);
+ });
+ afterEach(() => outside.remove());
+
+ it('closes the panel, leaving focus where the click put it', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+ await waitFor(() => expect(optionInputs()[0]).toHaveFocus());
+
+ await user.click(outside);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(chips()[0]).toHaveAttribute('aria-expanded', 'false');
+ await nextTick();
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ expect(outside).toHaveFocus();
+ });
+
+ it('stays open after a press that began in it, as when an option is dragged out', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+
+ await fireEvent.pointerDown(optionInputs()[0]);
+ await fireEvent.pointerUp(outside);
+ // The browser clicks the closest element that holds both ends of the press.
+ await fireEvent.click(document.body);
+ expect(dialog()).toBeInTheDocument();
+ });
+
+ // As with the toolbar's menus, the click dismisses the popup; a second one closes the editor.
+ it('keeps the passage open', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+
+ await user.click(outside);
+ expect(section(tr.passageEditorLabel$()).querySelector('.ProseMirror')).toHaveAttribute(
+ 'contenteditable',
+ 'true',
+ );
+ });
+ });
+
+ it('keeps the panel open until a click on another chip switches it to that chip', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 0);
+
+ await fireEvent.pointerDown(chips()[1]);
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ await user.click(chips()[1]);
+ await waitFor(() =>
+ expect(optionInputs().map(input => input.value)).toEqual(['revolves', 'circles']),
+ );
+ expect(chips()[0]).toHaveAttribute('aria-expanded', 'false');
+ expect(chips()[1]).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('opens a chip it inserts, ready for its options', async () => {
+ const { emitted, user } = await renderEditor();
+ await user.click(screen.getByRole('button', { name: tr.insertInlineChoice$() }));
+
+ await screen.findByRole('dialog');
+ await waitFor(() => expect(optionInputs()[0]).toHaveFocus());
+ await user.keyboard('Venus');
+ const saved = savedDropdowns(emitted());
+ expect(saved).toHaveLength(3);
+ expect(saved.map(d => d.options.map(o => o.text))).toContainEqual(['Venus']);
+ });
+
+ it('focuses the first option of a chip inserted from the keyboard', async () => {
+ const { user } = await renderEditor();
+ await waitFor(() => expect(document.activeElement).toHaveClass('ProseMirror'));
+ // Off the passage, which then takes focus back a frame after the insert.
+ screen.getByRole('button', { name: tr.insertInlineChoice$() }).focus();
+ await user.keyboard('{Enter}');
+
+ await screen.findByRole('dialog');
+ await waitFor(() => expect(optionInputs()[0]).toHaveFocus());
+ await new Promise(requestAnimationFrame);
+ await new Promise(requestAnimationFrame);
+ expect(optionInputs()[0]).toHaveFocus();
+ });
+
+ it('saves the passage images in their stored form while a chip is open', async () => {
+ const image = '83ab37e959e03fec7be3e1bf834cb169.jpg';
+ const { emitted, user } = await renderEditor({
+ interaction: interactionOf({ passage: `${PASSAGE}
` }),
+ });
+ await openChip(user, 1);
+ await user.type(optionInputs()[0], 's');
+
+ const [interaction] = emitted()['update:interaction'].at(-1);
+ expect(interaction.bodyXml).toContain(`src="${image}"`);
+ expect(interaction.bodyXml).not.toContain('/content/storage/');
+ });
+
+ it('does not save which chip is open', async () => {
+ const { emitted, user } = await renderEditor();
+ const before = emitted()['update:interaction'].at(-1)[0].bodyXml;
+ await openChip(user, 0);
+ expect(emitted()['update:interaction'].at(-1)[0].bodyXml).toBe(before);
+ });
+
+ it('restores every chip’s options and correct answer when reopened', async () => {
+ const { emitted, unmount, user } = await renderEditor();
+ await openChip(user, 0);
+ await user.click(
+ screen.getByRole('radio', { name: tr.markInlineChoiceOptionCorrect$({ number: 3 }) }),
+ );
+ const [saved] = emitted()['update:interaction'].at(-1);
+ unmount();
+
+ await renderEditor({ interaction: saved });
+ expect(chips()[0]).toHaveTextContent('Stars');
+ expect(chips()[1]).toHaveTextContent('revolves');
+ await openChip(user, 0);
+ expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Moon', 'Stars']);
+ expect(
+ screen.getByRole('radio', { name: tr.markInlineChoiceOptionCorrect$({ number: 3 }) }),
+ ).toBeChecked();
+ });
+
+ describe('validation', () => {
+ it('reads the problems a chip opens with as the panel’s description, not as alerts', async () => {
+ const { user } = await renderEditor({
+ interaction: interactionOf({ passage: `The ${chip('r1', [['sun', '']])}
` }),
+ });
+ mockSendAssertiveMessage.mockClear();
+ await openChip(user, 0);
+ expect(dialog()).toHaveAccessibleDescription(
+ `${tr.errorNoCorrectAnswer$()} ${tr.errorTooFewInlineChoiceOptions$()}`,
+ );
+ expect(within(dialog()).queryAllByRole('alert')).toHaveLength(0);
+ expect(mockSendAssertiveMessage).not.toHaveBeenCalled();
+ });
+
+ it('keeps the passage’s problems no alert when the panel they appeared in closes', async () => {
+ const valid = chip(
+ 'r1',
+ [
+ ['sun', 'Sun'],
+ ['moon', 'Moon'],
+ ],
+ 'sun',
+ );
+ const { user } = await renderEditor({
+ interaction: interactionOf({ passage: `The ${valid} rises
` }),
+ });
+ await openChip(user, 0);
+ await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() }));
+ await user.keyboard('{Escape}');
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ const passage = within(section(tr.passageEditorLabel$()));
+ expect(passage.getByText(tr.errorInlineChoiceOptionProblems$())).toBeInTheDocument();
+ expect(passage.queryAllByRole('alert')).toHaveLength(0);
+ });
+
+ // Removing a chip in the passage is announced already.
+ it('raises no alert for a passage left without dropdowns as it is edited', async () => {
+ const user = userEvent.setup();
+ render(InlineChoiceEditor, {
+ props: {
+ interaction: interactionOf({ passage: 'Nothing to choose
' }),
+ questionType: QuestionType.INLINE_CHOICE,
+ mode: 'edit',
+ teleportTargetId: 'settings-target',
+ },
+ routes: new VueRouter(),
+ });
+ await user.click(await screen.findByRole('button', { name: tr.editPassageLabel$() }));
+ await user.click(await screen.findByRole('button', { name: tr.insertInlineChoice$() }));
+ await waitFor(() => expect(chips()).toHaveLength(1));
+
+ const { undo$ } = getTipTapEditorStrings();
+ await user.click(screen.getByRole('button', { name: undo$() }));
+ expect(screen.queryAllByRole('button', CHIP)).toHaveLength(0);
+ const passage = within(section(tr.passageEditorLabel$()));
+ expect(passage.getByText(tr.errorNoInlineChoice$())).toBeInTheDocument();
+ expect(passage.queryAllByRole('alert')).toHaveLength(0);
+ });
+
+ // The panel and the chip's name say it already, and an alert would cut off the focused field.
+ it('raises no alert for the passage’s problems while a panel is open', async () => {
+ const valid = chip(
+ 'r1',
+ [
+ ['sun', 'Sun'],
+ ['moon', 'Moon'],
+ ],
+ 'sun',
+ );
+ const { user } = await renderEditor({
+ interaction: interactionOf({ passage: `The ${valid} rises
` }),
+ });
+ await openChip(user, 0);
+ await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() }));
+
+ const passage = within(section(tr.passageEditorLabel$()));
+ expect(passage.getByText(tr.errorInlineChoiceOptionProblems$())).toBeInTheDocument();
+ expect(passage.queryAllByRole('alert')).toHaveLength(0);
+ });
+
+ it('announces a problem that appears while the panel is open', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 1);
+ mockSendAssertiveMessage.mockClear();
+ mockSendPoliteMessage.mockClear();
+ await user.click(
+ screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number: 2 }) }),
+ );
+ expect(within(dialog()).getByText(tr.errorTooFewInlineChoiceOptions$())).toBeInTheDocument();
+ // Remove moves focus to the next option: what appeared waits for that field to be read.
+ expect(mockSendPoliteMessage).toHaveBeenCalledWith(tr.errorTooFewInlineChoiceOptions$());
+ expect(mockSendAssertiveMessage).not.toHaveBeenCalled();
+
+ // The option Add option focuses reads its own error, so it is not announced as well.
+ mockSendAssertiveMessage.mockClear();
+ await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() }));
+ await waitFor(() => expect(optionInputs()[1]).toHaveFocus());
+ expect(optionInputs()[1]).toHaveAccessibleDescription(tr.errorEmptyChoiceContent$());
+ expect(mockSendAssertiveMessage).not.toHaveBeenCalled();
+
+ // An error that appears as the author types is announced, once for rows that share it.
+ await user.keyboard('revolves');
+ expect(within(dialog()).getAllByText(tr.errorDuplicateChoiceContent$())).toHaveLength(2);
+ expect(mockSendAssertiveMessage).toHaveBeenCalledTimes(1);
+ expect(mockSendAssertiveMessage).toHaveBeenCalledWith(tr.errorDuplicateChoiceContent$());
+ });
+
+ it('asks for a second option on a dropdown with one in its panel', async () => {
+ const { emitted, user } = await renderEditor({
+ interaction: interactionOf({
+ passage: `The ${chip('r1', [['sun', 'Sun']], 'sun')}
`,
+ }),
+ });
+ expect(emitted()['update:errors'].at(-1)[0]).toEqual([{ code: 'TOO_FEW_CHOICES', id: 'r1' }]);
+ // A marked answer still shows, as beside a blank option; the error icon flags the chip.
+ expect(chips()[0]).toHaveTextContent(/^Sun$/);
+ expect(chips()[0].querySelector('svg')).toBeInTheDocument();
+ expect(chips()[0]).toHaveAccessibleName(/needs attention/);
+
+ await openChip(user, 0);
+ expect(within(dialog()).getByText(tr.errorTooFewInlineChoiceOptions$())).toBeInTheDocument();
+ });
+
+ it('shows on a chip the first of the problems its panel lists', async () => {
+ const { user } = await renderEditor({
+ interaction: interactionOf({ passage: `The ${chip('r1', [['sun', 'Sun']])}
` }),
+ });
+ expect(chips()[0]).toHaveTextContent(new RegExp(`^${tr.errorNoCorrectAnswer$()}$`));
+
+ await openChip(user, 0);
+ const problems = within(dialog())
+ .getAllByText(
+ new RegExp(`^(${tr.errorNoCorrectAnswer$()}|${tr.errorTooFewInlineChoiceOptions$()})$`),
+ )
+ .map(el => el.textContent.trim());
+ expect(problems).toEqual([tr.errorNoCorrectAnswer$(), tr.errorTooFewInlineChoiceOptions$()]);
+ });
+
+ it('marks the question incomplete and flags a chip without a correct answer', async () => {
+ const { emitted, user } = await renderEditor();
+ expect(emitted()['update:errors'].at(-1)[0]).toEqual([
+ { code: 'NO_CORRECT_ANSWER', id: 'r1' },
+ ]);
+ expect(
+ within(section(tr.passageEditorLabel$())).getByText(tr.errorInlineChoiceOptionProblems$()),
+ ).toBeInTheDocument();
+ expect(chips()[0]).toHaveAccessibleName(/needs attention/);
+ expect(chips()[1]).not.toHaveAccessibleName(/needs attention/);
+ // The error icon stands in for the option count, as on an option row.
+ expect(chips()[0]).toHaveTextContent(new RegExp(`^${tr.errorNoCorrectAnswer$()}$`));
+ expect(chips()[0].querySelector('svg')).toBeInTheDocument();
+ expect(chips()[1]).toHaveTextContent('2 revolves');
+
+ await openChip(user, 0);
+ expect(within(dialog()).getByText(tr.errorNoCorrectAnswer$())).toBeInTheDocument();
+ });
+
+ it('shows a duplicate option on its row and on its chip', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 1);
+ await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() }));
+ await user.keyboard('revolves');
+
+ expect(within(dialog()).getAllByText(tr.errorDuplicateChoiceContent$())).toHaveLength(2);
+ expect(optionInputs()[0]).toHaveAccessibleDescription(tr.errorDuplicateChoiceContent$());
+ expect(optionInputs()[2]).toHaveAccessibleDescription(tr.errorDuplicateChoiceContent$());
+ expect(chips()[1]).toHaveAccessibleName(/needs attention/);
+ });
+
+ it('shows a blank option in its row', async () => {
+ const { user } = await renderEditor();
+ await openChip(user, 1);
+ await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() }));
+
+ expect(optionInputs()[2]).toHaveAttribute('aria-invalid', 'true');
+ expect(within(dialog()).getByText(tr.errorEmptyChoiceContent$())).toBeInTheDocument();
+ expect(optionInputs()[2]).toHaveAccessibleDescription(tr.errorEmptyChoiceContent$());
+ expect(optionInputs()[0]).not.toHaveAttribute('aria-describedby');
+ });
+
+ it('shows a passage without dropdowns on the passage editor', async () => {
+ render(InlineChoiceEditor, {
+ props: {
+ interaction: interactionOf({ passage: 'Nothing to choose
' }),
+ questionType: QuestionType.INLINE_CHOICE,
+ mode: 'edit',
+ teleportTargetId: 'settings-target',
+ },
+ routes: new VueRouter(),
+ });
+ await screen.findByRole('heading', { name: tr.passageEditorLabel$() });
+ const passageSection = within(section(tr.passageEditorLabel$()));
+ expect(passageSection.getByText(tr.errorNoInlineChoice$())).toBeInTheDocument();
+ expect(
+ passageSection.getByRole('button', { name: tr.editPassageLabel$() }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('view mode', () => {
+ it('shows the question and the passage with the correct answers', async () => {
+ await renderEditor({ mode: 'view', showAnswers: true });
+ expect(within(section(tr.questionLabel$())).getByText('Pick the words')).toBeInTheDocument();
+ const [, revealed] = within(section(tr.passageLabel$())).getAllByRole('img', CHIP);
+ expect(revealed).toHaveTextContent('revolves');
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('heading', { name: tr.passageEditorLabel$() }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('heading', { name: tr.questionOptionalLabel$() }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows the answers while editing, even in the closed passage', async () => {
+ render(InlineChoiceEditor, {
+ props: {
+ interaction: interactionOf(),
+ questionType: QuestionType.INLINE_CHOICE,
+ mode: 'edit',
+ showAnswers: false,
+ teleportTargetId: 'settings-target',
+ },
+ routes: new VueRouter(),
+ });
+ const [, closed] = await screen.findAllByRole('img', { name: /answer dropdown/ });
+ expect(closed).toHaveTextContent('revolves');
+ expect(closed).not.toHaveTextContent(tr.chooseAnswer$());
+ });
+
+ it('shows a choice to make in place of the answers without Show answers', async () => {
+ await renderEditor({ mode: 'view', showAnswers: false });
+ const [, hidden] = screen.getAllByRole('img', { name: /answer dropdown/ });
+ expect(hidden).not.toHaveTextContent('revolves');
+ expect(hidden).toHaveTextContent(`2 ${tr.chooseAnswer$()}`);
+ expect(hidden).toHaveAccessibleName(
+ tr.answerDropdownHidden$({ label: tr.chooseAnswer$(), count: 2 }),
+ );
+ });
+
+ it('marks a hidden chip that needs attention by more than colour', async () => {
+ await renderEditor({ mode: 'view', showAnswers: false });
+ const [incomplete, complete] = screen.getAllByRole('img', { name: /answer dropdown/ });
+ expect(incomplete).toHaveAccessibleName(
+ tr.answerDropdownHiddenNeedsAttention$({ label: tr.chooseAnswer$(), count: 3 }),
+ );
+ // The error icon stands in for its option count.
+ expect(incomplete).toHaveTextContent(new RegExp(`^${tr.chooseAnswer$()}$`));
+ expect(incomplete.querySelector('svg')).toBeInTheDocument();
+ expect(complete.querySelector('svg')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceChip.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceChip.spec.js
index 5ddfe7bdda..4bd8a0cef8 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceChip.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceChip.spec.js
@@ -1,6 +1,9 @@
import { nextTick, ref } from 'vue';
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
+// The resolver does not read package `exports` subpaths.
+// eslint-disable-next-line import/no-unresolved
+import { NodeSelection } from '@tiptap/pm/state';
import { qtiEditorStrings } from '../../../qtiEditorStrings';
import { CHIP, chip, findChip, renderPassage } from './renderPassage';
import { stubProseMirrorLayout } from 'shared/utils/testing';
@@ -13,6 +16,12 @@ const getChips = () => screen.getAllByRole('button', CHIP);
const chipPos = (editor, responseIdentifier) => findChip(editor, responseIdentifier).pos;
+// A valid dropdown has at least two options; `a` is the one these tests mark correct.
+const TWO_OPTIONS = [
+ ['a', 'x'],
+ ['b', 'w'],
+];
+
describe('InlineChoiceChip', () => {
stubProseMirrorLayout();
@@ -36,9 +45,16 @@ describe('InlineChoiceChip', () => {
expect(el).toHaveAccessibleName(/^Moon, /);
});
+ // The chip says what its options panel says is missing.
it.each([
- ['options have text but none is correct', [['a', 'Sun']], undefined, 1],
- ['no option has text', [['a', '']], undefined, 0],
+ [
+ 'options have text but none is correct',
+ [['a', 'Sun']],
+ undefined,
+ 1,
+ qtiEditorStrings.errorNoCorrectAnswer$(),
+ ],
+ ['no option has text', [['a', '']], undefined, 0, qtiEditorStrings.errorNoCorrectAnswer$()],
[
'the correct option has blank text',
[
@@ -47,14 +63,13 @@ describe('InlineChoiceChip', () => {
],
'a',
1,
+ qtiEditorStrings.errorEmptyChoiceContent$(),
],
- ])('prompts for answers when %s', async (_, options, correct, count) => {
+ ])('prompts for an answer when %s', async (_, options, correct, count, label) => {
await renderPassage({ value: `${chip('r1', options, correct)}
` });
const el = getChip();
- expect(el).toHaveTextContent(`${count} ${qtiEditorStrings.addAnswers$()}`);
- expect(el).toHaveAccessibleName(
- qtiEditorStrings.answerDropdownNoCorrect$({ count, label: qtiEditorStrings.addAnswers$() }),
- );
+ expect(el).toHaveTextContent(`${count} ${label}`);
+ expect(el).toHaveAccessibleName(qtiEditorStrings.answerDropdownNoCorrect$({ count, label }));
});
it('is a button that opens a dialog, closed until opened', async () => {
@@ -122,43 +137,171 @@ describe('InlineChoiceChip', () => {
fireEvent.keyDown(editor.view.dom, { key, keyCode, ...init });
it.each([
- ['ArrowRight', 39, 2],
- ['ArrowLeft', 37, 3],
- ])('does not open a chip reached with %s', async (key, keyCode, cursor) => {
- const { editor, openResponseIdentifier } = await renderPassage({
+ ['Backspace', 8, 3],
+ ['Delete', 46, 2],
+ ])('removes the chip beside the caret with %s', async (key, keyCode, cursor) => {
+ const { editor } = await renderPassage({
value: `a${chip('r1', [['c1', 'x']], 'c1')}b
`,
});
editor.commands.setTextSelection(cursor);
press(editor, key, keyCode);
- expect(editor.state.selection.node).toBeDefined();
- await nextTick();
- expect(openResponseIdentifier.value).toBeNull();
- expect(getChip()).toHaveAttribute('aria-expanded', 'false');
+ expect(editor.getHTML()).toBe('ab
');
+ });
+
+ // ProseMirror moves the caret past an inline node it cannot select, rather than onto it.
+ it('cannot be selected on its own, so the caret moves past it', async () => {
+ const { editor } = await renderPassage({
+ value: `a${chip('r1', [['c1', 'x']], 'c1')}b
`,
+ });
+ expect(NodeSelection.isSelectable(findChip(editor, 'r1').node)).toBe(false);
+
+ editor.commands.setTextSelection(2);
+ press(editor, 'ArrowRight', 39);
+ expect(editor.state.selection.empty).toBe(true);
+ expect(editor.state.selection.head).toBe(3);
});
it.each([
- ['Enter', 13],
- [' ', 32],
- ])('does not open a chip the caret is on when %p is pressed', async (key, keyCode) => {
+ ['{ArrowRight}', 3],
+ ['{ArrowLeft}', 2],
+ ])('puts the caret beside a focused chip with %s', async (key, caret) => {
+ const user = userEvent.setup();
const { editor, openResponseIdentifier } = await renderPassage({
value: `a${chip('r1', [['c1', 'x']], 'c1')}b
`,
});
- editor.commands.setNodeSelection(chipPos(editor, 'r1'));
- press(editor, key, keyCode);
- await nextTick();
+ getChip().focus();
+ await user.keyboard(key);
+
+ await waitFor(() => expect(editor.view.dom).toHaveFocus());
+ expect(editor.state.selection.empty).toBe(true);
+ expect(editor.state.selection.head).toBe(caret);
expect(openResponseIdentifier.value).toBeNull();
});
- it('deletes a keyboard-selected chip with Backspace and restores it with undo', async () => {
+ it('puts the caret by the text direction in right-to-left text', async () => {
+ const user = userEvent.setup();
+ const { editor } = await renderPassage({
+ value: `a${chip('r1', [['c1', 'x']], 'c1')}b
`,
+ });
+ const chipEl = getChip();
+ const { getComputedStyle } = window;
+ const spy = jest
+ .spyOn(window, 'getComputedStyle')
+ .mockImplementation((el, ...rest) =>
+ el === chipEl ? { direction: 'rtl' } : getComputedStyle.call(window, el, ...rest),
+ );
+ try {
+ chipEl.focus();
+ await user.keyboard('{ArrowLeft}');
+ } finally {
+ spy.mockRestore();
+ }
+
+ await waitFor(() => expect(editor.view.dom).toHaveFocus());
+ expect(editor.state.selection.head).toBe(3);
+ });
+
+ it('leaves a focused chip with a modified arrow key alone', async () => {
+ const user = userEvent.setup();
const { editor } = await renderPassage({
value: `a${chip('r1', [['c1', 'x']], 'c1')}b
`,
});
- editor.commands.setTextSelection(2);
- press(editor, 'ArrowRight', 39);
+ getChip().focus();
+ await user.keyboard('{Shift>}{ArrowRight}{/Shift}');
+ expect(getChip()).toHaveFocus();
+ expect(editor.view.dom).not.toHaveFocus();
+ });
+
+ // The chip keeps its keys from ProseMirror, so it runs the passage's history shortcuts itself.
+ it.each([
+ ['Ctrl+Shift+Z', '{Control>}{Shift>}z{/Shift}{/Control}'],
+ ['Ctrl+Y', '{Control>}y{/Control}'],
+ ])('undoes with Ctrl+Z and redoes with %s while focused', async (_, redoKeys) => {
+ const user = userEvent.setup();
+ const { editor } = await renderPassage({ value: `${chip('r1', TWO_OPTIONS, 'a')}
` });
+ editor.commands.updateInlineChoice('r1', {
+ options: [
+ { id: 'a', text: 'x' },
+ { id: 'c', text: 'z' },
+ { id: 'b', text: 'y' },
+ ],
+ correctId: 'b',
+ });
+ await waitFor(() => expect(getChip()).toHaveTextContent('3 y'));
+ getChip().focus();
+
+ await user.keyboard('{Control>}z{/Control}');
+ await waitFor(() => expect(getChip()).toHaveTextContent('2 x'));
+ expect(getChip()).toHaveFocus();
+
+ await user.keyboard(redoKeys);
+ await waitFor(() => expect(getChip()).toHaveTextContent('3 y'));
+ expect(getChip()).toHaveFocus();
+ });
+
+ // As the passage's keymap does, by the key's place when the layout gives a non-Latin letter.
+ it('undoes and redoes by the key’s place on a non-Latin keyboard layout', async () => {
+ const { editor } = await renderPassage({ value: `${chip('r1', TWO_OPTIONS, 'a')}
` });
+ editor.commands.updateInlineChoice('r1', { correctId: null });
+ await waitFor(() => expect(getChip()).not.toHaveTextContent('x'));
+ const press = init => fireEvent.keyDown(getChip(), { ctrlKey: true, ...init });
+
+ await press({ key: 'я', keyCode: 90 });
+ await waitFor(() => expect(getChip()).toHaveTextContent('x'));
+ await press({ key: 'Я', keyCode: 90, shiftKey: true });
+ await waitFor(() => expect(getChip()).not.toHaveTextContent('x'));
+ await press({ key: 'я', keyCode: 90 });
+ await waitFor(() => expect(getChip()).toHaveTextContent('x'));
+ await press({ key: 'н', keyCode: 89 });
+ await waitFor(() => expect(getChip()).not.toHaveTextContent('x'));
+ });
+
+ // jsdom is no Mac, so the passage's `Mod` is Ctrl, and Cmd+Z is no shortcut there.
+ it('leaves alone a history key the passage would not take', async () => {
+ const user = userEvent.setup();
+ const { editor } = await renderPassage({ value: `${chip('r1', [['a', 'x']], 'a')}
` });
+ editor.commands.updateInlineChoice('r1', { correctId: null });
+ await waitFor(() => expect(getChip()).not.toHaveTextContent('x'));
+ getChip().focus();
+
+ await user.keyboard('{Meta>}z{/Meta}');
+ expect(editor.can().undo()).toBe(true);
+ expect(getChip()).not.toHaveTextContent('x');
+ });
+
+ it('puts focus in the passage when an undo takes the focused chip away', async () => {
+ const user = userEvent.setup();
+ const { editor } = await renderPassage({ value: 'ab
' });
+ editor.chain().setTextSelection(2).insertInlineChoice('r1').run();
+ getChip().focus();
+
+ await user.keyboard('{Control>}z{/Control}');
+ expect(screen.queryByRole('button', CHIP)).not.toBeInTheDocument();
+ await waitFor(() => expect(editor.view.dom).toHaveFocus());
+ });
+
+ it('marks the chips a text selection covers, so the highlight can cover them', async () => {
+ const { editor } = await renderPassage({
+ value: `a${chip('r1', [['c1', 'x']], 'c1')}b${chip('r2', [['c2', 'y']], 'c2')}
`,
+ });
+ editor.commands.setTextSelection({ from: 1, to: 4 });
+ const [first, second] = getChips();
+ await waitFor(() => expect(first).toHaveClass('is-selected'));
+ expect(second).not.toHaveClass('is-selected');
+
+ editor.commands.setTextSelection(1);
+ await waitFor(() => expect(first).not.toHaveClass('is-selected'));
+ });
+
+ it('deletes a chip with Backspace and restores it with undo', async () => {
+ const { editor } = await renderPassage({
+ value: `a${chip('r1', TWO_OPTIONS, 'a')}b
`,
+ });
+ editor.commands.setTextSelection(3);
press(editor, 'Backspace', 8);
await waitFor(() => expect(screen.queryByRole('button', CHIP)).not.toBeInTheDocument());
press(editor, 'z', 90, { ctrlKey: true });
- await waitFor(() => expect(getChip()).toHaveTextContent('1 x'));
+ await waitFor(() => expect(getChip()).toHaveTextContent('2 x'));
});
it('focuses a chip by its response identifier, or the editor once it is gone', async () => {
@@ -180,20 +323,9 @@ describe('InlineChoiceChip', () => {
expect(() => focusChip('gone')).not.toThrow();
});
- it('outlines a node-selected chip without opening it', async () => {
- const { editor, openResponseIdentifier } = await renderPassage({
- value: `a${chip('r1', [['c1', 'x']], 'c1')}b
`,
- });
- editor.commands.setNodeSelection(chipPos(editor, 'r1'));
- await waitFor(() => expect(getChip()).toHaveClass('is-selected'));
- expect(openResponseIdentifier.value).toBeNull();
-
- editor.commands.setTextSelection(1);
- await waitFor(() => expect(getChip()).not.toHaveClass('is-selected'));
- });
-
// The open chip's panel (#6182) sits outside the editor; using it must not close the editor.
- // Relies on `useClickOutside`'s `hasOpenMenu` matching the chip's `aria-expanded` button.
+ // The editor stays open while a trigger inside it has an expanded popup; see "Popups outside
+ // the editor" in docs/rich_text_editor.md.
it('keeps the editor open on a click outside it while a chip is open', async () => {
const { onMinimize } = await renderPassage({
value: `${chip('r1', [['a', 'x']], 'a')}
`,
@@ -204,30 +336,43 @@ describe('InlineChoiceChip', () => {
expect(onMinimize).not.toHaveBeenCalled();
});
+ // So speech input finds the chip by what it shows; its count is in its name, so not read twice.
+ it('starts its name with the text it shows to assistive technology', async () => {
+ await renderPassage({ value: `${chip('r1', TWO_OPTIONS, 'a')}
` });
+ const el = getChip();
+ const shown = [...el.querySelectorAll('*')]
+ .filter(child => !child.children.length && !child.closest('[aria-hidden="true"]'))
+ .map(child => child.textContent.trim())
+ .filter(Boolean)
+ .join(' ');
+ expect(shown).toBe('x');
+ expect(el.getAttribute('aria-label').startsWith(shown)).toBe(true);
+ });
+
it('shows errors while its response identifier is in the error list', async () => {
const errorResponseIdentifiers = ref([]);
await renderPassage({
- value: `${chip('r1', [['a', 'x']], 'a')}
`,
+ value: `${chip('r1', TWO_OPTIONS, 'a')}
`,
errorResponseIdentifiers,
});
const el = getChip();
expect(el).not.toHaveClass('has-errors');
expect(el).toHaveAccessibleName(
- qtiEditorStrings.answerDropdownWithCorrect$({ count: 1, label: 'x' }),
+ qtiEditorStrings.answerDropdownWithCorrect$({ count: 2, label: 'x' }),
);
errorResponseIdentifiers.value = ['r1'];
await nextTick();
expect(el).toHaveClass('has-errors');
expect(el).toHaveAccessibleName(
- qtiEditorStrings.answerDropdownWithCorrectNeedsAttention$({ count: 1, label: 'x' }),
+ qtiEditorStrings.answerDropdownWithCorrectNeedsAttention$({ count: 2, label: 'x' }),
);
errorResponseIdentifiers.value = [];
await nextTick();
expect(el).not.toHaveClass('has-errors');
expect(el).toHaveAccessibleName(
- qtiEditorStrings.answerDropdownWithCorrect$({ count: 1, label: 'x' }),
+ qtiEditorStrings.answerDropdownWithCorrect$({ count: 2, label: 'x' }),
);
});
@@ -239,7 +384,7 @@ describe('InlineChoiceChip', () => {
expect(getChip()).toHaveAccessibleName(
qtiEditorStrings.answerDropdownNoCorrectNeedsAttention$({
count: 0,
- label: qtiEditorStrings.addAnswers$(),
+ label: qtiEditorStrings.errorNoCorrectAnswer$(),
}),
);
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js
index 202a5b6911..0a5892da1b 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js
@@ -1,5 +1,6 @@
-import { screen, waitFor } from '@testing-library/vue';
+import { render, screen, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
+import VueRouter from 'vue-router';
// The resolver does not read package `exports` subpaths.
// eslint-disable-next-line import/no-unresolved
import { closeHistory } from '@tiptap/pm/history';
@@ -7,6 +8,7 @@ import { getDropdowns, parseInlineChoiceInteraction } from '../parse';
import { chip, findChip, renderPassage } from './renderPassage';
import { stubProseMirrorLayout } from 'shared/utils/testing';
import { getTipTapEditorStrings } from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditorStrings';
+import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue';
const { copy$, paste$ } = getTipTapEditorStrings();
@@ -16,6 +18,18 @@ jest.mock('shared/utils/browserInfo', () => ({ isTouchDevice: false }));
// jsdom has no ClipboardEvent, which `pasteHTML` creates by default.
const paste = (editor, html) => editor.view.pasteHTML(html, new Event('paste'));
+/** Mounts a rich text editor without dropdowns, as the question field is; resolves its editor. */
+async function renderEditorWithoutChips() {
+ const onReady = jest.fn();
+ render(TipTapEditor, {
+ props: { value: '', mode: 'edit', format: 'html' },
+ listeners: { ready: onReady },
+ routes: new VueRouter(),
+ });
+ await waitFor(() => expect(onReady).toHaveBeenCalled());
+ return onReady.mock.calls[0][0];
+}
+
describe('InlineChoiceNode', () => {
stubProseMirrorLayout();
@@ -128,14 +142,6 @@ describe('InlineChoiceNode', () => {
expect(findChip(editor, 'r1').node.attrs.correctId).toBeNull();
});
- it('keeps a selected chip selected', async () => {
- const { editor } = await renderPassage({ value: `a${chip('r1', [['c1', 'x']])}b
` });
- const { pos } = findChip(editor, 'r1');
- editor.commands.setNodeSelection(pos);
- editor.commands.updateInlineChoice('r1', { options: [{ id: 'c1', text: 'y' }] });
- expect(editor.state.selection.toJSON()).toEqual({ type: 'node', anchor: pos });
- });
-
it('gives an added option with no id a fresh one, keeping the other ids', async () => {
const { editor } = await renderPassage({ value });
editor.commands.updateInlineChoice('r1', {
@@ -201,6 +207,34 @@ describe('InlineChoiceNode', () => {
});
});
+ // TipTap's typing shortcuts read the text before the caret, so a chip read as its answer
+ // could start one, and be deleted as the characters typed for it.
+ it.each(['-', '#', '>'])(
+ 'starts no typing shortcut with a chip answering %s at the start of a paragraph',
+ async answer => {
+ const { editor } = await renderPassage({
+ value: `${chip(
+ 'r1',
+ [
+ ['a', answer],
+ ['b', 'z'],
+ ],
+ 'a',
+ )}rest
`,
+ });
+ editor.commands.focus();
+ const { view } = editor;
+ const typeSpace = () => view.state.tr.insertText(' ', 2, 2);
+ const handled = view.someProp('handleTextInput', handle =>
+ handle(view, 2, 2, ' ', typeSpace),
+ );
+ if (!handled) view.dispatch(typeSpace());
+
+ expect(getDropdowns(editor.getHTML())).toHaveLength(1);
+ expect(editor.state.doc.firstChild.type.name).toBe('paragraph');
+ },
+ );
+
describe('paste', () => {
const source = chip(
'r1',
@@ -261,6 +295,25 @@ describe('InlineChoiceNode', () => {
expect(correctId).toBe(options[0].id);
});
+ // QTI requires an option in every dropdown, and every save is checked against it.
+ it.each([
+ [
+ 'a dropdown with no options',
+ '',
+ '',
+ ],
+ [
+ 'a copy whose dropdown cannot be read',
+ 'sun',
+ 'sun',
+ ],
+ ])('gives %s one option, holding its text', async (_, html, text) => {
+ const { editor } = await renderPassage({ value: 'a
' });
+ paste(editor, html);
+ const [pasted] = getDropdowns(editor.getHTML());
+ expect(pasted.options).toEqual([{ id: expect.stringMatching(/./), text }]);
+ });
+
it('gives a pasted chip missing its ids fresh ones', async () => {
const { editor } = await renderPassage({ value: 'a
' });
paste(
@@ -308,6 +361,48 @@ describe('InlineChoiceNode', () => {
]);
});
+ describe('a copy', () => {
+ // What copying the passage's first paragraph puts on the clipboard. TipTap writes the
+ // plain text from the selection.
+ const copyParagraph = editor => {
+ const to = editor.state.doc.firstChild.nodeSize - 1;
+ editor.commands.setTextSelection({ from: 1, to });
+ return editor.view.serializeForClipboard(editor.state.selection.content());
+ };
+
+ it('pastes into an editor without dropdowns as just its correct answer', async () => {
+ const { editor } = await renderPassage({ value: `a ${source} b
` });
+ const plain = await renderEditorWithoutChips();
+ paste(plain, copyParagraph(editor).dom.innerHTML);
+ expect(plain.getHTML()).toBe('a y b
');
+ });
+
+ it('leaves a dropdown with no correct answer out of an editor without dropdowns', async () => {
+ const { editor } = await renderPassage({
+ value: `a ${chip('r1', [['c1', 'x']])} b
`,
+ });
+ const plain = await renderEditorWithoutChips();
+ paste(plain, copyParagraph(editor).dom.innerHTML);
+ expect(plain.getText()).not.toContain('x');
+ });
+
+ // The plain text has no form a passage could read the chip back from.
+ it('leaves the chip out of its plain text', async () => {
+ const { editor } = await renderPassage({ value: `a ${source} b
` });
+ expect(copyParagraph(editor).text).toBe('a b');
+ });
+
+ it('pastes into a passage as the whole dropdown', async () => {
+ const { editor } = await renderPassage({ value: `a ${source} b
` });
+ const { dom } = copyParagraph(editor);
+ editor.commands.setTextSelection(editor.state.doc.content.size - 1);
+ paste(editor, dom.innerHTML);
+ const [, copy] = getDropdowns(editor.getHTML());
+ expect(copy.options.map(o => o.text)).toEqual(['x', 'y']);
+ expectFreshCopy(copy);
+ });
+ });
+
describe('with the toolbar Copy and Paste buttons', () => {
let clipboard;
@@ -381,6 +476,29 @@ describe('InlineChoiceNode', () => {
expect(copy.options.map(o => o.text)).toEqual(['x', 'y']);
expectFreshCopy(copy);
});
+
+ it('copies a selected chip as its correct answer for an editor without dropdowns', async () => {
+ const { editor } = await renderPassage({ value: `a${source}b
` });
+ editor.commands.focus();
+ editor.commands.setNodeSelection(findChip(editor, 'r1').pos);
+ await userEvent.click(screen.getByRole('button', { name: copy$() }));
+ await waitFor(() => expect(clipboard).toHaveLength(1));
+
+ const html = await (await clipboard[0].getType('text/html')).text();
+ const plain = await renderEditorWithoutChips();
+ paste(plain, html);
+ expect(plain.getText()).toBe('y');
+ });
+
+ it('leaves a chip out of its plain text, as Ctrl+C does', async () => {
+ const { editor } = await renderPassage({ value: `a ${source} b
` });
+ editor.commands.focus();
+ editor.commands.setTextSelection({ from: 1, to: editor.state.doc.content.size - 1 });
+ await userEvent.click(screen.getByRole('button', { name: copy$() }));
+ await waitFor(() => expect(clipboard).toHaveLength(1));
+
+ expect(await (await clipboard[0].getType('text/plain')).text()).toBe('a b');
+ });
});
describe('inserted by a transaction', () => {
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/convert.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/convert.spec.js
new file mode 100644
index 0000000000..44e1547af2
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/convert.spec.js
@@ -0,0 +1,120 @@
+import flatMap from 'lodash/flatMap';
+import { ContentLevel, ConversionLoss, QuestionType } from '../../../constants';
+import { carryContent } from '../../../conversion';
+import { inlineChoiceInteractionDescriptor as descriptor } from '../Descriptor';
+import { exportInlineChoiceContent, importInlineChoiceContent } from '../convert';
+import { getDropdowns } from '../parse';
+
+const dropdown = (name, options, correct) =>
+ `' +
+ options
+ .map(text => `${text}`)
+ .join('') +
+ '';
+
+/** A gap match passage, as gap match exports it: answers inline, one shared pool. */
+const gapMatchPassage = {
+ html: 'Now is the of our .
',
+ blanks: [
+ { answer: 'winter', options: [] },
+ { answer: 'discontent', options: [] },
+ ],
+ distractors: ['summer', 'winter'],
+ contentLevel: ContentLevel.INLINE,
+ shuffle: true,
+};
+
+const summary = passage =>
+ getDropdowns(passage).map(({ options, correctId }) => ({
+ options: options.map(({ text }) => text),
+ correct: options.find(({ id }) => id === correctId)?.text ?? null,
+ }));
+
+describe('inline choice conversion', () => {
+ it('exports each dropdown as a blank, its correct option the answer', () => {
+ const state = {
+ prompt: 'Q
',
+ passage: `The ${dropdown('a', ['Moon', 'Sun'], 'Sun')} and ${dropdown('b', ['x'])}
`,
+ shuffle: true,
+ };
+ expect(exportInlineChoiceContent(state)).toEqual({
+ prompt: 'Q
',
+ passage: {
+ html: 'The and
',
+ blanks: [
+ { answer: 'Sun', options: ['Moon'] },
+ { answer: null, options: ['x'] },
+ ],
+ distractors: [],
+ contentLevel: ContentLevel.PLAIN,
+ shuffle: true,
+ },
+ });
+ });
+
+ it('imports a passage from gap match, each dropdown offering the whole pool', () => {
+ const { carried, losses } = carryContent(
+ { prompt: 'Q
', passage: gapMatchPassage },
+ descriptor.getConversionFormat(QuestionType.INLINE_CHOICE),
+ );
+ // The answer was in italics, which an option can't hold.
+ expect(losses).toEqual([ConversionLoss.FORMATTING_REMOVED]);
+
+ const state = importInlineChoiceContent(carried);
+ expect(state.prompt).toBe('Q
');
+ expect(state.shuffle).toBe(true);
+ expect(summary(state.passage)).toEqual([
+ { options: ['winter', 'discontent', 'summer'], correct: 'winter' },
+ { options: ['discontent', 'winter', 'summer'], correct: 'discontent' },
+ ]);
+ });
+
+ it('gives a blank with no answer and nothing to offer one empty option, unmarked', () => {
+ const state = importInlineChoiceContent({
+ prompt: '',
+ passage: {
+ html: '
',
+ blanks: [{ answer: null, options: [] }],
+ distractors: [],
+ contentLevel: ContentLevel.PLAIN,
+ },
+ });
+ expect(summary(state.passage)).toEqual([{ options: [''], correct: null }]);
+ expect(state.shuffle).toBe(false);
+ });
+
+ it('gives every dropdown its own identifiers', () => {
+ const { carried } = carryContent(
+ { prompt: '', passage: gapMatchPassage },
+ descriptor.getConversionFormat(QuestionType.INLINE_CHOICE),
+ );
+ const dropdowns = getDropdowns(importInlineChoiceContent(carried).passage);
+ const ids = flatMap(dropdowns, d => [d.responseIdentifier, ...d.options.map(o => o.id)]);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it('imports a new question with the question only, when there is no passage', () => {
+ expect(importInlineChoiceContent({ prompt: 'Q
' })).toEqual({
+ prompt: 'Q
',
+ passage: '',
+ shuffle: false,
+ });
+ });
+
+ it('writes a passage the inline choice editor reads back the same', () => {
+ const { carried } = carryContent(
+ { prompt: 'Q
', passage: gapMatchPassage },
+ descriptor.getConversionFormat(QuestionType.INLINE_CHOICE),
+ );
+ const state = importInlineChoiceContent(carried);
+ const { bodyXml, responseDeclarations } = descriptor.buildXML(
+ state,
+ QuestionType.INLINE_CHOICE,
+ );
+ const reopened = descriptor.parse(bodyXml, responseDeclarations);
+ expect(summary(reopened.passage)).toEqual(summary(state.passage));
+ expect(descriptor.validate(reopened)).toEqual([]);
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js
index 5967a48456..4730e94f46 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js
@@ -28,7 +28,7 @@ export const findChip = (editor, responseIdentifier) =>
findChipIn(editor.state.doc, responseIdentifier) || null;
/** Mounts the real TipTapEditor through `useInlineChoicePassage`; resolves once it is ready. */
-export async function renderPassage({ value, errorResponseIdentifiers, mode = 'edit' }) {
+export async function renderPassage({ value, errorResponseIdentifiers, onChange, mode = 'edit' }) {
let passage;
let provided;
const modeRef = ref(mode);
@@ -45,7 +45,7 @@ export async function renderPassage({ value, errorResponseIdentifiers, mode = 'e
{
components: { TipTapEditor, Panel },
setup() {
- passage = useInlineChoicePassage({ errorResponseIdentifiers });
+ passage = useInlineChoicePassage({ errorResponseIdentifiers, onChange });
return {
extensions: passage.extensions,
insertActions: passage.insertActions,
@@ -71,6 +71,8 @@ export async function renderPassage({ value, errorResponseIdentifiers, mode = 'e
editor: onReady.mock.calls[0][0],
openResponseIdentifier: passage.openResponseIdentifier,
focusChip: provided.focusChip,
+ undo: passage.undo,
+ redo: passage.redo,
onMinimize,
setMode: newMode => {
modeRef.value = newMode;
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js
index df1e61fbdc..e831b59f66 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js
@@ -1,6 +1,7 @@
import { nextTick } from 'vue';
-import { screen, waitFor } from '@testing-library/vue';
+import { fireEvent, screen, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
+import useKLiveRegion from 'kolibri-design-system/lib/composables/useKLiveRegion';
import { qtiEditorStrings } from '../../../qtiEditorStrings';
import { getDropdowns } from '../parse';
import { CHIP, chip, renderPassage } from './renderPassage';
@@ -8,6 +9,23 @@ import { stubProseMirrorLayout } from 'shared/utils/testing';
// jsdom defines `ontouchstart`, which would put the editor in its touch layout.
jest.mock('shared/utils/browserInfo', () => ({ isTouchDevice: false }));
+// KDS reads the window as small until it has measured it, which would swap Insert for its icon
+// button after the first render.
+jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
+ const { ref } = require('vue');
+ return {
+ __esModule: true,
+ default: () => ({ windowIsSmall: ref(false) }),
+ };
+});
+
+jest.mock('kolibri-design-system/lib/composables/useKLiveRegion', () => {
+ const sendAssertiveMessage = jest.fn();
+ return {
+ __esModule: true,
+ default: () => ({ sendPoliteMessage: jest.fn(), sendAssertiveMessage }),
+ };
+});
const dropdowns = editor => getDropdowns(editor.getHTML());
@@ -19,8 +37,8 @@ const chipHTML = ({ responseIdentifier, options, correctId }) =>
);
// The handler only trusts the selection once the author has focused the editor.
-async function renderAt(value, from, to = from) {
- const rendered = await renderPassage({ value });
+async function renderAt(value, from, to = from, passageOptions = {}) {
+ const rendered = await renderPassage({ value, ...passageOptions });
rendered.editor.view.dom.focus();
rendered.editor.commands.setTextSelection({ from, to });
await nextTick();
@@ -51,7 +69,7 @@ describe('useInlineChoicePassage Insert action', () => {
expect(editor.getHTML()).toBe(`The ${chipHTML(dropdown)}rises
`);
expect(openResponseIdentifier.value).toBe(dropdown.responseIdentifier);
const el = await screen.findByRole('button', CHIP);
- expect(el).toHaveTextContent(`0 ${qtiEditorStrings.addAnswers$()}`);
+ expect(el).toHaveTextContent(`0 ${qtiEditorStrings.errorNoCorrectAnswer$()}`);
await waitFor(() => expect(el).toHaveAttribute('aria-expanded', 'true'));
});
@@ -151,20 +169,6 @@ describe('useInlineChoicePassage Insert action', () => {
expect(dropdowns(editor)).toHaveLength(1);
});
- it('adds a new chip after a node-selected chip instead of replacing it', async () => {
- const { editor, openResponseIdentifier } = await renderAt(`a${chipHTML(ORIGINAL)}b
`, 1);
- editor.commands.setNodeSelection(2);
- await nextTick();
- await clickInsert();
-
- expect(dropdowns(editor)).toHaveLength(2);
- const [first, second] = dropdowns(editor);
- expect(first).toEqual(ORIGINAL);
- expect(second.options).toEqual([{ id: expect.any(String), text: '' }]);
- expect(editor.getHTML()).toBe(`a${chipHTML(ORIGINAL)}${chipHTML(second)}b
`);
- expect(openResponseIdentifier.value).toBe(second.responseIdentifier);
- });
-
it('keeps a chip inside the highlighted text and adds the new one after it', async () => {
const { editor } = await renderAt(`a ${chipHTML(ORIGINAL)} b
`, 1, 5);
await clickInsert();
@@ -176,3 +180,173 @@ describe('useInlineChoicePassage Insert action', () => {
expect(editor.getHTML()).toBe(`a ${chipHTML(ORIGINAL)} ${chipHTML(second)}b
`);
});
});
+
+describe('useInlineChoicePassage announcements', () => {
+ stubProseMirrorLayout();
+
+ const { sendAssertiveMessage } = useKLiveRegion();
+ beforeEach(() => sendAssertiveMessage.mockClear());
+
+ /** Puts the caret where a test starts, leaving out what getting it there announced. */
+ async function renderAnnouncerAt(...args) {
+ const rendered = await renderAt(...args);
+ sendAssertiveMessage.mockClear();
+ return rendered;
+ }
+
+ const announced = () => sendAssertiveMessage.mock.calls.map(([message]) => message);
+
+ // ProseMirror reads `keyCode`, which `userEvent.keyboard` does not set.
+ const press = (editor, key, keyCode) => fireEvent.keyDown(editor.view.dom, { key, keyCode });
+
+ const TWO_CHIPS = `a${chip(
+ 'r1',
+ [
+ ['c1', 'x'],
+ ['c4', 'w'],
+ ],
+ 'c1',
+ )}b${chip('r2', [
+ ['c2', 'y'],
+ ['c3', 'z'],
+ ])}
`;
+
+ it.each([
+ ['forward', 2, 3],
+ ['backward', 3, 2],
+ ])('names a chip the caret moves %s across', async (_, from, to) => {
+ const { editor } = await renderAnnouncerAt(TWO_CHIPS, from);
+ editor.commands.setTextSelection(to);
+ expect(announced()).toEqual([
+ qtiEditorStrings.answerDropdownWithCorrect$({ label: 'x', count: 2 }),
+ ]);
+ });
+
+ it('names a chip that needs attention as such', async () => {
+ const { editor } = await renderAnnouncerAt(TWO_CHIPS, 4, 4, {
+ errorResponseIdentifiers: ['r2'],
+ });
+ editor.commands.setTextSelection(5);
+ expect(announced()).toEqual([
+ qtiEditorStrings.answerDropdownNoCorrectNeedsAttention$({
+ label: qtiEditorStrings.errorNoCorrectAnswer$(),
+ count: 2,
+ }),
+ ]);
+ });
+
+ it('says nothing about a caret move across text', async () => {
+ const { editor } = await renderAnnouncerAt(TWO_CHIPS, 1);
+ editor.commands.setTextSelection(2);
+ expect(announced()).toEqual([]);
+ });
+
+ it('says which chip Backspace removed', async () => {
+ const { editor } = await renderAnnouncerAt(TWO_CHIPS, 3);
+ press(editor, 'Backspace', 8);
+ expect(announced()).toEqual([qtiEditorStrings.answerDropdownRemoved$({ label: 'x' })]);
+ });
+
+ it('counts the chips removed together', async () => {
+ const { editor } = await renderAnnouncerAt(TWO_CHIPS, 1, 6);
+ editor.commands.deleteSelection();
+ expect(announced()).toEqual([qtiEditorStrings.answerDropdownsRemoved$({ count: 2 })]);
+ });
+
+ // The options panel and a focused chip step through the passage's history from outside it.
+ it('says which chip an undo or redo from outside the passage removed', async () => {
+ const { editor, undo, redo } = await renderPassage({ value: TWO_CHIPS });
+ editor.commands.deleteRange({ from: 2, to: 3 });
+ undo();
+ expect(announced()).toEqual([]);
+ redo();
+ expect(announced()).toEqual([qtiEditorStrings.answerDropdownRemoved$({ label: 'x' })]);
+ });
+
+ it('says so when an undo from a focused chip removes it', async () => {
+ const user = userEvent.setup();
+ const { editor } = await renderPassage({ value: 'ab
' });
+ editor.chain().setTextSelection(2).insertInlineChoice('r1').run();
+ screen.getByRole('button', CHIP).focus();
+
+ await user.keyboard('{Control>}z{/Control}');
+ expect(announced()).toEqual([
+ qtiEditorStrings.answerDropdownRemoved$({ label: qtiEditorStrings.errorNoCorrectAnswer$() }),
+ ]);
+ });
+
+ it('says nothing about changes made while the passage is not focused', async () => {
+ const { editor } = await renderPassage({ value: TWO_CHIPS });
+ editor.commands.setTextSelection(2);
+ editor.commands.setTextSelection(3);
+ editor.commands.setContent('ab
');
+ expect(announced()).toEqual([]);
+ });
+});
+
+describe('useInlineChoicePassage history from outside the passage', () => {
+ stubProseMirrorLayout();
+
+ // The options panel steps through the passage's history; scrolling to the step would move the
+ // page away from the panel.
+ it('undoes and redoes without scrolling to the change', async () => {
+ const { editor, undo, redo } = await renderPassage({
+ value: `a${chip('r1', [['c1', 'x']], 'c1')}b
`,
+ });
+ editor.commands.deleteRange({ from: 2, to: 3 });
+ const steps = [];
+ editor.on('transaction', ({ transaction }) => steps.push(transaction));
+
+ undo();
+ redo();
+ expect(steps.filter(step => step.docChanged)).toHaveLength(2);
+ expect(steps.some(step => step.scrolledIntoView)).toBe(false);
+ });
+});
+
+describe('useInlineChoicePassage syncing', () => {
+ stubProseMirrorLayout();
+
+ const press = (editor, key, keyCode, init = {}) =>
+ fireEvent.keyDown(editor.view.dom, { key, keyCode, ...init });
+
+ const VALUE = `a${chip('r1', [['c1', 'x']], 'c1')}b
`;
+
+ async function renderSynced() {
+ const onChange = jest.fn();
+ const rendered = await renderPassage({ value: VALUE, onChange });
+ rendered.editor.view.dom.focus();
+ return { ...rendered, onChange };
+ }
+
+ it('reports the passage when a chip is removed, with no chip open', async () => {
+ const { editor, onChange } = await renderSynced();
+ editor.commands.setTextSelection(3);
+ press(editor, 'Backspace', 8);
+ expect(onChange).toHaveBeenLastCalledWith('ab
');
+ });
+
+ it('reports the passage when undo restores a removed chip', async () => {
+ const { editor, onChange } = await renderSynced();
+ editor.commands.setTextSelection(3);
+ press(editor, 'Backspace', 8);
+ press(editor, 'z', 90, { ctrlKey: true });
+ expect(dropdowns(editor)).toHaveLength(1);
+ expect(onChange).toHaveBeenLastCalledWith(editor.getHTML());
+ });
+
+ it('reports the passage when undo changes a closed chip’s options', async () => {
+ const { editor, onChange } = await renderSynced();
+ editor.commands.updateInlineChoice('r1', { correctId: null });
+ onChange.mockClear();
+ press(editor, 'z', 90, { ctrlKey: true });
+ expect(dropdowns(editor)[0].correctId).toBe('c1');
+ expect(onChange).toHaveBeenLastCalledWith(editor.getHTML());
+ });
+
+ it('leaves typing that changes no chip to be reported on blur', async () => {
+ const { editor, onChange } = await renderSynced();
+ editor.commands.insertContentAt(1, 'z');
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/validation.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/validation.spec.js
index 0ff4c8540b..ea80acf3b6 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/validation.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/validation.spec.js
@@ -77,11 +77,12 @@ describe('validateInlineChoiceInteraction', () => {
expect(errors).toEqual([{ code: ValidationError.EMPTY_CHOICE_CONTENT, id: 'b' }]);
});
- it('reports a single empty option with no correct answer as exactly two errors', () => {
+ it('reports a single empty option with no correct answer as exactly three errors', () => {
const errors = validate(withPassage(dropdown('r1', null, [{ id: 'a', text: '' }])));
expect(errors).toEqual([
{ code: ValidationError.EMPTY_CHOICE_CONTENT, id: 'a' },
{ code: ValidationError.NO_CORRECT_ANSWER, id: 'r1' },
+ { code: ValidationError.TOO_FEW_CHOICES, id: 'r1' },
]);
});
});
@@ -172,11 +173,32 @@ describe('validateInlineChoiceInteraction', () => {
it('is reported per dropdown', () => {
const errors = validate(
withPassage(
- dropdown('r1', 'a', [{ id: 'a', text: 'blue' }]),
- dropdown('r2', null, [{ id: 'c', text: 'red' }]),
+ dropdown('r1', 'a', [
+ { id: 'a', text: 'blue' },
+ { id: 'b', text: 'green' },
+ ]),
+ dropdown('r2', null, [
+ { id: 'c', text: 'red' },
+ { id: 'd', text: 'grey' },
+ ]),
),
);
expect(errors).toEqual([{ code: ValidationError.NO_CORRECT_ANSWER, id: 'r2' }]);
});
});
+
+ describe('TOO_FEW_CHOICES', () => {
+ it('flags a dropdown with one option by its response id', () => {
+ const errors = validate(withPassage(dropdown('r1', 'a', [{ id: 'a', text: 'blue' }])));
+ expect(errors).toEqual([{ code: ValidationError.TOO_FEW_CHOICES, id: 'r1' }]);
+ });
+
+ it('comes after a missing correct answer, as the options panel shows them', () => {
+ const errors = validate(withPassage(dropdown('r1', null, [{ id: 'a', text: 'blue' }])));
+ expect(errors).toEqual([
+ { code: ValidationError.NO_CORRECT_ANSWER, id: 'r1' },
+ { code: ValidationError.TOO_FEW_CHOICES, id: 'r1' },
+ ]);
+ });
+ });
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue
index 4cdeefad77..e24aa0656d 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue
@@ -11,19 +11,40 @@
'chip',
{
'is-open': isEditable && isOpen,
+ 'is-revealed': !isEditable && showAnswers,
+ 'is-concealed': isConcealed,
'has-errors': hasErrors,
+ // TipTap marks a chip selected whenever the selection covers it, text selections
+ // included.
'is-selected': isEditable && selected,
},
$computedClass({ ':focus': $coreOutline }),
]"
data-copy-ignore
@click="isEditable && open()"
+ @keydown="isEditable && onKeydown($event)"
>
-
+
+
+
+
{{ $formatNumber(optionCount) }}
{{ label }}
@@ -46,8 +67,9 @@
// eslint-disable-next-line import/no-unresolved
import { DOMSerializer } from '@tiptap/pm/model';
import { NodeViewWrapper } from '@tiptap/vue-2';
- import { qtiEditorStrings } from '../../../../qtiEditorStrings';
- import { injectPassageChips } from '../../passageChips';
+ import { describeChip, injectPassageChips } from '../../passageChips';
+ import { copiedDropdown } from '../../parse';
+ import { historyShortcut } from '../../historyShortcut';
export default {
name: 'InlineChoiceChip',
@@ -57,42 +79,31 @@
},
setup(props) {
- const { openResponseIdentifier, openDropdown, isEditable, errorResponseIdentifiers } =
- injectPassageChips();
const {
- addAnswers$,
- answerDropdownWithCorrect$,
- answerDropdownNoCorrect$,
- answerDropdownWithCorrectNeedsAttention$,
- answerDropdownNoCorrectNeedsAttention$,
- } = qtiEditorStrings;
-
+ openResponseIdentifier,
+ openDropdown,
+ focusChip,
+ stepHistory: stepPassageHistory,
+ isEditable,
+ errorResponseIdentifiers,
+ showAnswers,
+ } = injectPassageChips();
const responseIdentifier = computed(() => props.node.attrs.responseIdentifier);
- const optionCount = computed(
- () => props.node.attrs.options.filter(option => option.text.trim()).length,
- );
- const correctText = computed(() => {
- const correct = props.node.attrs.options.find(
- option => option.id === props.node.attrs.correctId,
- );
- return correct ? correct.text.trim() : '';
- });
- const hasAnswer = computed(() => correctText.value !== '');
const hasErrors = computed(() =>
errorResponseIdentifiers.value.includes(responseIdentifier.value),
);
- const label = computed(() => (hasAnswer.value ? correctText.value : addAnswers$()));
- // Starts with the visible label, so voice control finds the chip by what it shows.
- const accessibleName = computed(() => {
- const name = hasAnswer.value
- ? hasErrors.value
- ? answerDropdownWithCorrectNeedsAttention$
- : answerDropdownWithCorrect$
- : hasErrors.value
- ? answerDropdownNoCorrectNeedsAttention$
- : answerDropdownNoCorrect$;
- return name({ label: label.value, count: optionCount.value });
- });
+ // A preview that hides the answers shows what the learner sees before choosing.
+ const isConcealed = computed(() => !isEditable.value && !showAnswers.value);
+ const description = computed(() =>
+ describeChip(props.node.attrs, {
+ hasErrors: hasErrors.value,
+ isConcealed: isConcealed.value,
+ }),
+ );
+ const optionCount = computed(() => description.value.optionCount);
+ const hasAnswer = computed(() => description.value.hasAnswer);
+ const label = computed(() => description.value.label);
+ const accessibleName = computed(() => description.value.accessibleName);
const isOpen = computed(() => openResponseIdentifier.value === responseIdentifier.value);
const controlAttrs = computed(() =>
@@ -105,21 +116,60 @@
watchEffect(
() => {
if (!copySource.value) return;
- const serializer = DOMSerializer.fromSchema(props.editor.schema);
- copySource.value.replaceChildren(serializer.serializeNode(props.node));
+ const { dom } = DOMSerializer.renderSpec(document, copiedDropdown(props.node.attrs));
+ copySource.value.replaceChildren(dom);
},
{ flush: 'post' },
);
+ /**
+ * Left and Right on the focused chip put the caret back in the passage beside it, as they
+ * move past the chip there. They go by the text's direction, so in right-to-left text
+ * Left lands after the chip.
+ */
+ function leaveForText(event) {
+ if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
+ if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
+ const pos = props.getPos();
+ if (typeof pos !== 'number') return;
+ event.preventDefault();
+ const rtl = window.getComputedStyle(event.currentTarget).direction === 'rtl';
+ const after = (event.key === 'ArrowRight') !== rtl;
+ props.editor
+ .chain()
+ .setTextSelection(after ? pos + props.node.nodeSize : pos)
+ .focus()
+ .run();
+ }
+
+ /** The passage's undo and redo, as its keys never reach ProseMirror from the chip. */
+ function stepHistory(event) {
+ const direction = historyShortcut(event);
+ if (!direction) return;
+ event.preventDefault();
+ const button = event.currentTarget;
+ stepPassageHistory(direction);
+ // An undo can take the chip away, and focus with it.
+ if (!button.isConnected) focusChip(responseIdentifier.value);
+ }
+
+ function onKeydown(event) {
+ leaveForText(event);
+ stepHistory(event);
+ }
+
return {
+ onKeydown,
copySource,
open: () => openDropdown(responseIdentifier.value),
optionCount,
hasAnswer,
+ isConcealed,
hasErrors,
label,
accessibleName,
isEditable,
+ showAnswers,
isOpen,
controlAttrs,
};
@@ -138,6 +188,10 @@
type: Boolean,
default: false,
},
+ getPos: {
+ type: Function,
+ required: true,
+ },
},
};
@@ -146,46 +200,62 @@
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/convert.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/convert.js
new file mode 100644
index 0000000000..6f83a3a6e2
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/convert.js
@@ -0,0 +1,94 @@
+import { ContentLevel } from '../../constants';
+import { getContentHTML, parseHTMLBody } from '../../serialization/xml';
+import { BLANK_ATTR } from '../../conversion/passage';
+import {
+ DROPDOWN,
+ _defaultState,
+ newDropdown,
+ newOption,
+ readDropdown,
+ writeDropdown,
+} from './parse';
+
+/**
+ * @returns {import('../../conversion').ConversionFormat}
+ */
+export function getInlineChoiceConversionFormat() {
+ return { passage: { contentLevel: ContentLevel.PLAIN } };
+}
+
+/**
+ * Each dropdown becomes a blank, its correct option the blank's answer and the rest its own
+ * options.
+ *
+ * @param {import('./parse').InlineChoiceState} state
+ * @returns {import('../../conversion').ConvertibleContent}
+ */
+export function exportInlineChoiceContent(state) {
+ const body = parseHTMLBody(state.passage);
+ const blanks = [...body.querySelectorAll(DROPDOWN)].map((el, index) => {
+ const { options, correctId } = readDropdown(el);
+ const placeholder = body.ownerDocument.createElement('span');
+ placeholder.setAttribute(BLANK_ATTR, String(index));
+ el.replaceWith(placeholder);
+
+ const correct = options.find(option => option.id === correctId);
+ return {
+ answer: correct ? correct.text : null,
+ options: options.filter(option => option !== correct).map(option => option.text),
+ };
+ });
+ return {
+ prompt: state.prompt,
+ passage: {
+ html: getContentHTML(body),
+ blanks,
+ distractors: [],
+ contentLevel: ContentLevel.PLAIN,
+ shuffle: state.shuffle,
+ },
+ };
+}
+
+/**
+ * A dropdown offering everything a learner could have put in its blank: its answer, its own
+ * options, every other blank's answer and every distractor, as a gap match learner chooses
+ * from one shared pool. Its answer, when it has one, is its correct option. One with nothing
+ * to offer starts as an unfilled dropdown, with one empty option.
+ */
+function buildDropdown(doc, { answer, options }, answers, distractors) {
+ const texts = [
+ ...new Set(
+ [answer, ...options, ...answers, ...distractors].map(text => text?.trim()).filter(Boolean),
+ ),
+ ];
+ const dropdownOptions = (texts.length ? texts : ['']).map(text => newOption(text));
+ return writeDropdown(
+ doc,
+ newDropdown({
+ options: dropdownOptions,
+ // The answer leads the list, so it is the first option.
+ correctId: answer?.trim() ? dropdownOptions[0].id : null,
+ }),
+ );
+}
+
+/**
+ * @param {import('../../conversion').ConvertibleContent} content
+ * @returns {import('./parse').InlineChoiceState}
+ */
+export function importInlineChoiceContent({ prompt, passage }) {
+ const state = _defaultState();
+ if (!passage) {
+ return { ...state, prompt };
+ }
+ const body = parseHTMLBody(passage.html);
+ const answers = passage.blanks.map(({ answer }) => answer).filter(Boolean);
+ for (const placeholder of body.querySelectorAll(`[${BLANK_ATTR}]`)) {
+ const blank = passage.blanks[Number(placeholder.getAttribute(BLANK_ATTR))];
+ placeholder.replaceWith(
+ blank ? buildDropdown(body.ownerDocument, blank, answers, passage.distractors) : '',
+ );
+ }
+ return { prompt, passage: getContentHTML(body), shuffle: passage.shuffle ?? state.shuffle };
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/historyShortcut.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/historyShortcut.js
new file mode 100644
index 0000000000..635631ac19
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/historyShortcut.js
@@ -0,0 +1,27 @@
+// The resolver does not read package `exports` subpaths.
+// eslint-disable-next-line import/no-unresolved
+import { keydownHandler } from '@tiptap/pm/keymap';
+
+// The passage editor's own history shortcuts. `Mod` is Cmd on macOS and Ctrl elsewhere.
+const HISTORY_KEYS = { 'Mod-z': 'undo', 'Shift-Mod-z': 'redo', 'Mod-y': 'redo' };
+
+/**
+ * Which step of the passage's history a key press asks for, matched as the passage editor's
+ * keymap matches it, for controls that keep their keys from it: the chips and the options panel.
+ *
+ * @param {KeyboardEvent} event
+ * @returns {'undo'|'redo'|null}
+ */
+export function historyShortcut(event) {
+ let direction = null;
+ const bindings = {};
+ for (const [key, step] of Object.entries(HISTORY_KEYS)) {
+ bindings[key] = () => {
+ direction = step;
+ return true;
+ };
+ }
+ // The keymap hands its commands the view's state, which these do not use.
+ keydownHandler(bindings)({}, event);
+ return direction;
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js
index ec9c782735..2d0c092759 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js
@@ -10,6 +10,9 @@ export const DROPDOWN = 'qti-inline-choice-interaction';
export const OPTION = 'qti-inline-choice';
const PROMPT_MARKER = 'data-studio-prompt';
export const CORRECT_ATTR = 'data-studio-correct';
+const COPY_ATTR = 'data-studio-dropdown';
+/** A dropdown as `copiedDropdown` puts it on the clipboard. */
+export const COPIED_DROPDOWN = `${DROPDOWN}[${COPY_ATTR}]`;
const SENTINEL_ID = 'studio_sentinel';
const KEPT_DROPDOWN_ATTRS = ['response-identifier', CORRECT_ATTR];
@@ -60,6 +63,84 @@ export function readDropdown(el) {
};
}
+/**
+ * A new option, with a fresh identifier.
+ *
+ * @param {string} [text]
+ * @returns {{ id: string, text: string }}
+ */
+export const newOption = (text = '') => ({ id: generateRandomSlug('choice'), text });
+
+/**
+ * A new dropdown, with a fresh response identifier.
+ *
+ * @param {Partial} [fields]
+ * @returns {InlineChoiceDropdown}
+ */
+export const newDropdown = (fields = {}) => ({
+ responseIdentifier: generateRandomSlug('response'),
+ options: [newOption()],
+ correctId: null,
+ ...fields,
+});
+
+/**
+ * A dropdown as the passage holds it. The reverse of readDropdown.
+ *
+ * @param {Document} doc - The document of the passage it goes in
+ * @param {InlineChoiceDropdown} dropdown
+ * @returns {Element}
+ */
+export function writeDropdown(doc, { responseIdentifier, options, correctId }) {
+ const el = doc.createElement(DROPDOWN);
+ el.setAttribute('response-identifier', responseIdentifier);
+ if (correctId !== null) el.setAttribute(CORRECT_ATTR, correctId);
+ for (const { id, text } of options) {
+ const option = doc.createElement(OPTION);
+ option.setAttribute('identifier', id);
+ option.textContent = text;
+ el.appendChild(option);
+ }
+ return el;
+}
+
+/**
+ * A dropdown as it is copied, as a DOM output spec. An editor without dropdowns keeps only the
+ * text of an element it doesn't know, so the text is the correct answer alone, or nothing with no
+ * correct answer, and the dropdown itself is held in an attribute for a passage to read back.
+ *
+ * @param {InlineChoiceDropdown} dropdown
+ * @returns {Array}
+ */
+export function copiedDropdown({ responseIdentifier, options, correctId }) {
+ const correct = options.find(option => option.id === correctId);
+ const attrs = { [COPY_ATTR]: JSON.stringify({ responseIdentifier, options, correctId }) };
+ return [DROPDOWN, attrs, correct ? correct.text : ''];
+}
+
+/**
+ * @param {Element} el - A `COPIED_DROPDOWN`, from the clipboard
+ * @returns {InlineChoiceDropdown|false} false when it holds no dropdown with an option
+ */
+export function readCopiedDropdown(el) {
+ let copied;
+ try {
+ copied = JSON.parse(el.getAttribute(COPY_ATTR));
+ } catch {
+ return false;
+ }
+ const isText = value => typeof value === 'string';
+ const options = (Array.isArray(copied?.options) ? copied.options : [])
+ .filter(option => isText(option?.id) && isText(option?.text))
+ .map(({ id, text }) => ({ id, text }));
+ if (!options.length) return false;
+ return {
+ responseIdentifier: isText(copied.responseIdentifier) ? copied.responseIdentifier : '',
+ options,
+ correctId: options.some(option => option.id === copied.correctId) ? copied.correctId : null,
+ };
+}
+
/**
* @param {string[]} responseDeclarations
* @returns {Map} response identifier → declared correct choice id
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/passageChips.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/passageChips.js
index 2c53312e21..70c223c549 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/passageChips.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/passageChips.js
@@ -1,4 +1,7 @@
import { inject, provide } from 'vue';
+import { qtiEditorStrings } from '../../qtiEditorStrings';
+import { ValidationError } from '../../constants';
+import { dropdownErrors } from './validation';
const PassageChipsSymbol = Symbol('inlineChoicePassageChips');
@@ -9,3 +12,72 @@ export function providePassageChips(chips) {
export function injectPassageChips() {
return inject(PassageChipsSymbol);
}
+
+const problemMessages = {
+ [ValidationError.NO_CORRECT_ANSWER]: qtiEditorStrings.errorNoCorrectAnswer$,
+ [ValidationError.TOO_FEW_CHOICES]: qtiEditorStrings.errorTooFewInlineChoiceOptions$,
+};
+
+/**
+ * What a dropdown as a whole lacks, as its options panel lists it; its chip shows the first.
+ *
+ * @param {{ options: Array, correctId: string|null }} dropdown
+ * @returns {string[]}
+ */
+export function dropdownProblems(dropdown) {
+ return dropdownErrors(dropdown).map(code => problemMessages[code]());
+}
+
+/**
+ * What a chip shows and is called, shared by the chip and by what the passage announces about
+ * it, so a screen reader hears a chip named the same way wherever it meets it.
+ *
+ * @param {{ options: Array<{ id: string, text: string }>, correctId: string|null }} attrs - The
+ * chip node's attributes
+ * @param {object} [state]
+ * @param {boolean} [state.hasErrors] - Whether the dropdown has validation errors
+ * @param {boolean} [state.isConcealed] - Whether a preview hides the answers
+ * @returns {{ label: string, optionCount: number, hasAnswer: boolean, accessibleName: string }}
+ */
+export function describeChip(
+ { options, correctId },
+ { hasErrors = false, isConcealed = false } = {},
+) {
+ const {
+ answerDropdownWithCorrect$,
+ answerDropdownNoCorrect$,
+ answerDropdownWithCorrectNeedsAttention$,
+ answerDropdownNoCorrectNeedsAttention$,
+ answerDropdownHidden$,
+ answerDropdownHiddenNeedsAttention$,
+ chooseAnswer$,
+ errorEmptyChoiceContent$,
+ } = qtiEditorStrings;
+
+ const optionCount = options.filter(option => option.text.trim()).length;
+ const correct = options.find(option => option.id === correctId);
+ const correctText = correct ? correct.text.trim() : '';
+ const hasAnswer = correctText !== '';
+
+ if (isConcealed) {
+ // A preview that hides the answers must not read them out either, but still says which
+ // dropdowns need work, as their colour does.
+ const label = chooseAnswer$();
+ const name = hasErrors ? answerDropdownHiddenNeedsAttention$ : answerDropdownHidden$;
+ return { label, optionCount, hasAnswer, accessibleName: name({ label, count: optionCount }) };
+ }
+
+ // A chip shows its answer while it has one, its error icon saying whether it needs work.
+ // Without one, it says what its options panel says is missing.
+ const [problem] = dropdownProblems({ options, correctId });
+ let label = correctText;
+ if (!hasAnswer) label = problem ?? errorEmptyChoiceContent$();
+ // Starts with the visible label, so voice control finds the chip by what it shows.
+ let name;
+ if (hasAnswer) {
+ name = hasErrors ? answerDropdownWithCorrectNeedsAttention$ : answerDropdownWithCorrect$;
+ } else {
+ name = hasErrors ? answerDropdownNoCorrectNeedsAttention$ : answerDropdownNoCorrect$;
+ }
+ return { label, optionCount, hasAnswer, accessibleName: name({ label, count: optionCount }) };
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js
index 7117a308ce..5d97580c7d 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js
@@ -1,11 +1,16 @@
import { computed, readonly, ref, unref } from 'vue';
// The resolver does not read package `exports` subpaths.
-// eslint-disable-next-line import/no-unresolved
-import { AllSelection } from '@tiptap/pm/state';
+/* eslint-disable import/no-unresolved */
+import { redoNoScroll, undoNoScroll } from '@tiptap/pm/history';
+import { AllSelection, Plugin } from '@tiptap/pm/state';
+/* eslint-enable import/no-unresolved */
+import { findChildren } from '@tiptap/core';
+import useKLiveRegion from 'kolibri-design-system/lib/composables/useKLiveRegion';
import { qtiEditorStrings } from '../../qtiEditorStrings';
import { generateRandomSlug } from '../../utils/generateRandomSlug';
-import { InlineChoiceNode, findChip } from './InlineChoiceNode';
-import { providePassageChips } from './passageChips';
+import { InlineChoiceNode, findChip, isChip } from './InlineChoiceNode';
+import { describeChip, providePassageChips } from './passageChips';
+import { storedHTML } from 'shared/views/TipTapEditor/TipTapEditor/utils/imageSrc';
// Select-all selects the passage itself, where an inline node cannot go. Over a passage of one
// paragraph, it is taken as that paragraph's text.
@@ -16,20 +21,104 @@ function paragraphOfAllSelection({ doc, selection, schema }) {
return { from: 1, to: doc.content.size - 1 };
}
+/**
+ * The chip a caret move went straight over, or null. The caret passes a chip in one step, and
+ * a screen reader reads out the text the caret crosses, but there is none in a chip.
+ */
+function crossedChip(prevState, state) {
+ const before = prevState.selection;
+ const after = state.selection;
+ if (!before.empty || !after.empty || before.head === after.head) return null;
+ const from = Math.min(before.head, after.head);
+ const node = state.doc.nodeAt(from);
+ return node && isChip(node) && from + node.nodeSize === Math.max(before.head, after.head)
+ ? node
+ : null;
+}
+
+/** Whether two documents hold the same chips, in the same order, with the same options. */
+function sameChips(prevDoc, doc) {
+ const before = findChildren(prevDoc, isChip);
+ const after = findChildren(doc, isChip);
+ return before.length === after.length && before.every(({ node }, i) => node.eq(after[i].node));
+}
+
+/** The response identifiers of the chips in `doc` that `prevDoc` lacks or holds otherwise. */
+function changedChips(prevDoc, doc) {
+ const before = new Map(
+ findChildren(prevDoc, isChip).map(({ node }) => [node.attrs.responseIdentifier, node]),
+ );
+ return findChildren(doc, isChip)
+ .map(({ node }) => node)
+ .filter(node => !before.get(node.attrs.responseIdentifier)?.eq(node))
+ .map(node => node.attrs.responseIdentifier);
+}
+
+/** The chips in `prevDoc` that `doc` no longer holds; a moved chip keeps its identifier. */
+function removedChips(prevDoc, doc) {
+ const kept = new Set(findChildren(doc, isChip).map(({ node }) => node.attrs.responseIdentifier));
+ return findChildren(prevDoc, isChip)
+ .map(({ node }) => node)
+ .filter(node => !kept.has(node.attrs.responseIdentifier));
+}
+
+/**
+ * @typedef {object} InlineChoicePassageObject
+ * @property {import('@tiptap/core').AnyExtension[]} extensions - For the passage's
+ * `TipTapEditor` `extensions` prop: the inline choice node.
+ * @property {object[]} insertActions - For its `insertActions` prop: Insert, which adds a chip
+ * and opens it.
+ * @property {Readonly>} openResponseIdentifier - The response
+ * identifier of the chip whose options panel is open, or `null`.
+ * @property {(responseIdentifier: ?string) => void} openDropdown - Opens a chip's options
+ * panel, or closes it with `null`.
+ * @property {(responseIdentifier: string) => void} focusChip - Focuses a chip's button, or the
+ * editor when the chip has left the passage.
+ * @property {(responseIdentifier: string, change: { options?: Array<{ id: string, text: string }>,
+ * correctId?: ?string }) => boolean} updateDropdown - Applies an edit from the options panel
+ * as one step of the passage's history; `false` when there is no such chip, or the change
+ * would leave it invalid.
+ * @property {() => void} undo - Undoes a step of the passage's history, and opens the chip it
+ * changed when that is not the open one.
+ * @property {() => void} redo - Redoes a step of the passage's history, as `undo` does.
+ */
+
/**
* Everything a passage's `TipTapEditor` needs to hold inline choice dropdowns. Call from the
* setup of the component that renders the editor, so the chips can reach what it provides.
- * `openResponseIdentifier` names the chip whose options panel is open, or is `null`.
- * `errorResponseIdentifiers` (array or ref) names the dropdowns with validation errors.
+ *
+ * `onChange` receives the passage's HTML after every change to its chips, and while a dropdown
+ * is open after every change at all, which `TipTapEditor` itself only reports on blur. The
+ * options panel, outside the editor, edits the passage through `updateDropdown` and has to show
+ * each edit, and each undo, as it happens; and the question's validation has to follow chips
+ * removed or restored in the passage.
+ * @param {object} [options]
+ * @param {string[] | import('vue').Ref} [options.errorResponseIdentifiers] - The
+ * response identifiers of the dropdowns with validation errors.
+ * @param {boolean | import('vue').Ref} [options.showAnswers] - Whether a read-only
+ * passage shows each correct answer.
+ * @param {?string} [options.describedBy] - The id of the passage's helper text, which
+ * describes the editor.
+ * @param {(html: string) => void} [options.onChange] - Receives the passage's HTML, as above.
+ * @returns {InlineChoicePassageObject} What the passage's editor and options panel use.
*/
-export function useInlineChoicePassage({ errorResponseIdentifiers = [] } = {}) {
+export function useInlineChoicePassage({
+ errorResponseIdentifiers = [],
+ showAnswers = true,
+ describedBy = null,
+ onChange = () => {},
+} = {}) {
const openResponseIdentifier = ref(null);
// `setEditable` emits `update`; `editor.isEditable` itself is not reactive.
const isEditable = ref(false);
let editor = null;
+ const syncPassage = () => onChange(storedHTML(editor));
+
function openDropdown(responseIdentifier) {
openResponseIdentifier.value = responseIdentifier;
+ // The panel reads the dropdown from the synced passage, which may not hold it yet.
+ if (editor && responseIdentifier !== null) syncPassage();
}
function focusChip(responseIdentifier) {
@@ -39,16 +128,92 @@ export function useInlineChoicePassage({ errorResponseIdentifiers = [] } = {}) {
else editor.commands.focus();
}
+ /**
+ * One step of the passage's history, so undo restores what the panel showed before it.
+ *
+ * @param {string} responseIdentifier
+ * @param {{ options?: Array<{id: string, text: string}>, correctId?: string|null }} change
+ * @returns {boolean} false when there is no such chip, or the change would leave it invalid
+ */
+ function updateDropdown(responseIdentifier, change) {
+ return Boolean(editor?.commands.updateInlineChoice(responseIdentifier, change));
+ }
+
+ // A step taken from the options panel or a focused chip, outside the passage's focus.
+ let isSteppingFromOutside = false;
+
+ /**
+ * Steps through the passage's history, which holds the panel's edits too, for the panel and
+ * the chips, which keep their keys from the passage. A step that changes another chip than the
+ * open one opens that chip, where it can be seen. It does not scroll to the change, which
+ * would move the page away from the panel.
+ */
+ function stepHistory(direction) {
+ if (!editor) return;
+ const prevDoc = editor.state.doc;
+ isSteppingFromOutside = true;
+ try {
+ const step = direction === 'undo' ? undoNoScroll : redoNoScroll;
+ step(editor.state, editor.view.dispatch);
+ } finally {
+ isSteppingFromOutside = false;
+ }
+ const open = openResponseIdentifier.value;
+ const changed = changedChips(prevDoc, editor.state.doc);
+ if (open !== null && changed.length && !changed.includes(open)) openDropdown(changed[0]);
+ }
+
+ const isInError = responseIdentifier =>
+ unref(errorResponseIdentifiers).includes(responseIdentifier);
+
+ /** What a screen reader should hear about the chips a change crossed or removed, if any. */
+ function chipMessage(prevState, state) {
+ if (state.doc.eq(prevState.doc)) {
+ const crossed = crossedChip(prevState, state);
+ if (!crossed) return null;
+ const hasErrors = isInError(crossed.attrs.responseIdentifier);
+ return describeChip(crossed.attrs, { hasErrors }).accessibleName;
+ }
+ const removed = removedChips(prevState.doc, state.doc);
+ if (removed.length > 1)
+ return qtiEditorStrings.answerDropdownsRemoved$({ count: removed.length });
+ if (!removed.length) return null;
+ const { label } = describeChip(removed[0].attrs);
+ return qtiEditorStrings.answerDropdownRemoved$({ label });
+ }
+
+ // Assertive, so it is read as a crossed character would be, rather than after it.
+ const { sendAssertiveMessage } = useKLiveRegion();
+ const announcer = new Plugin({
+ view: () => ({
+ update(view, prevState) {
+ // Only what the author does in the passage, not what loading or another field does to it.
+ if (!view.editable || !(view.hasFocus() || isSteppingFromOutside)) return;
+ const message = chipMessage(prevState, view.state);
+ if (message) sendAssertiveMessage(message);
+ },
+ }),
+ });
+
+ const description = new Plugin({
+ props: { attributes: describedBy ? { 'aria-describedby': describedBy } : {} },
+ });
+
const readonlyOpenResponseIdentifier = readonly(openResponseIdentifier);
providePassageChips({
openResponseIdentifier: readonlyOpenResponseIdentifier,
openDropdown,
focusChip,
+ stepHistory,
isEditable: readonly(isEditable),
errorResponseIdentifiers: computed(() => unref(errorResponseIdentifiers)),
+ showAnswers: computed(() => unref(showAnswers)),
});
const node = InlineChoiceNode.extend({
+ addProseMirrorPlugins() {
+ return [...this.parent(), announcer, description];
+ },
onCreate() {
editor = this.editor;
isEditable.value = editor.isEditable;
@@ -58,16 +223,21 @@ export function useInlineChoicePassage({ errorResponseIdentifiers = [] } = {}) {
},
onUpdate() {
isEditable.value = this.editor.isEditable;
+ },
+ onTransaction({ transaction }) {
+ if (!transaction.docChanged) return;
+ const isOpen = openResponseIdentifier.value !== null;
+ if (!isOpen && sameChips(transaction.before, transaction.doc)) return;
+ syncPassage();
// Undo, delete or cut can take the open chip out of the passage.
- if (openResponseIdentifier.value === null) return;
- if (!findChip(this.editor.state.doc, openResponseIdentifier.value)) openDropdown(null);
+ if (isOpen && !findChip(transaction.doc, openResponseIdentifier.value)) openDropdown(null);
},
});
const insertAction = {
name: InlineChoiceNode.name,
title: qtiEditorStrings.insertInlineChoice$(),
- icon: 'add',
+ icon: 'insert',
prominent: true,
// Before focus, the chip goes at the end of the passage, which always takes one.
// Replacing a multi-line selection with an inline node would join the lines.
@@ -91,5 +261,8 @@ export function useInlineChoicePassage({ errorResponseIdentifiers = [] } = {}) {
openResponseIdentifier: readonlyOpenResponseIdentifier,
openDropdown,
focusChip,
+ updateDropdown,
+ undo: () => stepHistory('undo'),
+ redo: () => stepHistory('redo'),
};
}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/validation.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/validation.js
index 225fe35df7..ebc0a6811a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/validation.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/validation.js
@@ -1,6 +1,20 @@
import { ValidationError } from '../../constants';
import { getDropdowns } from './parse';
+/**
+ * What a dropdown as a whole lacks, rather than one of its options, in the order its options
+ * panel lists them; its chip shows the first.
+ *
+ * @param {{ options: Array, correctId: string|null }} dropdown
+ * @returns {string[]} ValidationError codes
+ */
+export function dropdownErrors({ options, correctId }) {
+ const codes = [];
+ if (correctId === null) codes.push(ValidationError.NO_CORRECT_ANSWER);
+ if (options.length < 2) codes.push(ValidationError.TOO_FEW_CHOICES);
+ return codes;
+}
+
/**
* Validate InlineChoiceState → ValidationError[]. Option errors carry the choice id,
* dropdown errors the response identifier.
@@ -15,7 +29,8 @@ export function validateInlineChoiceInteraction(state) {
}
const errors = [];
- for (const { responseIdentifier, options, correctId } of dropdowns) {
+ for (const dropdown of dropdowns) {
+ const { responseIdentifier, options } = dropdown;
const firstSeenId = new Map();
const duplicateIds = new Set();
@@ -37,8 +52,8 @@ export function validateInlineChoiceInteraction(state) {
errors.push({ code: ValidationError.DUPLICATE_CHOICE_CONTENT, id });
}
- if (correctId === null) {
- errors.push({ code: ValidationError.NO_CORRECT_ANSWER, id: responseIdentifier });
+ for (const code of dropdownErrors(dropdown)) {
+ errors.push({ code, id: responseIdentifier });
}
}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js
index 2318b037f0..9b2b7b638a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js
@@ -1,6 +1,7 @@
import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants';
import { InteractionDescriptor } from '../InteractionDescriptor';
import { parseMatchInteraction, buildMatchInteractionXML, hasTwoMatchSets } from './parse';
+import { exportMatchContent, getMatchConversionFormat, importMatchContent } from './convert';
import { validateMatchInteraction } from './validation';
/**
@@ -12,7 +13,6 @@ export class MatchInteractionDescriptor extends InteractionDescriptor {
type: QtiInteraction.MATCH,
questionTypes: [QuestionType.MATCH],
});
- this.convertsFrom = [];
}
getTypeOptions(tr) {
@@ -80,6 +80,29 @@ export class MatchInteractionDescriptor extends InteractionDescriptor {
validate(state) {
return validateMatchInteraction(state);
}
+
+ /**
+ * @returns {import('../../conversion').ConversionFormat}
+ */
+ getConversionFormat() {
+ return getMatchConversionFormat();
+ }
+
+ /**
+ * @param {object} state - MatchState
+ * @returns {import('../../conversion').ConvertibleContent}
+ */
+ exportContent(state) {
+ return exportMatchContent(state);
+ }
+
+ /**
+ * @param {import('../../conversion').ConvertibleContent} content
+ * @returns {object} MatchState
+ */
+ importContent(content) {
+ return importMatchContent(content);
+ }
}
/** Singleton — safe to import from any file in the match module tree. */
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue
index d663179866..5cf2a5f36d 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue
@@ -539,10 +539,7 @@
if (newMode !== 'edit') {
openTarget.value = null;
releaseErrors();
- return;
}
- // Open the question, the first thing in the card, so the card opens at its start.
- openPrompt();
},
{ immediate: true },
);
@@ -678,6 +675,9 @@
]);
return {
+ // Public: opens the question for editing, which focuses it.
+ // eslint-disable-next-line vue/no-unused-properties
+ focus: openPrompt,
EditorImageProcessor,
OpenTarget,
windowIsSmall,
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/Editor.spec.js
index 423d9b3d7d..c1cfd4c3eb 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/Editor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/Editor.spec.js
@@ -1,9 +1,10 @@
-import { fireEvent, render, screen, within } from '@testing-library/vue';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { nextTick } from 'vue';
import VueRouter from 'vue-router';
import { themePalette, themeTokens } from 'kolibri-design-system/lib/styles/theme';
import MatchEditor from '../Editor.vue';
+import { renderFocused } from '../../__tests__/renderFocused';
import {
MATCH_XML,
@@ -114,8 +115,9 @@ const rowAnswers = number =>
const answerItems = number => within(rowAnswers(number)).queryAllByRole('listitem');
const distractorList = () => screen.getByRole('list', { name: tr.$tr('distractorsLabel') });
-const renderEditor = (props = {}) =>
- render(MatchEditor, {
+// `focused` renders it as its card does when the card opens.
+const renderEditor = (props = {}, { focused = false } = {}) =>
+ (focused ? renderFocused : render)(MatchEditor, {
props: {
mode: 'edit',
questionType: QuestionType.MATCH,
@@ -164,10 +166,19 @@ describe('MatchEditor', () => {
expect(screen.queryByText(tr.$tr('pairNumberLabel', { number: 1 }))).not.toBeInTheDocument();
});
- it('opens the question prompt for editing when it is already written', () => {
- renderEditor();
+ it('opens the question prompt for editing, and focuses it, when its card opens', async () => {
+ renderEditor({}, { focused: true });
+ await nextTick();
expect(queryButton(tr.$tr('editQuestionLabel'))).not.toBeInTheDocument();
expect(button(editRow(1))).toBeInTheDocument();
+ await waitFor(() => expect(openTextarea()).toHaveFocus());
+ });
+
+ it('leaves the question prompt closed, and focus where it was, when it only mounts', async () => {
+ renderEditor();
+ await nextTick();
+ expect(button(tr.$tr('editQuestionLabel'))).toBeInTheDocument();
+ expect(document.body).toHaveFocus();
});
it('shows a placeholder in a blank row prompt until it opens', async () => {
@@ -179,8 +190,9 @@ describe('MatchEditor', () => {
expect(within(rows()[0]).queryByText(placeholder)).not.toBeInTheDocument();
});
- it('opens the question prompt for editing when it is blank', () => {
- renderEditor({ interaction: block('') });
+ it('opens the question prompt for editing when its card opens and it is blank', async () => {
+ renderEditor({ interaction: block('') }, { focused: true });
+ await nextTick();
expect(queryButton(tr.$tr('editQuestionLabel'))).not.toBeInTheDocument();
expect(button(editRow(1))).toBeInTheDocument();
});
@@ -205,8 +217,8 @@ describe('MatchEditor', () => {
it('writes the typed question prompt into the emitted XML', async () => {
const user = userEvent.setup();
- // The question prompt opens on mount.
const { emitted } = renderEditor();
+ await user.click(button(tr.$tr('editQuestionLabel')));
await user.type(openTextarea(), 'Which class?');
expect(latestBodyXml(emitted)).toContain('Which class?');
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/convert.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/convert.spec.js
new file mode 100644
index 0000000000..fd0eb2a316
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/convert.spec.js
@@ -0,0 +1,94 @@
+import { exportMatchContent, importMatchContent } from '../convert';
+
+describe('match conversion', () => {
+ it('exports one pair per match, and a row with no match yet as a pair with nothing', () => {
+ const state = {
+ prompt: 'Q
',
+ rows: [
+ {
+ id: 'row_a',
+ content: 'Bat
',
+ matches: [
+ { id: 'choice_m', content: 'Mammal
' },
+ { id: 'choice_f', content: 'Flyer
' },
+ ],
+ },
+ { id: 'row_b', content: 'Frog
', matches: [] },
+ ],
+ distractors: [{ id: 'choice_r', content: 'Reptile
' }],
+ };
+ expect(exportMatchContent(state)).toEqual({
+ prompt: 'Q
',
+ pairs: {
+ items: [
+ { source: 'Bat
', target: 'Mammal
' },
+ { source: 'Bat
', target: 'Flyer
' },
+ { source: 'Frog
', target: '' },
+ ],
+ distractors: ['Reptile
'],
+ },
+ });
+ });
+
+ it('imports each pair as a row of its own, with its target as the match', () => {
+ const state = importMatchContent({
+ prompt: 'Q
',
+ pairs: { items: [{ source: 'A
', target: 'B
' }], distractors: ['C
'] },
+ });
+ expect(state.rows).toEqual([
+ {
+ id: expect.stringMatching(/^row_/),
+ content: 'A
',
+ matches: [{ id: expect.stringMatching(/^choice_/), content: 'B
' }],
+ },
+ ]);
+ expect(state.distractors).toEqual([
+ { id: expect.stringMatching(/^choice_/), content: 'C
' },
+ ]);
+ });
+
+ it('gathers pairs from the same source into one row, matched to each target', () => {
+ const state = importMatchContent({
+ prompt: '',
+ pairs: {
+ items: [
+ { source: 'Bat
', target: 'Mammal
' },
+ { source: 'Dog
', target: 'Mammal
' },
+ { source: 'Bat
', target: 'Flyer
' },
+ { source: 'Bat
', target: 'Flyer
' },
+ ],
+ distractors: [],
+ },
+ });
+ expect(
+ state.rows.map(({ content, matches }) => [content, matches.map(m => m.content)]),
+ ).toEqual([
+ ['Bat
', ['Mammal
', 'Flyer
']],
+ ['Dog
', ['Mammal
']],
+ ]);
+ });
+
+ it('keeps rows with no source apart, each with a match to fill in', () => {
+ const state = importMatchContent({
+ prompt: '',
+ pairs: {
+ items: [
+ { source: '', target: '' },
+ { source: '', target: 'B
' },
+ ],
+ distractors: [],
+ },
+ });
+ expect(state.rows.map(({ matches }) => matches.map(m => m.content))).toEqual([
+ [''],
+ ['B
'],
+ ]);
+ });
+
+ it('starts with one blank row when there are no pairs', () => {
+ const state = importMatchContent({ prompt: '' });
+ expect(state.rows).toHaveLength(1);
+ expect(state.rows[0].content).toBe('');
+ expect(state.distractors).toEqual([]);
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/convert.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/convert.js
new file mode 100644
index 0000000000..a48b68b59e
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/convert.js
@@ -0,0 +1,78 @@
+import flatMap from 'lodash/flatMap';
+import { hasRichTextContent } from '../../utils/richText';
+import { newChoice } from '../associate/parse';
+import { _defaultState, newRow } from './parse';
+import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey';
+
+/**
+ * @returns {import('../../conversion').ConversionFormat}
+ */
+export function getMatchConversionFormat() {
+ return { pairs: true };
+}
+
+/**
+ * Each row gives one pair per match. A row with no match yet still gives one, with nothing
+ * to pair it with, so the row itself isn't lost.
+ *
+ * @param {import('./parse').MatchState} state
+ * @returns {import('../../conversion').ConvertibleContent}
+ */
+export function exportMatchContent(state) {
+ return {
+ prompt: state.prompt,
+ pairs: {
+ items: flatMap(state.rows, ({ content, matches }) =>
+ matches.length
+ ? matches.map(match => ({ source: content, target: match.content }))
+ : [{ source: content, target: '' }],
+ ),
+ distractors: state.distractors.map(({ content }) => content),
+ },
+ };
+}
+
+/**
+ * Pairs from the same source become one row, matched to each of their targets, so a row that
+ * went to associate as several pairs comes back as the row it was. A blank source is a row of
+ * its own, as two blank rows are two rows the author has yet to fill in.
+ *
+ * @param {Array<{ source: string, target: string }>} items
+ * @returns {import('./parse').MatchRow[]}
+ */
+function rowsFromPairs(items) {
+ const rows = [];
+ const rowByKey = new Map();
+ for (const { source, target } of items) {
+ const key = hasRichTextContent(source) ? richTextComparisonKey(source) : null;
+ let row = key === null ? null : rowByKey.get(key);
+ if (!row) {
+ row = newRow({ content: source, matches: [] });
+ rows.push(row);
+ if (key !== null) rowByKey.set(key, row);
+ }
+ const targetKey = richTextComparisonKey(target);
+ const repeated = row.matches.some(match => richTextComparisonKey(match.content) === targetKey);
+ if (hasRichTextContent(target) && !repeated) row.matches.push(newChoice(target));
+ }
+ // A row has a match to fill in, even before it has an answer.
+ for (const row of rows) {
+ if (!row.matches.length) row.matches.push(newChoice());
+ }
+ return rows;
+}
+
+/**
+ * @param {import('../../conversion').ConvertibleContent} content
+ * @returns {import('./parse').MatchState}
+ */
+export function importMatchContent({ prompt, pairs }) {
+ const state = _defaultState();
+ const items = pairs?.items ?? [];
+ return {
+ ...state,
+ prompt,
+ rows: items.length ? rowsFromPairs(items) : state.rows,
+ distractors: (pairs?.distractors ?? []).map(content => newChoice(content)),
+ };
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js
index c498bc8e4a..edd824a410 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js
@@ -31,12 +31,16 @@ const serializer = new XMLSerializer();
*/
/**
+ * A new row, with a fresh identifier and one match to fill in.
+ *
+ * @param {Partial} [fields]
* @returns {MatchRow}
*/
-export const newRow = () => ({
+export const newRow = (fields = {}) => ({
id: generateRandomSlug('row'),
content: '',
matches: [newChoice()],
+ ...fields,
});
/**
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js
index 58bce294de..f16445c7ba 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js
@@ -1,6 +1,11 @@
import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants';
import { InteractionDescriptor } from '../InteractionDescriptor';
import { parseOrderingInteraction, buildOrderingInteractionXML } from './parse';
+import {
+ exportOrderingContent,
+ getOrderingConversionFormat,
+ importOrderingContent,
+} from './convert';
import { validateOrderingInteraction } from './validation';
/**
@@ -12,7 +17,6 @@ export class OrderingInteractionDescriptor extends InteractionDescriptor {
type: QtiInteraction.ORDER,
questionTypes: [QuestionType.ORDERING],
});
- this.convertsFrom = [];
}
getTypeOptions(tr) {
@@ -75,6 +79,29 @@ export class OrderingInteractionDescriptor extends InteractionDescriptor {
validate(state) {
return validateOrderingInteraction(state);
}
+
+ /**
+ * @returns {import('../../conversion').ConversionFormat}
+ */
+ getConversionFormat() {
+ return getOrderingConversionFormat();
+ }
+
+ /**
+ * @param {object} state - OrderingState
+ * @returns {import('../../conversion').ConvertibleContent}
+ */
+ exportContent(state) {
+ return exportOrderingContent(state);
+ }
+
+ /**
+ * @param {import('../../conversion').ConvertibleContent} content
+ * @returns {object} OrderingState
+ */
+ importContent(content) {
+ return importOrderingContent(content);
+ }
}
/** Singleton — safe to import from any file in the ordering module tree. */
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue
index 90c0092687..1e3a18e4d7 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue
@@ -303,8 +303,6 @@
() => props.mode,
newMode => {
if (newMode === 'edit') {
- // Open the question, the first thing in the card, so the card opens at its start.
- openPrompt();
emit('update:interaction', workingInteraction.value);
} else {
isPromptOpen.value = false;
@@ -402,6 +400,9 @@
}
return {
+ // Public: opens the question for editing, which focuses it.
+ // eslint-disable-next-line vue/no-unused-properties
+ focus: openPrompt,
EditorImageProcessor,
state,
isOnlyItem,
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/Editor.spec.js
index 353aaf62e3..04c52b17a9 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/Editor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/Editor.spec.js
@@ -1,9 +1,10 @@
-import { render, screen, fireEvent, within } from '@testing-library/vue';
+import { render, screen, fireEvent, within, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { nextTick } from 'vue';
import VueRouter from 'vue-router';
import useKLiveRegion from 'kolibri-design-system/lib/composables/useKLiveRegion';
import OrderingEditor from '../Editor.vue';
+import { renderFocused } from '../../__tests__/renderFocused';
import {
ORDERING_XML,
@@ -43,8 +44,9 @@ const itemLabel = number => tr.$tr('orderingItemLabel', { number });
const moveUpName = number => dragTr.$tr('moveItemUpLabel', { item: itemLabel(number) });
const moveDownName = number => dragTr.$tr('moveItemDownLabel', { item: itemLabel(number) });
-const renderEditor = (props = {}) =>
- render(OrderingEditor, {
+// `focused` renders it as its card does when the card opens.
+const renderEditor = (props = {}, { focused = false } = {}) =>
+ (focused ? renderFocused : render)(OrderingEditor, {
props: { mode: 'edit', ...props },
routes: new VueRouter(),
});
@@ -67,15 +69,30 @@ const openTextarea = () => screen.queryAllByRole('textbox').find(el => el.tagNam
describe('OrderingEditor', () => {
describe('edit mode rendering', () => {
- it('opens the question for editing when it is already written', () => {
- renderEditor({
- interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
- questionType: QuestionType.ORDERING,
- });
+ it('opens the question for editing, and focuses it, when its card opens', async () => {
+ renderEditor(
+ {
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ },
+ { focused: true },
+ );
+ await nextTick();
expect(
screen.queryByRole('button', { name: tr.$tr('editQuestionLabel') }),
).not.toBeInTheDocument();
expect(openTextarea().value).toContain('Arrange the planets');
+ await waitFor(() => expect(openTextarea()).toHaveFocus());
+ });
+
+ it('leaves the question closed, and focus where it was, when it only mounts', async () => {
+ renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ });
+ await nextTick();
+ expect(screen.getByRole('button', { name: tr.$tr('editQuestionLabel') })).toBeInTheDocument();
+ expect(document.body).toHaveFocus();
});
it('renders the prompt text from the XML', () => {
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/convert.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/convert.spec.js
new file mode 100644
index 0000000000..438feb5d51
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/convert.spec.js
@@ -0,0 +1,55 @@
+import { CorrectCriterion, ContentLevel } from '../../../constants';
+import {
+ exportOrderingContent,
+ getOrderingConversionFormat,
+ importOrderingContent,
+} from '../convert';
+
+describe('ordering conversion', () => {
+ it('takes rich ordered answers', () => {
+ expect(getOrderingConversionFormat()).toEqual({
+ answers: { correctCriterion: CorrectCriterion.ORDERED, contentLevel: ContentLevel.RICH },
+ });
+ });
+
+ it('exports the items in their correct order and the layout, but not the shuffle setting', () => {
+ const state = {
+ prompt: 'Q
',
+ items: [
+ { id: 'order_a', content: '1
', fixed: true },
+ { id: 'order_b', content: '2
' },
+ ],
+ shuffle: false,
+ orientation: 'horizontal',
+ };
+ expect(exportOrderingContent(state)).toEqual({
+ prompt: 'Q
',
+ answers: {
+ correctCriterion: CorrectCriterion.ORDERED,
+ contentLevel: ContentLevel.RICH,
+ items: [
+ { content: '1
', correct: false, fixed: true },
+ { content: '2
', correct: false, fixed: false },
+ ],
+ orientation: 'horizontal',
+ },
+ });
+ });
+
+ it('imports each answer as a new item, and stays shuffled', () => {
+ const state = importOrderingContent({
+ prompt: 'Q
',
+ answers: { items: [{ content: '1
', correct: true }], shuffle: false },
+ });
+ expect(state.items).toEqual([
+ { id: expect.stringMatching(/^order_/), content: '1
', fixed: false },
+ ]);
+ expect(state.shuffle).toBe(true);
+ });
+
+ it('starts with one blank item when there are no answers', () => {
+ expect(importOrderingContent({ prompt: '' }).items).toEqual([
+ { id: expect.stringMatching(/^order_/), content: '' },
+ ]);
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/convert.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/convert.js
new file mode 100644
index 0000000000..5f07c43c06
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/convert.js
@@ -0,0 +1,53 @@
+import { CorrectCriterion, ContentLevel } from '../../constants';
+import { _defaultState, newItem } from './parse';
+
+/**
+ * Ordering's shuffle is neither given nor taken: authors can't change it, and an ordering
+ * question that isn't shuffled shows its items in the correct order.
+ */
+
+/**
+ * @returns {import('../../conversion').ConversionFormat}
+ */
+export function getOrderingConversionFormat() {
+ return {
+ answers: { correctCriterion: CorrectCriterion.ORDERED, contentLevel: ContentLevel.RICH },
+ };
+}
+
+/**
+ * @param {import('./parse').OrderingState} state
+ * @returns {import('../../conversion').ConvertibleContent}
+ */
+export function exportOrderingContent(state) {
+ return {
+ prompt: state.prompt,
+ answers: {
+ correctCriterion: CorrectCriterion.ORDERED,
+ contentLevel: ContentLevel.RICH,
+ items: state.items.map(({ content, fixed }) => ({
+ content,
+ correct: false,
+ fixed: Boolean(fixed),
+ })),
+ orientation: state.orientation,
+ },
+ };
+}
+
+/**
+ * @param {import('../../conversion').ConvertibleContent} content
+ * @returns {import('./parse').OrderingState}
+ */
+export function importOrderingContent({ prompt, answers }) {
+ const state = _defaultState();
+ const items = answers?.items ?? [];
+ return {
+ ...state,
+ prompt,
+ items: items.length
+ ? items.map(({ content, fixed }) => newItem({ content, fixed: Boolean(fixed) }))
+ : state.items,
+ orientation: answers?.orientation ?? state.orientation,
+ };
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js
index d3b9203e97..ac3470cbba 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js
@@ -24,11 +24,23 @@ import { Orientation, RESPONSE_IDENTIFIER } from '../../constants';
const serializer = new XMLSerializer();
+/**
+ * A new item, with a fresh identifier.
+ *
+ * @param {Partial} [fields]
+ * @returns {OrderingItem}
+ */
+export const newItem = (fields = {}) => ({
+ id: generateRandomSlug('order'),
+ content: '',
+ ...fields,
+});
+
export function _defaultState() {
return {
responseIdentifier: RESPONSE_IDENTIFIER,
prompt: '',
- items: [{ id: generateRandomSlug('order'), content: '' }],
+ items: [newItem()],
orientation: Orientation.VERTICAL,
shuffle: true,
};
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js
index 1529e211e8..11d7c183b0 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js
@@ -6,6 +6,11 @@ import {
buildTextEntryInteractionXML,
isSupportedTextEntryBody,
} from './parse';
+import {
+ exportTextEntryContent,
+ getTextEntryConversionFormat,
+ importTextEntryContent,
+} from './convert';
import { validateTextEntryInteraction } from './validation';
/**
@@ -21,7 +26,6 @@ class TextEntryInteractionDescriptor extends InteractionDescriptor {
questionTypes: [QuestionType.NUMERIC, QuestionType.TEXT_ENTRY, QuestionType.FREE_RESPONSE],
placement: Placement.INLINE,
});
- this.convertsFrom = [];
}
getTypeOptions(tr) {
@@ -127,6 +131,33 @@ class TextEntryInteractionDescriptor extends InteractionDescriptor {
validate(state, questionType, options) {
return validateTextEntryInteraction(state, questionType, options);
}
+
+ /**
+ * @param {string} questionType
+ * @returns {import('../../conversion').ConversionFormat}
+ */
+ getConversionFormat(questionType) {
+ return getTextEntryConversionFormat(questionType);
+ }
+
+ /**
+ * @param {object} state - TextEntryState
+ * @param {string} questionType
+ * @param {{ language?: string }} [options]
+ * @returns {import('../../conversion').ConvertibleContent}
+ */
+ exportContent(state, questionType, options) {
+ return exportTextEntryContent(state, questionType, options);
+ }
+
+ /**
+ * @param {import('../../conversion').ConvertibleContent} content
+ * @param {string} questionType
+ * @returns {object} TextEntryState
+ */
+ importContent(content, questionType) {
+ return importTextEntryContent(content, questionType);
+ }
}
/** Singleton — safe to import from any file in the textEntry module tree. */
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue
index 457909ab11..7ba2b8d984 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue
@@ -365,8 +365,6 @@
isPromptOpen.value = false;
return;
}
- // Open the question, the first thing in the card, so the card opens at its start.
- openPrompt();
showStoredAnswerValue();
// Opening a card does not remount it, so report answers stored in another form here.
if (
@@ -385,6 +383,9 @@
watch(errors, newVal => emit('update:errors', newVal), { immediate: true });
return {
+ // Public: opens the question for editing, which focuses it.
+ // eslint-disable-next-line vue/no-unused-properties
+ focus: openPrompt,
state,
windowIsSmall,
isNumeric,
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js
index 103ddfe9fe..4a7be88c97 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js
@@ -1,7 +1,8 @@
-import { render, screen, fireEvent } from '@testing-library/vue';
+import { render, screen, fireEvent, waitFor } from '@testing-library/vue';
import { nextTick } from 'vue';
import VueRouter from 'vue-router';
import TextEntryEditor from '../Editor.vue';
+import { renderFocused } from '../../__tests__/renderFocused';
import {
TEXT_ENTRY_BODY_XML,
@@ -23,8 +24,9 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
};
});
-const renderEditor = (props = {}) =>
- render(TextEntryEditor, {
+// `focused` renders it as its card does when the card opens.
+const renderEditor = (props = {}, { focused = false } = {}) =>
+ (focused ? renderFocused : render)(TextEntryEditor, {
props: { mode: 'edit', ...props },
routes: new VueRouter(),
});
@@ -36,15 +38,30 @@ const answerInputs = () =>
screen.queryAllByRole('textbox', { name: tr.$tr('answerValuePlaceholder') });
describe('TextEntryEditor — numeric', () => {
- it('opens the question for editing when it is already written', () => {
- renderEditor({
- interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL),
- questionType: QuestionType.NUMERIC,
- });
+ it('opens the question for editing, and focuses it, when its card opens', async () => {
+ renderEditor(
+ {
+ interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL),
+ questionType: QuestionType.NUMERIC,
+ },
+ { focused: true },
+ );
+ await nextTick();
expect(
screen.queryByRole('button', { name: tr.$tr('editQuestionLabel') }),
).not.toBeInTheDocument();
expect(openTextarea().value).toContain('What is H2O?');
+ await waitFor(() => expect(openTextarea()).toHaveFocus());
+ });
+
+ it('leaves the question closed, and focus where it was, when it only mounts', async () => {
+ renderEditor({
+ interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL),
+ questionType: QuestionType.NUMERIC,
+ });
+ await nextTick();
+ expect(screen.getByRole('button', { name: tr.$tr('editQuestionLabel') })).toBeInTheDocument();
+ expect(document.body).toHaveFocus();
});
describe('answer list', () => {
@@ -424,51 +441,6 @@ describe('TextEntryEditor — emits', () => {
},
);
- it.each([
- ['fr', '1234.5', '1234.5'],
- ['en', '1.50', '1.5'],
- ['en', '0012', '12'],
- ['ar-EG', '30', '30'],
- ['ar-EG', '-3.14', '-3.14'],
- ['fr', '1.2.3', '1.2.3'],
- ['he', '-0.5', '-0.5'],
- ['sv', '-0.5', '-0.5'],
- ['ar-EG', '-0.5', '-0.5'],
- ])(
- 'keeps a stored numeric answer as stored when switched to text entry in %s (%s)',
- async (language, stored, expected) => {
- const { emitted, updateProps } = renderEditor({
- interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL.replace('42', stored)),
- questionType: QuestionType.NUMERIC,
- language,
- });
- await updateProps({ questionType: QuestionType.TEXT_ENTRY });
-
- const [latest] = emitted()['update:interaction'].at(-1);
- expect(latest.responseDeclarations[0]).toContain(`${expected}`);
- },
- );
-
- it.each([
- ['fr', '1,5', '1,5', '1.5'],
- ['fr', '1 234,5', '1234,5', '1234.5'],
- ])(
- 'reads a text entry answer in %s (%s) when switched to numeric',
- async (language, text, shown, stored) => {
- const { emitted, updateProps } = renderEditor({
- interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, STRING_DECL.replace('H2O', text)),
- questionType: QuestionType.TEXT_ENTRY,
- language,
- });
- await updateProps({ questionType: QuestionType.NUMERIC });
-
- expect(answerInputs()[0]).toHaveValue(shown);
- expect(screen.queryByRole('alert')).not.toBeInTheDocument();
- const [latest] = emitted()['update:interaction'].at(-1);
- expect(latest.responseDeclarations[0]).toContain(`${stored}`);
- },
- );
-
it('leaves a text entry answer as stored in any language', () => {
renderEditor({
interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, STRING_DECL.replace('H2O', '1.5')),
@@ -480,53 +452,6 @@ describe('TextEntryEditor — emits', () => {
);
});
- it.each([
- ['de', '1.5', '1,5'],
- ['es', '1234.5', '1234,5'],
- ['fr', '1234.5', '1234,5'],
- ['ar-EG', '30', '٣٠'],
- ])(
- 'shows a stored answer in %s again after switching to text entry and back (%s)',
- async (language, stored, shown) => {
- const { emitted, updateProps } = renderEditor({
- interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL.replace('42', stored)),
- questionType: QuestionType.NUMERIC,
- language,
- });
- await updateProps({ questionType: QuestionType.TEXT_ENTRY });
- await updateProps({ questionType: QuestionType.NUMERIC });
-
- expect(answerInputs()[0]).toHaveValue(shown);
- expect(screen.queryByRole('alert')).not.toBeInTheDocument();
- const [latest] = emitted()['update:interaction'].at(-1);
- expect(latest.responseDeclarations[0]).toContain(`${stored}`);
- },
- );
-
- it.each([
- ['fr', '1,5', '1.5'],
- ['en', '1,234', '1234'],
- ['de', '1.500', '1500'],
- ['', ' 5 ', '5'],
- ['fa', 'می\u200cخواهم'],
- ['en', 'ab'],
- ])(
- 'keeps a typed numeric answer as stored when switched to text entry in "%s" (%s)',
- async (language, typed, expected = typed) => {
- const { emitted, updateProps } = renderEditor({
- interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL),
- questionType: QuestionType.NUMERIC,
- language,
- });
- const [input] = answerInputs();
- await fireEvent.input(input, { target: { value: typed } });
- await updateProps({ questionType: QuestionType.TEXT_ENTRY });
-
- const [latest] = emitted()['update:interaction'].at(-1);
- expect(latest.responseDeclarations[0]).toContain(`${expected}`);
- },
- );
-
it.each([
['', 'fr', '1,5', '1,5', '1.5'],
['fr', 'en', '1,234.5', '1234.5', '1234.5'],
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/convert.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/convert.spec.js
new file mode 100644
index 0000000000..98cbfa62ba
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/convert.spec.js
@@ -0,0 +1,112 @@
+import { CorrectCriterion, ContentLevel, QuestionType } from '../../../constants';
+import {
+ exportTextEntryContent,
+ getTextEntryConversionFormat,
+ importTextEntryContent,
+} from '../convert';
+import { DEFAULT_EXPECTED_LENGTH } from '../parse';
+
+const state = {
+ prompt: 'Q
',
+ answers: [{ id: 'answer_a', value: 'Paris', caseSensitive: true }],
+ expectedLength: 80,
+};
+
+describe('text entry conversion', () => {
+ it.each([QuestionType.NUMERIC, QuestionType.TEXT_ENTRY])(
+ 'takes plain accepted answers for %s',
+ questionType => {
+ expect(getTextEntryConversionFormat(questionType)).toEqual({
+ answers: { correctCriterion: CorrectCriterion.ACCEPTED, contentLevel: ContentLevel.PLAIN },
+ });
+ },
+ );
+
+ it('takes no answers for a free response', () => {
+ expect(getTextEntryConversionFormat(QuestionType.FREE_RESPONSE)).toEqual({});
+ });
+
+ it('exports each answer as accepted, with its case sensitivity, and the expected length', () => {
+ expect(exportTextEntryContent(state, QuestionType.TEXT_ENTRY)).toEqual({
+ prompt: 'Q
',
+ answers: {
+ correctCriterion: CorrectCriterion.ACCEPTED,
+ contentLevel: ContentLevel.PLAIN,
+ items: [{ content: 'Paris', correct: true, caseSensitive: true }],
+ expectedLength: 80,
+ },
+ });
+ });
+
+ it('exports no answers from a free response, which saves none, but its expected length', () => {
+ expect(exportTextEntryContent(state, QuestionType.FREE_RESPONSE).answers).toEqual({
+ correctCriterion: CorrectCriterion.ACCEPTED,
+ contentLevel: ContentLevel.PLAIN,
+ items: [],
+ expectedLength: 80,
+ });
+ });
+
+ describe('numeric answers', () => {
+ const numeric = values => ({
+ prompt: '',
+ answers: values.map((value, i) => ({ id: `answer_${i}`, value, caseSensitive: false })),
+ expectedLength: 80,
+ });
+ const exported = (values, options) =>
+ exportTextEntryContent(numeric(values), QuestionType.NUMERIC, options).answers.items.map(
+ ({ content }) => content,
+ );
+
+ it('exports them as the exercise language shows them', () => {
+ expect(exported(['1.5', '1234.5'], { language: 'de' })).toEqual(['1,5', '1234,5']);
+ expect(exported(['1.5'], { language: 'en' })).toEqual(['1.5']);
+ });
+
+ it('exports them as stored when the exercise has no language', () => {
+ expect(exported(['1.5', '1234.5'])).toEqual(['1.5', '1234.5']);
+ });
+
+ it('exports an answer that is not a number as it was written', () => {
+ expect(exported(['abc'], { language: 'de' })).toEqual(['abc']);
+ });
+ });
+
+ it('exports text entry answers as written, whatever the exercise language', () => {
+ const decimal = { ...state, answers: [{ id: 'answer_a', value: '1.5', caseSensitive: false }] };
+ const { items } = exportTextEntryContent(decimal, QuestionType.TEXT_ENTRY, {
+ language: 'de',
+ }).answers;
+ expect(items.map(({ content }) => content)).toEqual(['1.5']);
+ });
+
+ it('imports the expected length that came with the answers', () => {
+ const content = { prompt: '', answers: { items: [], expectedLength: 10 } };
+ expect(importTextEntryContent(content, QuestionType.TEXT_ENTRY).expectedLength).toBe(10);
+ });
+
+ it('imports each answer as a new one, with the default expected length otherwise', () => {
+ const content = {
+ prompt: 'Q
',
+ answers: { items: [{ content: 'Paris', correct: true, caseSensitive: true }] },
+ };
+ expect(importTextEntryContent(content, QuestionType.TEXT_ENTRY)).toEqual({
+ prompt: 'Q
',
+ answers: [{ id: expect.stringMatching(/^answer_/), value: 'Paris', caseSensitive: true }],
+ expectedLength: DEFAULT_EXPECTED_LENGTH,
+ });
+ });
+
+ it('makes no numeric answer case-sensitive', () => {
+ const content = { prompt: '', answers: { items: [{ content: '1', caseSensitive: true }] } };
+ expect(importTextEntryContent(content, QuestionType.NUMERIC).answers[0].caseSensitive).toBe(
+ false,
+ );
+ });
+
+ it('starts with one blank answer when there are none', () => {
+ expect(importTextEntryContent({ prompt: '' }, QuestionType.TEXT_ENTRY).answers).toEqual([
+ { id: expect.stringMatching(/^answer_/), value: '', caseSensitive: false },
+ ]);
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/convert.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/convert.js
new file mode 100644
index 0000000000..48cb2b18a0
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/convert.js
@@ -0,0 +1,72 @@
+import { CorrectCriterion, ContentLevel, QuestionType } from '../../constants';
+import { formatLocaleNumber } from '../../utils/localeNumbers';
+import { _defaultState, newAnswer } from './parse';
+
+// A free response has no answers to give or take.
+const takesAnswers = questionType => questionType !== QuestionType.FREE_RESPONSE;
+
+/**
+ * @param {string} questionType
+ * @returns {import('../../conversion').ConversionFormat}
+ */
+export function getTextEntryConversionFormat(questionType) {
+ if (!takesAnswers(questionType)) {
+ return {};
+ }
+ return {
+ answers: { correctCriterion: CorrectCriterion.ACCEPTED, contentLevel: ContentLevel.PLAIN },
+ };
+}
+
+/**
+ * @param {import('./parse').TextEntryState} state
+ * @param {string} questionType
+ * @param {{ language?: string }} [options] - The exercise language
+ * @returns {import('../../conversion').ConvertibleContent}
+ */
+export function exportTextEntryContent(state, questionType, { language } = {}) {
+ // Numeric answers are stored as xsd:double but shown in the exercise language; they leave as
+ // the author saw them, which is also how learners of that language write them.
+ const shown = value =>
+ questionType === QuestionType.NUMERIC ? formatLocaleNumber(value, language) : value;
+ return {
+ prompt: state.prompt,
+ answers: {
+ correctCriterion: CorrectCriterion.ACCEPTED,
+ contentLevel: ContentLevel.PLAIN,
+ // A free response saves no answers, so it has none to carry, whatever its state holds.
+ items: takesAnswers(questionType)
+ ? state.answers.map(({ value, caseSensitive }) => ({
+ content: shown(value),
+ correct: true,
+ caseSensitive,
+ }))
+ : [],
+ expectedLength: state.expectedLength,
+ },
+ };
+}
+
+/**
+ * @param {import('../../conversion').ConvertibleContent} content
+ * @param {string} questionType
+ * @returns {import('./parse').TextEntryState}
+ */
+export function importTextEntryContent({ prompt, answers }, questionType) {
+ const state = _defaultState();
+ const items = answers?.items ?? [];
+ return {
+ ...state,
+ prompt,
+ answers: items.length
+ ? items.map(({ content, caseSensitive }) =>
+ newAnswer({
+ value: content,
+ // Numeric answers are never case-sensitive.
+ caseSensitive: questionType === QuestionType.TEXT_ENTRY && Boolean(caseSensitive),
+ }),
+ )
+ : state.answers,
+ expectedLength: answers?.expectedLength ?? state.expectedLength,
+ };
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js
index faf5116a3e..d1edbc7829 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js
@@ -40,6 +40,19 @@ const serializer = new XMLSerializer();
*/
export const DEFAULT_EXPECTED_LENGTH = 50;
+/**
+ * A new answer, with a fresh identifier.
+ *
+ * @param {Partial} [fields]
+ * @returns {TextEntryAnswer}
+ */
+export const newAnswer = (fields = {}) => ({
+ id: generateRandomSlug('answer'),
+ value: '',
+ caseSensitive: false,
+ ...fields,
+});
+
/**
* Default state — used when bodyXml is absent or unparseable.
*
@@ -48,7 +61,7 @@ export const DEFAULT_EXPECTED_LENGTH = 50;
export function _defaultState() {
return {
prompt: '',
- answers: [{ id: generateRandomSlug('answer'), value: '', caseSensitive: false }],
+ answers: [newAnswer()],
expectedLength: DEFAULT_EXPECTED_LENGTH,
};
}
@@ -145,12 +158,7 @@ function extractNumericAnswers(declarationEl) {
const mapKeys = [...declarationEl.querySelectorAll(':scope > qti-mapping qti-map-entry')]
.filter(entry => parseFloat(entry.getAttribute('mapped-value')) >= 1)
.map(entry => (entry.getAttribute('map-key') ?? '').trim());
- const toAnswers = answerValues =>
- answerValues.map(value => ({
- id: generateRandomSlug('answer'),
- value,
- caseSensitive: false,
- }));
+ const toAnswers = answerValues => answerValues.map(value => newAnswer({ value }));
// The editor writes its first answer as the correct response and maps every answer,
// so its map-keys are the answers as authored, repeats included.
if (values.length === 1 && values[0] === mapKeys[0]) {
@@ -202,11 +210,7 @@ export function _extractAnswers(responseDeclarations) {
return [];
}
- const toAnswer = (value, caseSensitive) => ({
- id: generateRandomSlug('answer'),
- value,
- caseSensitive,
- });
+ const toAnswer = (value, caseSensitive) => newAnswer({ value, caseSensitive });
const mapEntries = declaration.mapping?.entries ?? [];
// Key on the XML string form: both map-key and correct-response values are coerced
// on parse (empty → null under QTI NULL semantics), so formatting both back matches
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
index a2324c9fcf..a2840fab32 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
@@ -369,6 +369,11 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', {
message: 'Choose a correct answer',
context: 'Validation error when no choice is marked correct',
},
+ errorTooFewInlineChoiceOptions: {
+ message: 'Add at least 2 options',
+ context:
+ 'Validation error when an answer dropdown in an inline choice question has fewer than 2 options. Also shown on the dropdown itself',
+ },
errorTooManyCorrectAnswers: {
message: 'Only one correct answer is allowed for single-choice questions.',
context: 'Validation error when multiple choices are marked correct for single-select',
@@ -523,10 +528,6 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', {
context:
'Button in the passage toolbar that adds an answer dropdown at the cursor, or turns the highlighted text into one',
},
- addAnswers: {
- message: 'Add answers',
- context: 'Label on an answer dropdown chip that has no correct answer yet',
- },
answerDropdownWithCorrect: {
message:
'{label}, correct answer, answer dropdown, {count, plural, one {# option} other {# options}}',
@@ -534,10 +535,9 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', {
'Accessible name of an answer dropdown chip in the passage that has a correct answer. {label} is the correct answer, as the chip shows it',
},
answerDropdownNoCorrect: {
- message:
- '{label}, answer dropdown, {count, plural, one {# option} other {# options}}, no correct answer yet',
+ message: '{label}, answer dropdown, {count, plural, one {# option} other {# options}}',
context:
- "Accessible name of an answer dropdown chip in the passage that has no correct answer. {label} is the chip's 'Add answers' label",
+ "Accessible name of an answer dropdown chip in the passage that has no correct answer. {label} is the chip's label, the error its options show, such as 'Choose a correct answer'",
},
answerDropdownWithCorrectNeedsAttention: {
message:
@@ -547,8 +547,164 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', {
},
answerDropdownNoCorrectNeedsAttention: {
message:
- '{label}, answer dropdown, needs attention, {count, plural, one {# option} other {# options}}, no correct answer yet',
+ '{label}, answer dropdown, needs attention, {count, plural, one {# option} other {# options}}',
+ context:
+ "Accessible name of an answer dropdown chip in the passage that has no correct answer and has validation errors. {label} is the chip's label, the error its options show, such as 'Choose a correct answer'",
+ },
+ answerDropdownRemoved: {
+ message: '{label}, answer dropdown, removed',
+ context:
+ "Announced to screen readers when an answer dropdown is deleted from the passage. {label} is what the chip showed: its correct answer, or the error its options show, such as 'Choose a correct answer'",
+ },
+ answerDropdownsRemoved: {
+ message: '{count, plural, one {# answer dropdown removed} other {# answer dropdowns removed}}',
+ context:
+ 'Announced to screen readers when several answer dropdowns are deleted from the passage at once',
+ },
+ answerDropdownHidden: {
+ message: '{label}, answer dropdown, {count, plural, one {# option} other {# options}}',
+ context:
+ "Accessible name of an answer dropdown chip in a question preview that hides the correct answers. {label} is the chip's 'Choose…' label",
+ },
+ answerDropdownHiddenNeedsAttention: {
+ message:
+ '{label}, answer dropdown, needs attention, {count, plural, one {# option} other {# options}}',
+ context:
+ "Accessible name of an answer dropdown chip with a problem to fix, in a question preview that hides the correct answers. {label} is the chip's 'Choose…' label",
+ },
+ chooseAnswer: {
+ message: 'Choose…',
+ context:
+ 'Label on an answer dropdown in a question preview that hides the correct answers, where the learner would pick one',
+ },
+ inlineChoiceLabel: {
+ message: 'Inline choice',
+ context: 'Display name for an inline choice question type shown in the question type selector',
+ },
+ inlineChoiceDescription: {
+ message: 'Learners choose the correct answer from dropdowns placed within a passage.',
+ context: 'Description for the inline choice question type in the info modal',
+ },
+ questionOptionalLabel: {
+ message: 'Question (optional)',
+ context: 'Section header for a question prompt that authors may leave empty',
+ },
+ passageEditorLabel: {
+ message: 'Passage editor',
+ context: 'Section header for the passage that holds the inline choice dropdowns',
+ },
+ passageLabel: {
+ message: 'Passage',
+ context: 'Section header for the passage of an inline choice question, as a preview shows it',
+ },
+ passageEditorDescription: {
+ message: 'Inline choice widgets can be inserted blank or converted from highlighted text.',
+ context: 'Instruction under the passage editor header of an inline choice question',
+ },
+ editPassageLabel: {
+ message: 'Edit passage',
+ context: 'Accessible label for the clickable region that opens the passage for editing',
+ },
+ inlineChoiceOptionsLabel: {
+ message: 'Inline choice answer options',
+ context: 'Section header above the answer options of the open inline choice dropdown',
+ },
+ inlineChoiceOptionsDialogLabel: {
+ message: 'Answer options for dropdown {number}',
+ context:
+ 'Accessible name of the panel that edits the options of one inline choice dropdown. {number} is its position in the passage',
+ },
+ closeInlineChoiceOptions: {
+ message: 'Close answer options',
+ context: 'Button that closes the panel editing the options of an inline choice dropdown',
+ },
+ inlineChoiceOptionLabel: {
+ message: 'Option {number}',
+ context:
+ 'Names an inline choice option row: its text field, its reorder controls, and the announcement after it moves',
+ },
+ markInlineChoiceOptionCorrect: {
+ message: 'Mark option {number} as correct',
+ context: 'Accessible label for the radio button that marks an inline choice option correct',
+ },
+ addInlineChoiceOptionBtn: {
+ message: 'Add option',
+ context: 'Button that appends a new option to the open inline choice dropdown',
+ },
+ deleteInlineChoiceOptionBtn: {
+ message: 'Delete option {number}',
+ context: 'Accessible label for the delete icon button next to an inline choice option',
+ },
+ errorInlineChoiceOptionProblems: {
+ message: 'One or more inline choice options have problems',
+ context: 'Validation error above the passage when any of its dropdowns needs attention',
+ },
+ errorNoInlineChoice: {
+ message: 'Insert at least one inline choice',
+ context: 'Validation error when the passage of an inline choice question has no dropdown',
+ },
+ changeQuestionTypeTitle: {
+ message: 'Change question type?',
+ context:
+ 'Title of the dialog asking an author to confirm a change of question type that would remove or change some of the question',
+ },
+ changeQuestionTypeIntro: {
+ message: 'Switching to {type} will change this question:',
+ context:
+ "Opens the list of what changing the question's type would remove or change. {type} is the new question type, e.g. 'Numeric input'",
+ },
+ changeQuestionTypeAction: {
+ message: 'Change',
+ context: 'Button that confirms changing the question type',
+ },
+ cancelAction: {
+ message: 'Cancel',
+ context: 'Button that closes a dialog without doing anything',
+ },
+ answersRemovedLoss: {
+ message: 'All answers will be removed.',
+ context:
+ 'Listed when changing the question type: the new type has no place for the answers the question has',
+ },
+ incorrectAnswersRemovedLoss: {
+ message: 'Answers not marked as correct will be removed.',
+ context:
+ 'Listed when changing a choice question to a type whose answers are all correct, such as numeric input or text entry',
+ },
+ anyCorrectAnswerAcceptedLoss: {
+ message: 'Learners will only need to give one of the correct answers.',
+ context:
+ 'Listed when changing a choice question with several correct answers to numeric input or text entry, where any one accepted answer is enough',
+ },
+ correctAnswersReducedLoss: {
+ message: 'Only the first correct answer will stay marked as correct.',
+ context:
+ 'Listed when changing a question with several correct answers to single choice, which can only have one',
+ },
+ correctAnswersNotKeptLoss: {
+ message: 'Answers will no longer be marked as correct.',
+ context:
+ 'Listed when changing a choice question to ordering, where an answer is not correct or incorrect by itself',
+ },
+ orderBecomesAnswerLoss: {
+ message: 'The current order will become the correct order.',
+ context: 'Listed when changing a question to ordering: the answers keep their current order',
+ },
+ orderNotKeptLoss: {
+ message: "The correct order won't be kept.",
+ context:
+ 'Listed when changing an ordering question to a choice question, which has no correct order',
+ },
+ formattingRemovedLoss: {
+ message:
+ 'Formatting, images and formulas in answers will be removed. Answers with only an image or formula will be removed entirely.',
+ context:
+ 'Listed when changing the question type to one whose answers can only hold text, such as text entry',
+ },
+ passageAddedToQuestionLoss: {
+ message:
+ 'The passage will be added to the question, with each blank shown as "{blank}". Its answers will be removed.',
context:
- "Accessible name of an answer dropdown chip in the passage that has no correct answer and has validation errors. {label} is the chip's 'Add answers' label",
+ 'Listed when changing an inline choice question to a type with no passage. {blank} is what each blank becomes, a row of underscores',
},
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js
index 22867eea52..73faefa612 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js
@@ -239,11 +239,9 @@ describe('assembleItemXml', () => {
it('writes no processing for a lone response declaration with nothing to score it by', () => {
// A free response: there is no answer to score against, which is not a mistake.
- const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const xml = assembleWith([declaration('RESPONSE')]);
expect(parseXML(xml).querySelector('qti-response-processing')).toBeNull();
expect(identifiersOf(xml, 'qti-outcome-declaration')).toEqual(['SCORE']);
- expect(warn).not.toHaveBeenCalled();
});
it('averages the scoring rules of several scorable response declarations', () => {
@@ -255,28 +253,21 @@ describe('assembleItemXml', () => {
expect(xml).toContain(EXPECTED_PROCESSING);
});
- it('writes no processing and warns when a response declaration cannot be scored', () => {
- const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ it('writes no processing when a response declaration cannot be scored', () => {
const xml = assembleWith([
declaration('response_xq7tbn2c', 'choice_a1b2c3d4'),
declaration('response_pw4rzk8d'),
]);
expect(parseXML(xml).querySelector('qti-response-processing')).toBeNull();
expect(identifiersOf(xml, 'qti-outcome-declaration')).toEqual(['SCORE']);
- expect(warn).toHaveBeenCalledTimes(1);
- expect(warn.mock.calls[0][0]).toContain('response_pw4rzk8d');
- expect(warn.mock.calls[0][0]).not.toContain('response_xq7tbn2c');
});
it('treats a response declaration it cannot read as unscorable', () => {
- const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const xml = assembleWith([
declaration('response_xq7tbn2c', 'choice_a1b2c3d4'),
'',
]);
expect(parseXML(xml).querySelector('qti-response-processing')).toBeNull();
- expect(warn).toHaveBeenCalledTimes(1);
- expect(warn.mock.calls[0][0]).toContain('response_pw4rzk8d');
});
describe('element order', () => {
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js
index 80e1872d17..419ab2f854 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js
@@ -119,23 +119,17 @@ function buildScoringNodes(declNodes) {
return { outcomeDeclarations, responseProcessing: rule ? buildRulesNode(SCORE, [rule]) : null };
}
- const scored = declNodes.map(node => ({
- identifier: node.getAttribute('identifier'),
- rule: readDeclaration(node)?.getScoringRule(RAW_SCORE) ?? null,
- }));
- const unscorable = scored.filter(({ rule }) => !rule).map(({ identifier }) => identifier);
- if (unscorable.length) {
- // eslint-disable-next-line no-console
- console.warn(
- `[QTI Editor] Writing no response processing: cannot score ${unscorable.join(', ')}`,
- );
+ const rules = declNodes.map(node => readDeclaration(node)?.getScoringRule(RAW_SCORE) ?? null);
+ // Expected while an item is still being written, such as an inline choice dropdown with no
+ // correct answer yet; validation reports that to the author, so it is not logged here.
+ if (rules.includes(null)) {
return { outcomeDeclarations, responseProcessing: null };
}
return {
outcomeDeclarations: [...outcomeDeclarations, buildOutcomeDeclarationNode(RAW_SCORE)],
responseProcessing: buildRulesNode(RAW_SCORE, [
- ...scored.map(({ rule }) => rule),
+ ...rules,
buildXmlNode({
tag: 'qti-set-outcome-value',
attrs: { identifier: SCORE },
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js
index 61a64cca33..a5e0676571 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js
@@ -66,6 +66,18 @@ export function getContentHTML(el) {
return serializeAsHtml([...el.childNodes], el.namespaceURI);
}
+/**
+ * An HTML fragment, as a rich text editor writes it, parsed into the `` of a new
+ * document. As HTML, not XML: the fragment can hold `
`, ` ` and other markup an XML
+ * parser rejects. Read it back with getContentHTML.
+ *
+ * @param {string} html
+ * @returns {HTMLElement}
+ */
+export function parseHTMLBody(html) {
+ return parseXML(`${html ?? ''}`, 'text/html').body;
+}
+
const xmlDoc = parser.parseFromString('', 'text/xml');
const XHTML_NS = 'http://www.w3.org/1999/xhtml';
const DROPPED_ELEMENTS = new Set(['noscript', 'script', 'style']);
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/richText.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/richText.spec.js
index 31ef06600e..08ee511779 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/richText.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/richText.spec.js
@@ -1,4 +1,4 @@
-import { hasRichTextContent } from '../richText';
+import { escapeText, hasRichTextContent, toPlainText } from '../richText';
describe('hasRichTextContent', () => {
it.each([
@@ -33,3 +33,44 @@ describe('hasRichTextContent', () => {
expect(hasRichTextContent(content)).toBe(expected);
});
});
+
+describe('toPlainText', () => {
+ it.each([
+ ['Paris', 'Paris'],
+ ['Paris
', 'Paris'],
+ ['', ''],
+ ['', ''],
+ [null, ''],
+ // Entities are decoded, not left as their literal characters.
+ ['a & b c
', 'a & b c'],
+ ])('reads %s as %s and loses nothing', (content, text) => {
+ expect(toPlainText(content)).toEqual({ text, lost: false });
+ });
+
+ it.each([
+ ['Paris
', 'Paris'],
+ ['H2O
', 'H2O'],
+ ['first
second
', 'first second'],
+ ['first
second
', 'first second'],
+ ['', 'one two'],
+ ])('reads %s as %s and says what plain text cannot hold is lost', (content, text) => {
+ expect(toPlainText(content)).toEqual({ text, lost: true });
+ });
+
+ it.each([
+ ['an image', '
'],
+ ['a formula', '
'],
+ ])('reads %s on its own as no text, and lost', (_, content) => {
+ expect(toPlainText(content)).toEqual({ text: '', lost: true });
+ });
+
+ it('counts an empty paragraph after the text as no second line', () => {
+ expect(toPlainText('Paris
')).toEqual({ text: 'Paris', lost: false });
+ });
+});
+
+describe('escapeText', () => {
+ it('escapes markup, so it reads back as the same text', () => {
+ expect(escapeText('a < b & c')).toBe('a < b & <b>c</b>');
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js
index 9ead9a85fe..5e2aff19cd 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js
@@ -13,7 +13,7 @@
*/
import { QTISanitizer } from '../serialization/qti/QTISanitizer';
-import { parseXML } from '../serialization/xml';
+import { getContentHTML, parseHTMLBody, parseXML } from '../serialization/xml';
/**
* Markup that is content in its own right, with no text to find.
@@ -49,3 +49,77 @@ export function hasRichTextContent(content) {
Boolean(parseXML(content ?? '', 'text/html').querySelector(EMBEDDED_MEDIA))
);
}
+
+/**
+ * Elements that end a line of text. Their content is kept apart from the next block's when
+ * the fragment is read as one line, so `a
b
` reads `a b`, not `ab`.
+ */
+const LINE_BREAKING = new Set([
+ 'p',
+ 'div',
+ 'br',
+ 'li',
+ 'ul',
+ 'ol',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'blockquote',
+ 'pre',
+ 'table',
+ 'tr',
+ 'td',
+ 'th',
+]);
+
+function lineText(node) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ return node.nodeValue;
+ }
+ if (node.nodeType !== Node.ELEMENT_NODE) {
+ return '';
+ }
+ const text = [...node.childNodes].map(lineText).join('');
+ return LINE_BREAKING.has(node.localName) ? ` ${text} ` : text;
+}
+
+/**
+ * A rich text fragment as one line of plain text, for a field that holds text only, such
+ * as a text entry answer or an inline choice option.
+ *
+ * Anything plain text cannot hold is dropped: formatting, images, formulas, and the breaks
+ * between paragraphs. `lost` says whether there was any, so the author can be told before
+ * it goes. A single paragraph of text loses nothing, since a plain text field shows it the
+ * same way.
+ *
+ * @param {string} content - HTML fragment from a TipTap editor
+ * @returns {{ text: string, lost: boolean }}
+ */
+export function toPlainText(content) {
+ const body = parseHTMLBody(content);
+ body.querySelectorAll('script, style').forEach(el => el.remove());
+ const elements = [...body.querySelectorAll('*')];
+ // Loose text beside the paragraphs is a line of its own; empty paragraphs are none.
+ const hasLooseText = [...body.childNodes].some(
+ node => node.nodeType === Node.TEXT_NODE && node.nodeValue.trim(),
+ );
+ const lines = elements.filter(el => el.textContent.trim()).length + (hasLooseText ? 1 : 0);
+ const lost = elements.some(el => el.localName !== 'p') || lines > 1;
+ const text = lineText(body).replace(/\s+/g, ' ').trim();
+ return { text, lost };
+}
+
+/**
+ * Plain text as HTML, escaped, with no element around it.
+ *
+ * @param {string} text
+ * @returns {string}
+ */
+export function escapeText(text) {
+ const body = parseHTMLBody('');
+ body.textContent = text ?? '';
+ return getContentHTML(body);
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
index 01b0fbdeaa..77240d0e76 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
@@ -518,8 +518,8 @@ export const UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT = ``;
/**
- * Inline choice dropdowns, which have a descriptor but no editor yet. The second dropdown
- * has no correct answer, which the editor's rules would report.
+ * Inline choice dropdowns. The second dropdown has no correct answer, which the editor's
+ * rules report.
*/
export const INLINE_CHOICE_ITEM_DOCUMENT = `
- import { defineComponent, provide, watch, computed, ref, toRef, nextTick } from 'vue';
+ import {
+ defineComponent,
+ provide,
+ watch,
+ computed,
+ ref,
+ toRef,
+ nextTick,
+ onBeforeUnmount,
+ } from 'vue';
import EditorToolbar from './components/EditorToolbar.vue';
import EditorContentWrapper from './components/EditorContentWrapper.vue';
import { useEditor } from './composables/useEditor';
@@ -95,7 +104,7 @@
import { useMathHandling } from './composables/useMathHandling';
import FormulasMenu from './components/math/FormulasMenu.vue';
import { preprocessMarkdown } from './utils/markdown';
- import { resolveImageSrcs, toStoredImageSrcs } from './utils/imageSrc';
+ import { resolveImageSrcs, storedHTML } from './utils/imageSrc';
import { toInlineHTML } from './utils/inlineContent';
import MobileTopBar from './components/toolbar/MobileTopBar.vue';
import MobileFormattingBar from './components/toolbar/MobileFormattingBar.vue';
@@ -167,7 +176,7 @@
// Image srcs are resolved for display on the way in, so they are reduced
// back to their stored form here — leaving this the one place that reads
// content out, whichever form the editor happens to be holding.
- if (isHTML.value) return toStoredImageSrcs(editor.value.getHTML());
+ if (isHTML.value) return storedHTML(editor.value);
if (!editor.value.storage?.markdown) return '';
return editor.value.storage.markdown.getMarkdown();
};
@@ -177,6 +186,32 @@
// back, so we can skip unnecessary re-rendering of the editor content.
let lastEmittedMarkdown = null;
+ /**
+ * Focus the content once it is in the page. TipTap puts it there only when the editor is
+ * ready, in a timeout after it is created, and focusing it any earlier focuses nothing:
+ * so an editor switched into edit mode as soon as it mounts waits for that.
+ */
+ let stopWaitingForReady = () => {};
+
+ const focusWhenReady = () => {
+ stopWaitingForReady();
+ if (isReady.value) {
+ nextTick(() => editor.value?.commands.focus('end'));
+ return;
+ }
+ stopWaitingForReady = watch(isReady, ready => {
+ if (!ready) return;
+ stopWaitingForReady();
+ // After the re-render that puts the content into the page.
+ nextTick(() => {
+ if (props.mode === 'edit') editor.value?.commands.focus('end');
+ });
+ });
+ };
+
+ // Created outside setup, so the component's unmount doesn't stop it by itself.
+ onBeforeUnmount(() => stopWaitingForReady());
+
watch(
() => props.mode,
newMode => {
@@ -184,9 +219,7 @@
editor.value.setEditable(newMode === 'edit');
}
if (newMode === 'edit' && editor.value && props.autofocus) {
- nextTick(() => {
- editor.value?.commands.focus('end');
- });
+ focusWhenReady();
}
},
);
diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue
index 64400ddbbb..ca87788802 100644
--- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue
@@ -100,8 +100,10 @@
aria-hidden="true"
class="more-button-icon"
>
+
@@ -135,18 +137,22 @@
-
+
@@ -165,6 +171,8 @@