The ${chip('r1', [ + ['a', 'Moon'], + ['b', 'Sun'], +])} rises
`; + +function setup(state = { prompt: '', passage: PASSAGE, shuffle: false }) { + const questionType = ref(QuestionType.INLINE_CHOICE); + const block = inlineChoiceInteractionDescriptor.buildXML(state, QuestionType.INLINE_CHOICE); + return useInlineChoiceInteraction(block, questionType); +} + +describe('useInlineChoiceInteraction', () => { + it('reads the dropdowns out of the passage', () => { + const { dropdowns } = setup(); + expect(dropdowns.value).toEqual([ + { + responseIdentifier: 'r1', + options: [ + { id: 'a', text: 'Moon' }, + { id: 'b', text: 'Sun' }, + ], + correctId: null, + }, + ]); + }); + + it('sets the question', () => { + const { state, setPrompt } = setup(); + setPrompt('Fill in the blanks
'); + expect(state.value.prompt).toBe('Fill in the blanks
'); + }); + + it('sets the passage, and the dropdowns follow it', () => { + const { dropdowns, setPassage } = setup(); + setPassage(`${chip('r2', [['c', 'Earth']], 'c')}
`); + expect(dropdowns.value.map(d => d.responseIdentifier)).toEqual(['r2']); + }); + + it('writes Shuffle onto every dropdown in the saved XML', () => { + const { bodyXml, setShuffle } = setup({ + prompt: '', + passage: `${chip('r1', [['a', 'x']], 'a')} ${chip('r2', [['b', 'y']], 'b')}
`, + shuffle: false, + }); + setShuffle(true); + const shuffles = [...bodyXml.value.matchAll(/shuffle="(\w+)"/g)].map(m => m[1]); + expect(shuffles).toEqual(['true', 'true']); + }); + + it('reads Shuffle back from a saved item', () => { + const { state } = setup({ prompt: '', passage: PASSAGE, shuffle: true }); + expect(state.value.shuffle).toBe(true); + }); + + it('reports a passage with no dropdown', async () => { + const { errors, setPassage } = setup(); + setPassage('No dropdowns here
'); + await nextTick(); + expect(errors.value).toEqual([{ code: ValidationError.NO_INTERACTION }]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInlineChoiceInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInlineChoiceInteraction.js new file mode 100644 index 0000000000..28364e13c5 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInlineChoiceInteraction.js @@ -0,0 +1,43 @@ +import { computed, readonly } from 'vue'; +import { inlineChoiceInteractionDescriptor } from '../interactions/inlineChoice/Descriptor'; +import { getDropdowns } from '../interactions/inlineChoice/parse'; +import { useInteraction } from './useInteraction'; + +/** + * Composable for the inline choice interaction editor. + * + * Extends useInteraction with the question-level mutations. A dropdown's options are not + * edited here: they live in the passage, and are changed through the passage editor's node + * commands so that each edit is one step of its history. + * + * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock + * @param {import('vue').RefFill in the blanks
Read this first.
', + ), + ], ])('accepts %s', (_, document) => { expect(isSupported(document)).toBe(true); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js index 395196005d..45ffdcbb10 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js @@ -35,9 +35,9 @@ export const registry = Object.fromEntries(descriptors.map(d => [d.type, d])); /** * Interactions with a descriptor but no editor in index.js yet. Items holding one are - * shown read-only. TODO: #6182 removes inline choice. + * shown read-only. */ -export const HEADLESS_INTERACTIONS = Object.freeze([QtiInteraction.INLINE_CHOICE]); +export const HEADLESS_INTERACTIONS = Object.freeze([]); /** * Inline interactions whose editor holds one per item body. A body with several would 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..75fa995cab 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Descriptor.js @@ -18,11 +18,32 @@ class InlineChoiceInteractionDescriptor extends InteractionDescriptor { }); } + getTypeOptions(tr) { + return [ + { + value: QuestionType.INLINE_CHOICE, + label: tr.inlineChoiceLabel$(), + description: tr.inlineChoiceDescription$(), + }, + ]; + } + matches(el) { if (el.tagName.toLowerCase() === QtiInteraction.INLINE_CHOICE) return true; return Boolean(el.querySelector(QtiInteraction.INLINE_CHOICE)); } + /** + * parse() reads every body: elements it wrote as the question go back to the question, and + * everything else is the passage. Another interaction in the body would be its own block, + * which isSupportedItem already rejects. + * + * @returns {boolean} + */ + isSupportedBody() { + return true; + } + getQuestionType() { return QuestionType.INLINE_CHOICE; } 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..9e07e4cbc7 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Editor.vue @@ -0,0 +1,447 @@ + + +The Earth ${chip('r1', [ + ['sun', 'Sun'], + ['moon', 'Moon'], + ['stars', 'Stars'], +])} orbits, and ${chip('r2', [['revolves', 'revolves']], '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', async () => { + render(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('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']); + }); + + 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']); + }); + }); + + 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' }]); + }); + + 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(2); + await waitFor(() => expect(optionInputs()[1]).toHaveFocus()); + await user.keyboard('rotates'); + expect(chips()[1]).toHaveTextContent(/^2/); + expect(savedDropdowns(emitted())[1].options.map(o => o.text)).toEqual([ + 'revolves', + '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(); + await openChip(user, 1); + 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(['rotates', 'revolves']); + expect( + screen.getByRole('radio', { name: tr.markInlineChoiceOptionCorrect$({ number: 2 }) }), + ).toBeChecked(); + const [, dropdown] = savedDropdowns(emitted()); + expect(dropdown.options.map(o => o.text)).toEqual(['rotates', 'revolves']); + 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.addAnswers$()); + 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()[1]).toHaveFocus()); + + await user.keyboard('{Control>}z{/Control}'); + expect(optionInputs().map(input => input.value)).toEqual(['revolves']); + await waitFor(() => expect(optionInputs()[0]).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']); + + await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}'); + expect(optionInputs().map(input => input.value)).toEqual(['revolvess']); + 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'])); + 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}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(`1 ${tr.chooseAnswer$()}`); + expect(hidden).toHaveAccessibleName( + tr.answerDropdownHidden$({ label: tr.chooseAnswer$(), count: 1 }), + ); + }); + + 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..169ad82569 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'; @@ -122,39 +125,166 @@ 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', [['a', 'x']], 'a')}
` }); + editor.commands.updateInlineChoice('r1', { + options: [ + { id: 'a', text: 'x' }, + { id: 'b', text: 'y' }, + ], + correctId: 'b', + }); + await waitFor(() => expect(getChip()).toHaveTextContent('2 y')); + getChip().focus(); + + await user.keyboard('{Control>}z{/Control}'); + await waitFor(() => expect(getChip()).toHaveTextContent('1 x')); + expect(getChip()).toHaveFocus(); + + await user.keyboard(redoKeys); + await waitFor(() => expect(getChip()).toHaveTextContent('2 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', [['a', 'x']], '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', [['c1', 'x']], 'c1')}b
`, + }); + editor.commands.setTextSelection(3); press(editor, 'Backspace', 8); await waitFor(() => expect(screen.queryByRole('button', CHIP)).not.toBeInTheDocument()); press(editor, 'z', 90, { ctrlKey: true }); @@ -180,20 +310,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')}
`, 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..5d756e604a 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 @@ -128,14 +128,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', { 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..71c230f419 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(); @@ -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,146 @@ 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']], '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: 1 }), + ]); + }); + + 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.addAnswers$(), + 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.addAnswers$() }), + ]); + }); + + 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 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/components/InlineChoiceChip/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue index 4cdeefad77..c90c475fe1 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,36 @@ 'chip', { 'is-open': isEditable && isOpen, + 'is-revealed': !isEditable && showAnswers, '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)" > - + +