From 7c45abff78393c5330cf3e05851c315618591c27 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Mon, 18 May 2026 17:29:47 +0300 Subject: [PATCH 1/4] fix(exercises): autofocus RTE when answer/hint editor opens Answer and hint fields now receive focus when their RTE opens (on click or when a new answer/hint is added), matching the existing behaviour of the question editor. Cursor is placed at the end of any existing text so the user can type immediately. Also fixes a pre-existing bug in HintsEditor desktop layout where @open-editor was passing the undefined variable `answerIdx` instead of `hintIdx`. Fixes #5885 Co-Authored-By: Claude Sonnet 4.6 --- .../channelEdit/components/AnswersEditor/AnswersEditor.vue | 2 ++ .../channelEdit/components/HintsEditor/HintsEditor.vue | 4 +++- .../shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue | 5 +++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue index 83fc9e2bcf..fb2c83b9e2 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue @@ -94,6 +94,7 @@ v-model="answer.answer" class="editor" :mode="isAnswerOpen(answerIdx) ? 'edit' : 'view'" + :autofocus="isAnswerOpen(answerIdx)" :imageProcessor="EditorImageProcessor" @update="updateAnswerText($event, answerIdx)" @minimize="emitClose" @@ -164,6 +165,7 @@ v-model="answer.answer" class="editor" :mode="isAnswerOpen(answerIdx) ? 'edit' : 'view'" + :autofocus="isAnswerOpen(answerIdx)" :imageProcessor="EditorImageProcessor" @update="updateAnswerText($event, answerIdx)" @minimize="emitClose" diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue index 05ad7ebf85..ad204c50cd 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue @@ -51,6 +51,7 @@ diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index 70a2b6b77c..4600a49789 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue @@ -205,6 +205,11 @@ if (editor.value && editor.value.isEditable !== (newMode === 'edit')) { editor.value.setEditable(newMode === 'edit'); } + if (newMode === 'edit' && editor.value && props.autofocus) { + nextTick(() => { + editor.value.commands.focus('end'); + }); + } }, ); From f8bc773cc257b342ecbb1e652e43190a842b7910 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Mon, 18 May 2026 17:45:26 +0300 Subject: [PATCH 2/4] fix(rte): guard editor.value in nextTick with optional chaining MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synchronous null check doesn't protect against the component unmounting between the watcher firing and the nextTick callback running — useEditor's onUnmounted sets editor.value to null, which would cause a throw. Optional chaining handles this safely. Co-Authored-By: Claude Sonnet 4.6 --- .../shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index 4600a49789..f59e183e86 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue @@ -207,7 +207,7 @@ } if (newMode === 'edit' && editor.value && props.autofocus) { nextTick(() => { - editor.value.commands.focus('end'); + editor.value?.commands.focus('end'); }); } }, From f79c5081f08dbf29f40a2cd802d3ae9e65b56f31 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Mon, 18 May 2026 17:54:26 +0300 Subject: [PATCH 3/4] test(exercises): assert autofocus prop on open answer/hint editor Adds tests to AnswersEditor and HintsEditor specs verifying that the editor at the open index receives autofocus=true and all other editors receive autofocus=false. Co-Authored-By: Claude Sonnet 4.6 --- .../AnswersEditor/AnswersEditor.spec.js | 25 +++++++++++++++++++ .../HintsEditor/HintsEditor.spec.js | 24 ++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js index 7544e79acf..af9c7fb687 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js @@ -245,6 +245,31 @@ describe('AnswersEditor', () => { }); }); + describe('autofocus on the open answer editor', () => { + beforeEach(() => { + wrapper = mount(AnswersEditor, { + propsData: { + questionKind: AssessmentItemTypes.SINGLE_SELECTION, + answers: [ + { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, + { answer: 'Peanut butter', correct: false, order: 2 }, + ], + openAnswerIdx: 1, + }, + }); + }); + + it('passes autofocus=true to the open answer editor', () => { + const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); + expect(editors.at(1).props('autofocus')).toBe(true); + }); + + it('passes autofocus=false to closed answer editors', () => { + const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); + expect(editors.at(0).props('autofocus')).toBe(false); + }); + }); + describe('on an answer click', () => { beforeEach(async () => { wrapper = mount(AnswersEditor, { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js index 65b228a26f..8dcf9969a7 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js @@ -131,6 +131,30 @@ describe('HintsEditor', () => { }); }); + describe('autofocus on the open hint editor', () => { + beforeEach(() => { + wrapper = mount(HintsEditor, { + propsData: { + hints: [ + { hint: 'First hint', order: 1 }, + { hint: 'Second hint', order: 2 }, + ], + openHintIdx: 0, + }, + }); + }); + + it('passes autofocus=true to the open hint editor', () => { + const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); + expect(editors.at(0).props('autofocus')).toBe(true); + }); + + it('passes autofocus=false to closed hint editors', () => { + const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); + expect(editors.at(1).props('autofocus')).toBe(false); + }); + }); + describe('on hint click', () => { beforeEach(async () => { wrapper = mount(HintsEditor, { From ef24d28e883b4f25fecae8b952f3cf87fad54609 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Mon, 18 May 2026 18:01:09 +0300 Subject: [PATCH 4/4] refactor(tests): use component reference instead of name string in findAllComponents Replace { name: 'RichTextEditor' } with the imported TipTapEditor reference so that a component rename causes a clear import error rather than a silent test failure. Co-Authored-By: Claude Sonnet 4.6 --- .../components/AnswersEditor/AnswersEditor.spec.js | 7 ++++--- .../components/HintsEditor/HintsEditor.spec.js | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js index af9c7fb687..fe0ec93a82 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js @@ -3,6 +3,7 @@ import { shallowMount, mount } from '@vue/test-utils'; import { AssessmentItemToolbarActions } from '../../constants'; import AnswersEditor from './AnswersEditor'; import { AssessmentItemTypes } from 'shared/constants'; +import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'); @@ -260,12 +261,12 @@ describe('AnswersEditor', () => { }); it('passes autofocus=true to the open answer editor', () => { - const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); + const editors = wrapper.findAllComponents(TipTapEditor); expect(editors.at(1).props('autofocus')).toBe(true); }); it('passes autofocus=false to closed answer editors', () => { - const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); + const editors = wrapper.findAllComponents(TipTapEditor); expect(editors.at(0).props('autofocus')).toBe(false); }); }); @@ -339,7 +340,7 @@ describe('AnswersEditor', () => { }, }); - const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); + const editors = wrapper.findAllComponents(TipTapEditor); editors.at(1).vm.$emit('update', 'European butter'); await wrapper.vm.$nextTick(); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js index 8dcf9969a7..a44043a60a 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js @@ -3,6 +3,7 @@ import { shallowMount, mount } from '@vue/test-utils'; import { AssessmentItemToolbarActions } from '../../constants'; import HintsEditor from './HintsEditor'; +import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'); @@ -65,7 +66,7 @@ describe('HintsEditor', () => { }); // Find all instances of your new RichTextEditor component - const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); + const editors = wrapper.findAllComponents(TipTapEditor); expect(editors.length).toBe(2); // Instead of checking the raw HTML, we check the `value` prop passed to each editor. @@ -85,7 +86,7 @@ describe('HintsEditor', () => { }, }); - const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); + const editors = wrapper.findAllComponents(TipTapEditor); editors.at(1).vm.$emit('update', 'Updated hint'); }); @@ -145,12 +146,12 @@ describe('HintsEditor', () => { }); it('passes autofocus=true to the open hint editor', () => { - const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); + const editors = wrapper.findAllComponents(TipTapEditor); expect(editors.at(0).props('autofocus')).toBe(true); }); it('passes autofocus=false to closed hint editors', () => { - const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); + const editors = wrapper.findAllComponents(TipTapEditor); expect(editors.at(1).props('autofocus')).toBe(false); }); });