From 5b885558c4b1e89c267f42b0184c5e1a2e928d7b Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Thu, 24 Sep 2026 09:14:31 -0700 Subject: [PATCH 1/4] Extract ShuffledResponsePool and EditableChipList from associate editor EditableChipList adds a region add mode, reachable by keyboard through ClickableRegion's overlay button. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/ClickableRegion.spec.js | 19 + .../components/ClickableRegion/index.vue | 25 +- .../__tests__/EditableChipList.spec.js | 376 +++++++++++++ .../components/EditableChipList/index.vue | 509 ++++++++++++++++++ .../__tests__/ShuffledResponsePool.spec.js | 75 +++ .../components/ShuffledResponsePool/index.vue | 146 +++++ .../interactions/associate/Editor.vue | 462 +++------------- .../views/QTIEditor/qtiEditorStrings.js | 4 + .../TipTapEditor/TipTapEditor.vue | 18 +- .../components/EditorContentWrapper.vue | 19 +- 10 files changed, 1267 insertions(+), 386 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/__tests__/EditableChipList.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/index.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/__tests__/ShuffledResponsePool.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/index.vue diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/__tests__/ClickableRegion.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/__tests__/ClickableRegion.spec.js index e8e5589e38..3af1d2c4f0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/__tests__/ClickableRegion.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/__tests__/ClickableRegion.spec.js @@ -66,4 +66,23 @@ describe('ClickableRegion', () => { expect(emitted().click).toHaveLength(1); }); + + it('focuses its button when asked to', async () => { + render( + { + components: { ClickableRegion }, + template: ` +
+ + +
+ `, + }, + { routes: new VueRouter() }, + ); + + await fireEvent.click(screen.getByRole('button', { name: 'Elsewhere' })); + + expect(screen.getByRole('button', { name: 'Test label' })).toHaveFocus(); + }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue index 80ff7e1d42..2abc6b65cd 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue @@ -10,8 +10,10 @@ > @@ -25,9 +27,13 @@ + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/__tests__/ShuffledResponsePool.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/__tests__/ShuffledResponsePool.spec.js new file mode 100644 index 0000000000..62c2dff586 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/__tests__/ShuffledResponsePool.spec.js @@ -0,0 +1,75 @@ +import { render, screen, within } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import shuffle from 'lodash/shuffle'; +import { themePalette } from 'kolibri-design-system/lib/styles/theme'; +import ShuffledResponsePool from '../index.vue'; +import { qtiEditorStrings } from '../../../qtiEditorStrings'; + +jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); +jest.mock('lodash/shuffle', () => jest.fn(list => [...list].reverse())); + +const { responsePoolLabel$ } = qtiEditorStrings; + +const CHOICES = [ + { content: 'Antonio', isCorrect: true }, + { content: 'Prospero', isCorrect: false }, + { content: 'Capulet', isCorrect: false }, +]; + +const renderPool = (choices = CHOICES) => + render(ShuffledResponsePool, { + props: { choices, label: responsePoolLabel$() }, + routes: new VueRouter(), + }); + +const pool = () => screen.getByRole('list', { name: responsePoolLabel$() }); +const chipTexts = () => + within(pool()) + .getAllByRole('listitem') + .map(item => item.textContent.trim()); +const chip = text => within(pool()).getByText(text).closest('li'); + +describe('ShuffledResponsePool', () => { + beforeEach(() => { + shuffle.mockImplementation(list => [...list].reverse()); + }); + + it('labels the pool heading and the list, with one item per choice', () => { + renderPool(); + expect(screen.getByRole('heading', { name: responsePoolLabel$() })).toBeInTheDocument(); + expect(within(pool()).getAllByRole('listitem')).toHaveLength(3); + }); + + it('renders two choices with the same content as two items', () => { + renderPool([ + { content: 'Antonio', isCorrect: false }, + { content: 'Antonio', isCorrect: false }, + ]); + expect(chipTexts()).toEqual(['Antonio', 'Antonio']); + }); + + it('paints only the correct choice green, wherever the shuffle puts it', () => { + const palette = themePalette(); + renderPool(); + expect(chipTexts()).toEqual(['Capulet', 'Prospero', 'Antonio']); + expect(chip('Antonio')).toHaveStyle({ + borderColor: palette.green.v_600, + backgroundColor: palette.green.v_50, + }); + expect(chip('Prospero')).not.toHaveStyle({ borderColor: palette.green.v_600 }); + expect(chip('Prospero')).not.toHaveStyle({ backgroundColor: palette.green.v_50 }); + }); + + it('keeps its order when only correctness changes', async () => { + const { updateProps } = renderPool(); + shuffle.mockImplementation(list => [...list]); + await updateProps({ choices: CHOICES.map(choice => ({ ...choice, isCorrect: false })) }); + expect(chipTexts()).toEqual(['Capulet', 'Prospero', 'Antonio']); + }); + + it('reshuffles when the contents change', async () => { + const { updateProps } = renderPool(); + await updateProps({ choices: [...CHOICES, { content: 'Montague', isCorrect: false }] }); + expect(chipTexts()).toEqual(['Montague', 'Capulet', 'Prospero', 'Antonio']); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/index.vue new file mode 100644 index 0000000000..7335ff7243 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/index.vue @@ -0,0 +1,146 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue index 1b7ad2c114..53f729824c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue @@ -1,9 +1,6 @@