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 @@ + + + + + + + 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 @@
+ + +
@@ -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 @@ + + + + + + + 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}

a

` }), + }); + 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)" > - + + + + {{ 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', 'a­b'], - ])( - '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
', '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" > + - + @@ -165,6 +171,8 @@