diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js index a3c831b10c..009cd3e574 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js +++ b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js @@ -259,6 +259,57 @@ export const ASSOCIATE_ITEM_XML = ` `; +/** + * Demo item 8: match interaction — learner matches animals to their traits. + * Uses cardinality="multiple" and base-type="directedPair". + * Bat has two answers, "Can fly" and "Mammal" are each shared by two rows, and + * "Reptile" is a distractor. + */ +export const MATCH_ITEM_XML = ` + + + + row_bat choice_mammal + row_bat choice_fly + row_eagle choice_bird + row_eagle choice_fly + row_whale choice_mammal + + + + + + Match each animal with its traits: + + Bat + Eagle + Whale + + + Mammal + Can fly + Bird + Reptile + + + +`; + /** * Hardcoded items covering different states: * - item-1: single-select choice interaction @@ -268,6 +319,7 @@ export const ASSOCIATE_ITEM_XML = ` * - item-free-response: free-response text-entry (no correct answer) * - item-ordering: ordering interaction (planets by distance from the Sun) * - item-associate: associate interaction (countries to capitals, with distractors) + * - item-match: match interaction (animals to traits, with shared answers and a distractor) */ export const INITIAL_ASSESSMENTS = [ { @@ -305,4 +357,9 @@ export const INITIAL_ASSESSMENTS = [ type: AssessmentItemTypes.QTI, raw_data: ASSOCIATE_ITEM_XML, }, + { + assessment_id: 'demo-item-match', + type: AssessmentItemTypes.QTI, + raw_data: MATCH_ITEM_XML, + }, ]; 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 0b072d7b38..7f9d5204bd 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js @@ -5,6 +5,9 @@ import { CHOICE_ITEM_DOCUMENT_NO_PROMPT, CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, NO_INTERACTION_ITEM_DOCUMENT, + VALID_MATCH_ITEM_DOCUMENT, + MATCH_THREE_SETS_XML, + MATCH_XML, } from '../utils/testingFixtures'; const codesOf = errors => errors.map(error => error.code); @@ -37,6 +40,22 @@ describe('validateQtiItem', () => { expect(validateQtiItem(undefined)).toEqual([{ code: ValidationError.NO_INTERACTION }]); }); + describe('match interaction', () => { + it('returns no errors for a complete match item', () => { + expect(validateQtiItem(VALID_MATCH_ITEM_DOCUMENT)).toEqual([]); + }); + + it('runs the match validator', () => { + const noPrompt = VALID_MATCH_ITEM_DOCUMENT.replace(/.*<\/qti-prompt>/, ''); + expect(codesOf(validateQtiItem(noPrompt))).toContain(ValidationError.PROMPT_REQUIRED); + }); + + it('reports a match interaction without exactly two match sets as unparseable', () => { + const threeSets = VALID_MATCH_ITEM_DOCUMENT.replace(MATCH_XML, MATCH_THREE_SETS_XML); + expect(validateQtiItem(threeSets)).toEqual([{ code: ValidationError.PARSE_ERROR }]); + }); + }); + it('reports unparseable XML', () => { expect(validateQtiItem('')).toEqual([ { code: ValidationError.PARSE_ERROR }, 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: ` + + Elsewhere + + + `, + }, + { 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..080a4b3bf5 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue @@ -6,10 +6,12 @@ --> + import { ref } from 'vue'; + export default { name: 'ClickableRegion', setup(props, { emit }) { + const button = ref(null); + function onClick(event) { if (props.suppressed) return; if (event && event.stopPropagation) { @@ -35,7 +41,13 @@ } emit('click', event); } - return { onClick }; + return { + button, + onClick, + // Public: lets a parent move focus to the region. + // eslint-disable-next-line vue/no-unused-properties + focus: () => button.value?.focus(), + }; }, props: { ariaLabel: { @@ -46,6 +58,11 @@ type: Boolean, default: false, }, + /** A text cursor and no hover tint, for a region that opens an editor where it is clicked */ + textCursor: { + type: Boolean, + default: false, + }, }, emits: ['click'], }; @@ -58,6 +75,19 @@ .clickable-area { position: relative; border-radius: inherit; + + &.is-text-target { + cursor: text; + + // Child only: regions nested in this one keep their own cursor and tint. + > .overlay-button { + cursor: inherit; + + &:hover { + background-color: transparent; + } + } + } } .overlay-button { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/__tests__/EditableChipList.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/__tests__/EditableChipList.spec.js new file mode 100644 index 0000000000..6430b74235 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/__tests__/EditableChipList.spec.js @@ -0,0 +1,398 @@ +import { render, screen, within } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import VueRouter from 'vue-router'; +import EditableChipList from '../index.vue'; +import { qtiEditorStrings } from '../../../qtiEditorStrings'; + +jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); + +const { + distractorsLabel$, + addDistractorBtn$, + editDistractorLabel$, + deleteDistractorBtn$, + errorEmptyChoiceContent$, + saveChipBtn$, +} = qtiEditorStrings; + +const CHIPS = [ + { id: 'choice_a', content: 'Antonio' }, + { id: 'choice_b', content: 'Prospero' }, + { id: 'choice_c', content: 'Capulet' }, +]; + +const LABELS = { + addLabel: addDistractorBtn$(), + listLabel: distractorsLabel$(), + chipLabel: number => editDistractorLabel$({ number }), + deleteLabel: number => deleteDistractorBtn$({ number }), +}; + +const renderList = (props = {}) => + render(EditableChipList, { + props: { chips: CHIPS, ...LABELS, ...props }, + routes: new VueRouter(), + }); + +// Applies every emit to its own data, the way an editor does, and closes the +// list the way an editor does when another editor opens. +const Host = { + components: { EditableChipList }, + template: ` + + chips.push({ id: 'choice_new', content: html })" + @update-chip="(index, html) => chips.splice(index, 1, { ...chips[index], content: html })" + @remove-chip="index => chips.splice(index, 1)" + /> + Elsewhere + + `, + props: { + initialChips: { type: Array, default: () => CHIPS }, + addMode: { type: String, default: 'button' }, + minChips: { type: Number, default: 0 }, + errorMessages: { type: Array, default: () => [] }, + }, + data() { + return { chips: [...this.initialChips], labels: LABELS }; + }, +}; + +const renderHost = (props = {}) => render(Host, { props, routes: new VueRouter() }); + +const list = () => screen.getByRole('list', { name: distractorsLabel$() }); +const chipItems = () => within(list()).queryAllByRole('listitem'); +const chipTexts = () => chipItems().map(item => item.textContent.trim()); +const button = name => screen.getByRole('button', { name }); +const queryButton = name => screen.queryByRole('button', { name }); +const openTextarea = () => screen.queryAllByRole('textbox').find(el => el.tagName === 'TEXTAREA'); +const closeFromElsewhere = user => user.click(button('Elsewhere')); + +describe('EditableChipList', () => { + describe('button mode', () => { + it('lists each chip in the named list', () => { + renderList(); + expect(chipTexts()).toEqual(['Antonio', 'Prospero', 'Capulet']); + }); + + it('opens a chip in place of its edit button, and reports its edits', async () => { + const user = userEvent.setup(); + const { emitted } = renderList(); + await user.click(button(editDistractorLabel$({ number: 2 }))); + expect(emitted().open).toHaveLength(1); + expect(queryButton(editDistractorLabel$({ number: 2 }))).not.toBeInTheDocument(); + expect(within(chipItems()[1]).getByRole('textbox')).toHaveValue('Prospero'); + + await user.type(openTextarea(), '!'); + expect(emitted()['update-chip'].at(-1)).toEqual([1, 'Prospero!']); + }); + + it('asks to remove a chip without also opening it', async () => { + const user = userEvent.setup(); + const { emitted } = renderList(); + await user.click(button(deleteDistractorBtn$({ number: 3 }))); + expect(emitted()['remove-chip']).toEqual([[2]]); + expect(emitted().open).toBeUndefined(); + }); + + it('writes a new chip outside the list, adding nothing until it is saved', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(addDistractorBtn$())); + expect(within(list()).queryByRole('textbox')).not.toBeInTheDocument(); + await user.type(openTextarea(), 'Montague'); + expect(chipTexts()).toHaveLength(3); + + await user.click(button(saveChipBtn$())); + expect(chipTexts().at(-1)).toBe('Montague'); + expect(openTextarea()).toBeUndefined(); + }); + + it('closes an open chip, keeping its edits, when its save button is pressed', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(editDistractorLabel$({ number: 2 }))); + await user.type(openTextarea(), '!'); + await user.click(within(chipItems()[1]).getByRole('button', { name: saveChipBtn$() })); + expect(openTextarea()).toBeUndefined(); + expect(chipTexts()).toEqual(['Antonio', 'Prospero!', 'Capulet']); + }); + + it('adds the written chip when its editor is closed without saving', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(addDistractorBtn$())); + await user.type(openTextarea(), 'Montague'); + await closeFromElsewhere(user); + expect(chipTexts().at(-1)).toBe('Montague'); + expect(openTextarea()).toBeUndefined(); + }); + + it('adds nothing when a new chip is closed blank', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(addDistractorBtn$())); + await user.click(button(saveChipBtn$())); + expect(chipTexts()).toHaveLength(3); + }); + + it('throws a written chip away when its discard button is pressed', async () => { + const user = userEvent.setup(); + const { emitted } = renderList(); + await user.click(button(addDistractorBtn$())); + await user.type(openTextarea(), 'x'); + await user.click(button(deleteDistractorBtn$({ number: 4 }))); + expect(openTextarea()).toBeUndefined(); + expect(emitted()['add-chip']).toBeUndefined(); + expect(emitted().close).toHaveLength(1); + }); + + it('commits the written chip and leaves a fresh, focused editor on a second add press', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(addDistractorBtn$())); + await user.type(openTextarea(), 'Montague'); + await user.click(button(addDistractorBtn$())); + expect(within(list()).getByText('Montague')).toBeInTheDocument(); + expect(openTextarea()).toHaveValue(''); + expect(openTextarea()).toHaveFocus(); + }); + + it('drops a chip left blank when its editor closes', async () => { + const user = userEvent.setup(); + renderHost({ initialChips: [...CHIPS, { id: 'choice_d', content: '' }] }); + await user.click(button(editDistractorLabel$({ number: 4 }))); + await closeFromElsewhere(user); + expect(chipTexts()).toEqual(['Antonio', 'Prospero', 'Capulet']); + }); + + it('opens the chip clicked after a blank one drops out ahead of it', async () => { + const user = userEvent.setup(); + renderHost({ initialChips: [{ id: 'choice_d', content: '' }, ...CHIPS] }); + await user.click(button(editDistractorLabel$({ number: 1 }))); + await user.click(button(editDistractorLabel$({ number: 3 }))); + expect(chipItems()).toHaveLength(3); + expect(within(chipItems()[1]).getByRole('textbox')).toHaveValue('Prospero'); + }); + + it('shows each error message under its own chip only', () => { + renderList({ errorMessages: [null, errorEmptyChoiceContent$(), null] }); + const [first, second, third] = chipItems(); + expect(within(second).getByText(errorEmptyChoiceContent$())).toBeInTheDocument(); + expect(within(first).queryByText(errorEmptyChoiceContent$())).not.toBeInTheDocument(); + expect(within(third).queryByText(errorEmptyChoiceContent$())).not.toBeInTheDocument(); + }); + + it('shows an open chip’s error under its editor', async () => { + const user = userEvent.setup(); + renderHost({ + initialChips: [{ id: 'choice_d', content: '' }], + minChips: 1, + errorMessages: [errorEmptyChoiceContent$()], + }); + await user.click(button(editDistractorLabel$({ number: 1 }))); + expect(within(chipItems()[0]).getByText(errorEmptyChoiceContent$())).toBeInTheDocument(); + }); + + it('shows the placeholder in a blank chip only', () => { + renderList({ + chips: [CHIPS[0], { id: 'choice_d', content: '' }], + placeholder: 'Write here', + }); + const [written, blank] = chipItems(); + expect(within(blank).getByText('Write here')).toBeInTheDocument(); + expect(within(written).queryByText('Write here')).not.toBeInTheDocument(); + }); + }); + + describe('keeping a minimum', () => { + it('disables every remove button once the list is down to its minimum', () => { + renderList({ chips: [CHIPS[0]], minChips: 1 }); + expect(button(deleteDistractorBtn$({ number: 1 }))).toBeDisabled(); + }); + + it('keeps a chip left blank when it is the last one the list must keep', async () => { + const user = userEvent.setup(); + renderHost({ initialChips: [{ id: 'choice_d', content: '' }], minChips: 1 }); + await user.click(button(editDistractorLabel$({ number: 1 }))); + await closeFromElsewhere(user); + expect(chipItems()).toHaveLength(1); + }); + + it('focuses the add control once the list reaches its minimum', async () => { + const user = userEvent.setup(); + renderHost({ initialChips: CHIPS.slice(0, 2), minChips: 1 }); + await user.click(button(deleteDistractorBtn$({ number: 1 }))); + expect(chipTexts()).toEqual(['Prospero']); + expect(button(addDistractorBtn$())).toHaveFocus(); + }); + }); + + describe('focus', () => { + it('focuses the delete button of the chip taking a removed chip’s place', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(deleteDistractorBtn$({ number: 2 }))); + expect(screen.queryByText('Prospero')).not.toBeInTheDocument(); + // Capulet now sits second. + expect(button(deleteDistractorBtn$({ number: 2 }))).toHaveFocus(); + }); + + it('focuses the chip taking a removed chip’s place after an open blank chip drops', async () => { + const user = userEvent.setup(); + renderHost({ + initialChips: [ + { id: 'choice_blank', content: '' }, + { id: 'choice_c', content: 'Capulet' }, + { id: 'choice_m', content: 'Montague' }, + { id: 'choice_l', content: 'Lysander' }, + ], + }); + await user.click(button(editDistractorLabel$({ number: 1 }))); + await user.click(button(deleteDistractorBtn$({ number: 2 }))); + expect(chipTexts()).toEqual(['Montague', 'Lysander']); + expect(button(deleteDistractorBtn$({ number: 1 }))).toHaveFocus(); + }); + + it('focuses the add button when the last chip is removed', async () => { + const user = userEvent.setup(); + renderHost({ initialChips: [CHIPS[0]] }); + await user.click(button(deleteDistractorBtn$({ number: 1 }))); + expect(chipItems()).toHaveLength(0); + expect(button(addDistractorBtn$())).toHaveFocus(); + }); + + it('focuses the add button when the new chip is discarded', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(addDistractorBtn$())); + await user.click(button(deleteDistractorBtn$({ number: 4 }))); + expect(openTextarea()).toBeUndefined(); + expect(button(addDistractorBtn$())).toHaveFocus(); + }); + + it('focuses the add button when the new chip is saved', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(addDistractorBtn$())); + await user.type(openTextarea(), 'Montague'); + await user.click(button(saveChipBtn$())); + expect(button(addDistractorBtn$())).toHaveFocus(); + }); + + it('focuses a saved chip', async () => { + const user = userEvent.setup(); + renderHost(); + await user.click(button(editDistractorLabel$({ number: 2 }))); + await user.click(button(saveChipBtn$())); + expect(button(editDistractorLabel$({ number: 2 }))).toHaveFocus(); + }); + }); + + describe('region mode', () => { + const region = () => button(addDistractorBtn$()); + const queryRegion = () => queryButton(addDistractorBtn$()); + + it('makes the box itself the add control, with no add button', () => { + renderList({ addMode: 'region' }); + expect(region()).toBeInTheDocument(); + // The add button shows its label as text; the region only names itself. + expect(screen.queryByText(addDistractorBtn$())).not.toBeInTheDocument(); + }); + + it('opens a new chip when the empty area of the list is clicked', async () => { + const user = userEvent.setup(); + const { emitted } = renderList({ addMode: 'region' }); + await user.click(list()); + expect(emitted().open).toHaveLength(1); + expect(openTextarea()).toHaveValue(''); + }); + + it('opens the first blank chip rather than a new one when the list is clicked', async () => { + const user = userEvent.setup(); + renderList({ + addMode: 'region', + chips: [CHIPS[0], { id: 'choice_d', content: '' }, { id: 'choice_e', content: '' }], + }); + await user.click(list()); + expect(within(chipItems()[1]).getByRole('textbox')).toHaveValue(''); + expect(chipItems()).toHaveLength(3); + expect(screen.queryAllByRole('textbox').filter(el => el.tagName === 'TEXTAREA')).toHaveLength( + 1, + ); + }); + + it('names the region after the blank chip it opens', () => { + renderList({ addMode: 'region', chips: [CHIPS[0], { id: 'choice_d', content: '' }] }); + expect(queryRegion()).not.toBeInTheDocument(); + // The chip's own button and the region that opens it + expect( + screen.getAllByRole('button', { name: editDistractorLabel$({ number: 2 }) }), + ).toHaveLength(2); + }); + + it('opens a new chip when Enter is pressed on the region', async () => { + const user = userEvent.setup(); + renderList({ addMode: 'region' }); + region().focus(); + await user.keyboard('{Enter}'); + expect(openTextarea()).toHaveValue(''); + }); + + it('opens only the chip when the chip is clicked', async () => { + const user = userEvent.setup(); + renderList({ addMode: 'region' }); + await user.click(button(editDistractorLabel$({ number: 1 }))); + expect(openTextarea()).toHaveValue('Antonio'); + }); + + it('asks only to remove a chip when its delete button is clicked', async () => { + const user = userEvent.setup(); + const { emitted } = renderList({ addMode: 'region' }); + await user.click(button(deleteDistractorBtn$({ number: 1 }))); + expect(emitted()['remove-chip']).toEqual([[0]]); + expect(openTextarea()).toBeUndefined(); + }); + + it('turns the region off while one of its editors is open', async () => { + const user = userEvent.setup(); + renderList({ addMode: 'region' }); + await user.click(list()); + expect(queryRegion()).not.toBeInTheDocument(); + await user.click(list()); + expect(openTextarea()).toHaveValue(''); + }); + + it('opens no new chip on a click inside an open chip', async () => { + const user = userEvent.setup(); + renderList({ addMode: 'region' }); + await user.click(button(editDistractorLabel$({ number: 1 }))); + await user.click(openTextarea()); + expect(openTextarea()).toHaveValue('Antonio'); + }); + + it('focuses the region when the last chip is removed', async () => { + const user = userEvent.setup(); + renderHost({ initialChips: [CHIPS[0]], addMode: 'region' }); + await user.click(button(deleteDistractorBtn$({ number: 1 }))); + expect(chipItems()).toHaveLength(0); + expect(region()).toHaveFocus(); + }); + + it('focuses the region when the new chip is discarded', async () => { + const user = userEvent.setup(); + renderHost({ addMode: 'region' }); + await user.click(list()); + await user.click(button(deleteDistractorBtn$({ number: 4 }))); + expect(openTextarea()).toBeUndefined(); + expect(region()).toHaveFocus(); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/index.vue new file mode 100644 index 0000000000..523f80ba23 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/EditableChipList/index.vue @@ -0,0 +1,535 @@ + + + + + + + + + + + {{ placeholder }} + + onChipUpdate(index, html)" + @minimize="close" + /> + + + + + + + + onRemove(index, event)" + /> + + + + + + {{ errorMessages[index] || NO_BREAK_SPACE }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 e5f25514dc..360d681956 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 @@ -8,6 +8,7 @@ import { CHOICE_SINGLE_SELECT_XML, CHOICE_MULTI_SELECT_XML, UNKNOWN_INTERACTION_XML, + MATCH_THREE_SETS_XML, mockInteractionBlock as interactionBlock, } from '../../../utils/testingFixtures'; @@ -16,7 +17,7 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { const { ref } = require('vue'); return { __esModule: true, - default: () => ({ windowIsSmall: ref(false) }), + default: () => ({ windowIsSmall: ref(false), windowIsLarge: ref(true) }), }; }); @@ -62,36 +63,43 @@ describe('InteractionSection', () => { renderSection({ interaction: interactionBlock('not-xml<{{') }); expect(screen.getByText(tr.$tr('errorParsingQuestion'))).toBeInTheDocument(); }); + + it('shows a parse error for a match interaction without exactly two match sets', () => { + renderSection({ interaction: interactionBlock(MATCH_THREE_SETS_XML) }); + expect(screen.getByText(tr.$tr('errorParsingQuestion'))).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: tr.$tr('addRowBtn') })).not.toBeInTheDocument(); + }); }); - describe('type switching', () => { - it('preserves the prompt but resets choices when switching from choice to text-entry', async () => { - const Wrapper = { - components: { InteractionSection }, - template: ` - - `, - data() { - return { - interactionBlock: { - bodyXml: CHOICE_SINGLE_SELECT_XML, - responseDeclarations: [], - }, - }; - }, - methods: { - onUpdate(val) { - this.interactionBlock = val; - this.$emit('wrapper-update', val); - }, + // Feeds each emitted interaction back in, as the item editor does. + const SwitchableSection = { + components: { InteractionSection }, + template: ` + + `, + data() { + return { + interactionBlock: { + bodyXml: CHOICE_SINGLE_SELECT_XML, + responseDeclarations: [], }, }; + }, + methods: { + onUpdate(val) { + this.interactionBlock = val; + this.$emit('wrapper-update', val); + }, + }, + }; - const { emitted } = render(Wrapper, { + 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(), }); @@ -114,6 +122,25 @@ describe('InteractionSection', () => { }); }); + describe('switching to match', () => { + it('produces a match item that reopens in the match editor', async () => { + const { emitted, unmount } = render(SwitchableSection, { routes: new VueRouter() }); + await nextTick(); + + await fireEvent.click(screen.getAllByText(tr.$tr('singleSelectLabel'))[0]); + await fireEvent.click(screen.getByText(tr.$tr('matchLabel'))); + 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(/ { it('falls back silently when the interaction tag is unrecognized', () => { // Should not throw — just renders the fallback component diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js index 1404d9753f..b74b9b405b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js @@ -13,6 +13,7 @@ import { CHOICE_ITEM_DOCUMENT_WITH_HINTS, NO_INTERACTION_ITEM_WITH_HINTS, VALID_ASSOCIATE_ITEM_DOCUMENT, + VALID_MATCH_ITEM_DOCUMENT, } from '../../../utils/testingFixtures'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); @@ -20,7 +21,7 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { const { ref } = require('vue'); return { __esModule: true, - default: () => ({ windowIsSmall: ref(false) }), + default: () => ({ windowIsSmall: ref(false), windowIsLarge: ref(true) }), }; }); @@ -31,6 +32,8 @@ const { incompleteItemIndicatorLabel$, hintsLabel$, associateLabel$, + matchLabel$, + questionNumberAndTypeLabel$, unknownTypeLabel$, responsePoolLabel$, } = qtiEditorStrings; @@ -318,6 +321,29 @@ describe('QTIItemEditor', () => { }); }); + describe('match interaction', () => { + const renderMatchItem = () => + renderComponent({ + item: { + assessment_id: 'test-item-id', + type: AssessmentItemTypes.QTI, + raw_data: VALID_MATCH_ITEM_DOCUMENT, + }, + }); + + test('names the match question type rather than falling back to unknown', async () => { + renderMatchItem(); + const heading = questionNumberAndTypeLabel$({ number: 1, total: 5, type: matchLabel$() }); + expect(await screen.findByText(heading)).toBeInTheDocument(); + }); + + test('renders the match editor for the parsed interaction', async () => { + renderMatchItem(); + expect(await screen.findByText(responsePoolLabel$())).toBeInTheDocument(); + expect(screen.getByText('Dog')).toBeInTheDocument(); + }); + }); + describe('toolbarActions slot', () => { test('renders content injected into the toolbarActions slot', () => { renderComponent({}, { toolbarActions: 'Edit' }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue index 8c36a87748..df8778b14d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -183,6 +183,7 @@ [QuestionType.FREE_RESPONSE]: qtiEditorStrings.freeResponseLabel$, [QuestionType.ORDERING]: qtiEditorStrings.orderingLabel$, [QuestionType.ASSOCIATE]: qtiEditorStrings.associateLabel$, + [QuestionType.MATCH]: qtiEditorStrings.matchLabel$, }; return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)(); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue index 68d4fcd265..c3b5391705 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue @@ -198,7 +198,9 @@ flex-wrap: wrap; gap: 5px; align-items: flex-start; - padding: 10px 20px; + + // The question card body already insets its content. + padding: 10px 0; &.small-screen { flex-direction: column; @@ -223,8 +225,9 @@ gap: 8px; } + // Matches the question label of every interaction editor. .group-label { - font-size: 12px; + font-size: 14px; font-weight: 600; line-height: 1.4; white-space: nowrap; 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..cd2a0b5d9a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/index.vue @@ -0,0 +1,145 @@ + + + + + {{ label }} + + + + + + + + + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useMatchInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useMatchInteraction.spec.js new file mode 100644 index 0000000000..818352695e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useMatchInteraction.spec.js @@ -0,0 +1,179 @@ +import { ref } from 'vue'; +import { useMatchInteraction } from '../useMatchInteraction'; +import { MATCH_XML, MATCH_DECL_XML } from '../../utils/testingFixtures'; +import { parseXML } from '../../serialization/xml'; +import { QuestionType } from '../../constants'; + +const GENERATED_CHOICE_ID = /^choice_[a-z0-9]{8}$/; +const GENERATED_ROW_ID = /^row_[a-z0-9]{8}$/; + +const contentsOf = rows => + rows.map(row => [row.content, row.matches.map(choice => choice.content)]); + +const maxAssociations = bodyXml => + parseXML(bodyXml).documentElement.getAttribute('max-associations'); + +describe('useMatchInteraction', () => { + function setup(bodyXml = MATCH_XML, declarationXml = MATCH_DECL_XML) { + const questionType = ref(QuestionType.MATCH); + return useMatchInteraction({ bodyXml, responseDeclarations: [declarationXml] }, questionType); + } + + describe('addRow()', () => { + it('appends a row with a blank prompt and one blank answer', () => { + const { state, addRow } = setup(); + addRow(); + expect(state.value.rows).toHaveLength(4); + const row = state.value.rows[3]; + expect(row.content).toBe(''); + expect(row.id).toMatch(GENERATED_ROW_ID); + expect(row.matches).toHaveLength(1); + expect(row.matches[0].content).toBe(''); + expect(row.matches[0].id).toMatch(GENERATED_CHOICE_ID); + }); + + it('rebuilds bodyXml with the new max-associations', () => { + const { bodyXml, addRow } = setup(); + addRow(); + expect(maxAssociations(bodyXml.value)).toBe('4'); + }); + }); + + describe('removeRow()', () => { + it('drops the row at the given index and keeps the rest in order', () => { + const { state, removeRow } = setup(); + removeRow(1); + expect(contentsOf(state.value.rows)).toEqual([ + ['Dog', ['Mammal']], + ['Frog', ['Amphibian']], + ]); + }); + + it('is a no-op when only one row remains', () => { + const { state, removeRow } = setup(); + removeRow(0); + removeRow(0); + removeRow(0); + expect(contentsOf(state.value.rows)).toEqual([['Frog', ['Amphibian']]]); + }); + + it("drops the row's values from the emitted correct response", () => { + const { responseDeclarations, removeRow } = setup(); + removeRow(0); + expect(responseDeclarations.value[0]).not.toContain('row_dog'); + }); + }); + + describe('setRowContent()', () => { + it('updates only the targeted row prompt', () => { + const { state, setRowContent } = setup(); + setRowContent(1, 'Hawk'); + expect(contentsOf(state.value.rows)).toEqual([ + ['Dog', ['Mammal']], + ['Hawk', ['Bird']], + ['Frog', ['Amphibian']], + ]); + }); + }); + + describe('addMatch()', () => { + it('appends a blank answer with a generated id to that row only', () => { + const { state, addMatch } = setup(); + addMatch(0); + expect(contentsOf(state.value.rows)).toEqual([ + ['Dog', ['Mammal', '']], + ['Eagle', ['Bird']], + ['Frog', ['Amphibian']], + ]); + expect(state.value.rows[0].matches[1].id).toMatch(GENERATED_CHOICE_ID); + }); + + it('appends the given content when the answer is written before it is added', () => { + const { state, addMatch } = setup(); + addMatch(2, 'Vertebrate'); + expect(state.value.rows[2].matches.map(c => c.content)).toEqual([ + 'Amphibian', + 'Vertebrate', + ]); + expect(state.value.rows[2].matches[1].id).toMatch(GENERATED_CHOICE_ID); + }); + + it('leaves a previously captured state untouched', () => { + const { state, addMatch } = setup(); + const before = state.value; + addMatch(0, 'Vertebrate'); + expect(contentsOf(before.rows)).toEqual([ + ['Dog', ['Mammal']], + ['Eagle', ['Bird']], + ['Frog', ['Amphibian']], + ]); + }); + }); + + describe('removeMatch()', () => { + it('drops only the targeted answer', () => { + const { state, addMatch, removeMatch } = setup(); + addMatch(0, 'Vertebrate'); + addMatch(1, 'Vertebrate'); + removeMatch(0, 0); + expect(contentsOf(state.value.rows)).toEqual([ + ['Dog', ['Vertebrate']], + ['Eagle', ['Bird', 'Vertebrate']], + ['Frog', ['Amphibian']], + ]); + }); + + it("is a no-op on a row's only answer", () => { + const { state, removeMatch } = setup(); + removeMatch(0, 0); + expect(contentsOf(state.value.rows)[0]).toEqual(['Dog', ['Mammal']]); + }); + }); + + describe('setMatchContent()', () => { + it('updates only the targeted answer', () => { + const { state, addMatch, setMatchContent } = setup(); + addMatch(0, 'Vertebrate'); + setMatchContent(0, 1, 'Canine'); + expect(contentsOf(state.value.rows)).toEqual([ + ['Dog', ['Mammal', 'Canine']], + ['Eagle', ['Bird']], + ['Frog', ['Amphibian']], + ]); + }); + }); + + describe('addDistractor()', () => { + it('appends one blank choice with a generated id', () => { + const { state, addDistractor } = setup(); + addDistractor(); + expect(state.value.distractors).toHaveLength(2); + expect(state.value.distractors[1].content).toBe(''); + expect(state.value.distractors[1].id).toMatch(GENERATED_CHOICE_ID); + }); + + it('appends the given content when the distractor is written before it is added', () => { + const { state, addDistractor } = setup(); + addDistractor('Fish'); + expect(state.value.distractors[1].content).toBe('Fish'); + expect(state.value.distractors[1].id).toMatch(GENERATED_CHOICE_ID); + }); + }); + + describe('removeDistractor()', () => { + it('drops the distractor at the given index', () => { + const { state, removeDistractor } = setup(); + removeDistractor(0); + expect(state.value.distractors).toEqual([]); + }); + }); + + describe('setDistractorContent()', () => { + it('updates only the targeted distractor', () => { + const { state, addDistractor, setDistractorContent } = setup(); + addDistractor(); + setDistractorContent(1, 'Updated'); + expect(state.value.distractors.map(d => d.content)).toEqual(['Reptile', 'Updated']); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useMatchInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useMatchInteraction.js new file mode 100644 index 0000000000..f38dc0b52f --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useMatchInteraction.js @@ -0,0 +1,104 @@ +import { readonly } from 'vue'; +import { matchInteractionDescriptor } from '../interactions/match/Descriptor'; +import { newChoice } from '../interactions/associate/parse'; +import { newRow } from '../interactions/match/parse'; +import { useInteraction } from './useInteraction'; + +/** + * Composable for the match interaction editor. + * + * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock + * @param {import('vue').Ref} questionType + */ +export function useMatchInteraction(interactionBlock, questionType) { + const base = useInteraction(matchInteractionDescriptor, interactionBlock, questionType); + const { state } = base; + + function updateRow(index, update) { + state.value = { + ...state.value, + rows: state.value.rows.map((row, i) => (i === index ? update(row) : row)), + }; + } + + function addRow() { + state.value = { ...state.value, rows: [...state.value.rows, newRow()] }; + } + + function removeRow(index) { + // A match question is meaningless without a row to match. + if (state.value.rows.length <= 1) return; + state.value = { + ...state.value, + rows: state.value.rows.filter((_, i) => i !== index), + }; + } + + function setRowContent(index, html) { + updateRow(index, row => ({ ...row, content: html })); + } + + function addMatch(rowIndex, content = '') { + updateRow(rowIndex, row => ({ ...row, matches: [...row.matches, newChoice(content)] })); + } + + function removeMatch(rowIndex, index) { + // A row always keeps an answer to match. + if (state.value.rows[rowIndex].matches.length <= 1) return; + updateRow(rowIndex, row => ({ + ...row, + matches: row.matches.filter((_, i) => i !== index), + })); + } + + function setMatchContent(rowIndex, index, html) { + updateRow(rowIndex, row => ({ + ...row, + matches: row.matches.map((choice, i) => + i === index ? { ...choice, content: html } : choice, + ), + })); + } + + function addDistractor(content = '') { + state.value = { + ...state.value, + distractors: [...state.value.distractors, newChoice(content)], + }; + } + + function removeDistractor(index) { + state.value = { + ...state.value, + distractors: state.value.distractors.filter((_, i) => i !== index), + }; + } + + function setDistractorContent(index, html) { + state.value = { + ...state.value, + distractors: state.value.distractors.map((choice, i) => + i === index ? { ...choice, content: html } : choice, + ), + }; + } + + function setPrompt(html) { + state.value = { ...state.value, prompt: html }; + } + + return { + ...base, + state: readonly(state), + addRow, + removeRow, + setRowContent, + addMatch, + removeMatch, + setMatchContent, + addDistractor, + removeDistractor, + setDistractorContent, + setPrompt, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index 22572b1467..3a4b941c21 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -84,6 +84,7 @@ export const QuestionType = Object.freeze({ FREE_RESPONSE: 'freeResponse', ORDERING: 'ordering', ASSOCIATE: 'associate', + MATCH: 'match', }); /** @@ -108,6 +109,11 @@ export const ValidationError = Object.freeze({ TOO_FEW_PAIRS: 'TOO_FEW_PAIRS', DUPLICATE_PAIR_CONTENT: 'DUPLICATE_PAIR_CONTENT', DUPLICATE_DISTRACTOR_CONTENT: 'DUPLICATE_DISTRACTOR_CONTENT', + EMPTY_ROW_CONTENT: 'EMPTY_ROW_CONTENT', + ROW_WITHOUT_MATCH: 'ROW_WITHOUT_MATCH', + TOO_FEW_ROWS: 'TOO_FEW_ROWS', + DUPLICATE_ROW_CONTENT: 'DUPLICATE_ROW_CONTENT', + DUPLICATE_MATCH_CONTENT: 'DUPLICATE_MATCH_CONTENT', }); export const RESPONSE_IDENTIFIER = 'RESPONSE'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js index 1f753c67cd..73a745dde6 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js @@ -1,6 +1,6 @@ import { descriptors, editors, registry, DEFAULT_INTERACTION } from '../index'; import { isInlineInteraction } from '../descriptors'; -import { Placement } from '../../constants'; +import { Placement, QtiInteraction } from '../../constants'; /** * An interaction is registered in two places: its descriptor in `descriptors.js` and its @@ -42,7 +42,7 @@ describe('interaction registry', () => { }); it('reports an interaction with no descriptor as not inline', () => { - expect(isInlineInteraction('qti-match-interaction')).toBe(false); + expect(isInlineInteraction(QtiInteraction.EXTENDED_TEXT)).toBe(false); }); }); }); 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..94df62cef0 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 @@ - + @@ -42,44 +39,17 @@ - - - - - {{ responsePoolLabel$() }} - - - - - - - - - + + + + - - - - - - setDistractorContent(index, html)" - @minimize="closeOpenTarget" - /> - - - - - - - - - {{ distractorErrorMessages[index] }} - - - - - - - - - - - - - - - - - + @@ -353,15 +238,16 @@ import { computed, nextTick, ref, watch } from 'vue'; import flatten from 'lodash/flatten'; import isEqual from 'lodash/isEqual'; - import shuffle from 'lodash/shuffle'; import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; - import { themeTokens, themePalette } from 'kolibri-design-system/lib/styles/theme'; + import { themeTokens } from 'kolibri-design-system/lib/styles/theme'; import { qtiEditorStrings } from '../../qtiEditorStrings'; import { ValidationError } from '../../constants'; import { useAssociateInteraction } from '../../composables/useAssociateInteraction'; import ValidationMessage from '../../components/ValidationMessage/index.vue'; import AddListItemButton from '../../components/AddListItemButton/index.vue'; import ClickableRegion from '../../components/ClickableRegion/index.vue'; + import ShuffledResponsePool from '../../components/ShuffledResponsePool/index.vue'; + import EditableChipList from '../../components/EditableChipList/index.vue'; import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText'; import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor'; import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService'; @@ -370,8 +256,7 @@ const OpenTarget = Object.freeze({ PROMPT: 'prompt', PAIR: 'pair', - DISTRACTOR: 'distractor', - DRAFT: 'draft', + DISTRACTORS: 'distractors', }); export default { @@ -382,12 +267,13 @@ ValidationMessage, AddListItemButton, ClickableRegion, + ShuffledResponsePool, + EditableChipList, }, setup(props, { emit }) { const { windowIsLarge, windowIsSmall } = useKResponsiveWindow(); const tokens = themeTokens(); - const palette = themePalette(); const { questionLabel$, @@ -431,28 +317,9 @@ // At most one card holds an open TipTap editor at a time. const openTarget = ref(null); - // The distractor being written, held out of state until its editor closes. - const draft = ref(null); - - // A TipTap editor takes focus only as it mounts, so bumping this key on every - // add press remounts the draft editor — patched in place it leaves focus behind. - const draftKey = ref(0); - - const rootEl = ref(null); const addPairButton = ref(null); - const addDistractorButton = ref(null); - - /** - * The press that removes a row unmounts its own delete button, dropping focus to - * the body. Hand focus to the row taking its place, or to the add button. - */ - async function focusAfterRemoval(deleteButtonSelector, index, addButton) { - await nextTick(); - const buttons = [...(rootEl.value?.querySelectorAll(deleteButtonSelector) ?? [])].filter( - button => !button.disabled, - ); - (buttons[Math.min(index, buttons.length - 1)] ?? addButton.value?.$el)?.focus(); - } + const deletePairButtons = ref([]); + const distractorList = ref(null); const isPromptOpen = computed( () => props.mode === 'edit' && openTarget.value?.kind === OpenTarget.PROMPT, @@ -474,36 +341,23 @@ return windowIsSmall.value || (!windowIsLarge.value && isPairRowOpen(index)); } - function isDistractorOpen(index) { - const target = openTarget.value; - return ( - props.mode === 'edit' && target?.kind === OpenTarget.DISTRACTOR && target.index === index - ); - } + const isDistractorsOpen = () => openTarget.value?.kind === OpenTarget.DISTRACTORS; - /** - * Closing a distractor's editor is what commits it: a written draft joins - * the pool, and a distractor left blank drops out of it. Either shifts the - * indexes that follow, so callers re-resolve the row they were holding. - */ + // The distractor list commits its own editor as it closes. function closeOpenTarget() { - const target = openTarget.value; + const wasDistractorsOpen = isDistractorsOpen(); openTarget.value = null; - if (target?.kind === OpenTarget.DRAFT) { - const { content } = draft.value; - draft.value = null; - if (hasRichTextContent(content)) addDistractor(content); - } else if (target?.kind === OpenTarget.DISTRACTOR) { - const choice = state.value.distractors[target.index]; - if (choice && !hasRichTextContent(choice.content)) removeDistractor(target.index); - } + if (wasDistractorsOpen) distractorList.value.close(); } - // Leaving edit mode throws the draft away rather than committing it: the - // parent stops listening for updates, so a commit would go unreported. - function discardDraft() { - openTarget.value = null; - draft.value = null; + function onDistractorsOpen() { + if (isDistractorsOpen()) return; + closeOpenTarget(); + openTarget.value = { kind: OpenTarget.DISTRACTORS }; + } + + function onDistractorsClose() { + if (isDistractorsOpen()) openTarget.value = null; } function openPrompt() { @@ -518,13 +372,6 @@ openTarget.value = { kind: OpenTarget.PAIR, index, position }; } - function openDistractor(choice) { - if (props.mode !== 'edit') return; - closeOpenTarget(); - const index = state.value.distractors.indexOf(choice); - if (index !== -1) openTarget.value = { kind: OpenTarget.DISTRACTOR, index }; - } - function setPairItemContent(index, position, html) { const pair = state.value.pairs[index].map((choice, i) => i === position ? { ...choice, content: html } : choice, @@ -537,44 +384,27 @@ openPairItem(state.value.pairs.length - 1, 0); } - function onAddDistractor() { - if (props.mode !== 'edit') return; - closeOpenTarget(); - draftKey.value += 1; - draft.value = { content: '' }; - openTarget.value = { kind: OpenTarget.DRAFT }; - } - - function setDraftContent(html) { - draft.value = { content: html }; - } - // Open targets are held by index, so a deletion shifts the ones after it — - // close the editor rather than let it land on a different item. - function onRemovePair(index) { + // close the editor rather than let it land on a different item. The press + // unmounts its own delete button, so focus moves to the pair taking its place. + async function onRemovePair(index) { closeOpenTarget(); removePair(index); - focusAfterRemoval('.pair-actions button', index, addPairButton); + await nextTick(); + if (isOnlyPair.value) { + addPairButton.value.$el.focus(); + } else { + deletePairButtons.value[Math.min(index, state.value.pairs.length - 1)].$el.focus(); + } } - function onRemoveDistractor(choice) { - const index = state.value.distractors.indexOf(choice); - if (index === -1) return; - // Closing drops a distractor left blank, so this one may already be gone — - // and the rest have shifted. Re-resolve before removing, but still move - // focus off the button this press is unmounting. + function onRemoveDistractor(index) { closeOpenTarget(); - const remaining = state.value.distractors.indexOf(choice); - if (remaining !== -1) removeDistractor(remaining); - focusAfterRemoval('.distractor-actions button', index, addDistractorButton); + removeDistractor(index); } - // Separate from discardDraft, which also runs on leaving edit mode — moving focus - // there would pull it back into a section that is being unmounted. - function onDiscardDraft() { - discardDraft(); - addDistractorButton.value?.$el?.focus(); - } + const distractorChipLabel = number => editDistractorLabel$({ number }); + const distractorDeleteLabel = number => deleteDistractorBtn$({ number }); const workingInteraction = computed(() => ({ bodyXml: bodyXml.value, @@ -584,8 +414,11 @@ watch( () => props.mode, newMode => { + // Leaving edit mode unmounts the distractor list, throwing a draft away + // rather than committing it: the parent stops listening for updates, so + // a commit would go unreported. if (newMode !== 'edit') { - discardDraft(); + openTarget.value = null; return; } if (!hasRichTextContent(state.value.prompt)) { @@ -684,13 +517,6 @@ ), ); - // An open distractor is bordered by its own editor, so the row adds none. - const distractorStyles = computed(() => - state.value.distractors.map((choice, index) => - isDistractorOpen(index) ? {} : borderStyle(choiceHasError(choice)), - ), - ); - const promptWrapperClass = computed(() => isPromptOpen.value ? 'prompt-wrapper' @@ -721,26 +547,19 @@ // One option per association the learner can make: the emitted XML folds // choices holding the same answer into a single option whose match-max is // the number of occurrences walked here. - const shuffledPool = computed(() => - shuffle([...flatten(state.value.pairs), ...state.value.distractors]), - ); - - const poolChipStyles = computed(() => - shuffledPool.value.map(choice => { - const isCorrect = - props.showAnswers && pairedTexts.value.has(richTextComparisonKey(choice.content)); - return { - borderColor: isCorrect ? palette.green.v_600 : tokens.fineLine, - backgroundColor: isCorrect ? palette.green.v_50 : null, - }; - }), + const poolChoices = computed(() => + [...flatten(state.value.pairs), ...state.value.distractors].map(choice => ({ + content: choice.content, + isCorrect: + props.showAnswers && pairedTexts.value.has(richTextComparisonKey(choice.content)), + })), ); return { EditorImageProcessor, - rootEl, addPairButton, - addDistractorButton, + deletePairButtons, + distractorList, state, isPromptOpen, promptWrapperClass, @@ -749,30 +568,26 @@ closeOpenTarget, isPairItemOpen, isPairRowStacked, - isDistractorOpen, + distractorChipLabel, + distractorDeleteLabel, openPairItem, - openDistractor, + onDistractorsOpen, + onDistractorsClose, setPairItemContent, + addDistractor, setDistractorContent, setPrompt, onRemovePair, onRemoveDistractor, onAddPair, - onAddDistractor, - draft, - draftKey, - setDraftContent, - onDiscardDraft, promptHasError, tooFewPairsError, pairErrorMessages, distractorErrorMessages, pairItemStyles, - distractorStyles, isOnlyPair, pairsSectionLabel, - shuffledPool, - poolChipStyles, + poolChoices, questionLabel$, editQuestionLabel$, errorPromptRequired$, @@ -784,9 +599,7 @@ addPairBtn$, deletePairBtn$, addDistractorBtn$, - deleteDistractorBtn$, editPairItemLabel$, - editDistractorLabel$, errorTooFewPairs$, }; }, @@ -955,44 +768,30 @@ // A pair card sits on the tinted row, so it carries the surface colour itself. // Its editor stretches with it: a card pulled taller by the one beside it - // would otherwise show the editor's own bottom border above its own. + // would otherwise show the editor's own bottom border above its own. As a + // grid, the card stretches the slot wrapper inside it to its own height. Its + // one track may shrink below a long formula, leaving the editor to clip it. .pair-card { - display: flex; + display: grid; flex: 1; - flex-direction: column; + grid-template-columns: minmax(0, 1fr); min-width: 0; background-color: v-bind('$themeTokens.surface'); - ::v-deep .content-wrapper { - display: flex; - flex: 1; - flex-direction: column; - } - .editor { flex: 1; } .item-card-text { display: flex; - flex: 1; flex-direction: column; + height: 100%; padding: 0; &.is-closed { min-height: 40px; - - // A closed card shows one line, so paragraph margins would only push it - // past that height. - ::v-deep .ProseMirror p { - margin: 0; - } } } - - ::v-deep .editor-content { - padding: 8px; - } } .pair-actions { @@ -1009,30 +808,7 @@ margin-top: 8px; } - .pool-box { - display: flex; - flex-direction: column; - gap: 8px; - padding: 12px; - border: 1px solid; - border-radius: 4px; - - &.is-shuffled { - background-color: v-bind('$themePalette.grey.v_50'); - } - } - - .chip-list { - display: flex; - flex-wrap: wrap; - gap: 8px; - padding: 0; - margin: 0; - list-style: none; - } - - // A chip is a compact pill, so it replaces the editor's own content padding - // and paragraph margins with its own. + // A chip is a compact pill, so its editor has no padding of its own. .chip { display: flex; gap: 8px; @@ -1043,78 +819,6 @@ background-color: v-bind('$themeTokens.surface'); border: 1px solid; border-radius: 8px; - - // Tinted only in the distractor pool, whose box sits on the surface; the - // shuffled pool is itself tinted, so its chips stay on the surface. - &.is-tinted { - background-color: v-bind('$themePalette.grey.v_50'); - } - - ::v-deep .editor-content { - padding: 0; - } - - ::v-deep .ProseMirror p { - margin: 0; - } - } - - .draft-row { - display: flex; - gap: 8px; - align-items: center; - } - - // The open editor draws its own border, so the row it sits in adds none. - .draft-editor { - flex: 1; - min-width: 0; - background-color: v-bind('$themeTokens.surface'); - } - - // Stacks the chip over the validation message that belongs to it, so a - // flagged distractor pushes the chips after it along rather than down. - .distractor { - display: flex; - flex-direction: column; - gap: 4px; - max-width: 100%; - - &.is-editing { - flex-basis: 100%; - } - } - - // The chip itself is the clickable region, so hover, focus ring and radius all - // follow the chip's own edge. - .distractor-row { - display: flex; - align-items: center; - - &.is-clickable { - cursor: pointer; - transition: background-color 0.3s; - - &:hover { - background-color: v-bind('$themeTokens.fineLine'); - } - } - - ::v-deep .content-wrapper { - display: flex; - flex: 1; - gap: 8px; - align-items: center; - min-width: 0; - } - } - - // A blank choice renders nothing, so without a floor the chip collapses to a - // strip too small to click. - .distractor-content { - flex: 1; - min-width: 24px; - min-height: 24px; } .editor { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Editor.spec.js index c35d630b3d..5cf9249e90 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Editor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Editor.spec.js @@ -385,6 +385,20 @@ describe('AssociateEditor', () => { expect(screen.getAllByText('Prospero')).toHaveLength(1); }); + it('keeps the pool order across a round trip through edit mode', async () => { + const { updateProps } = renderEditor(viewProps); + const order = () => + within(screen.getByRole('list', { name: tr.$tr('responsePoolLabel') })) + .getAllByRole('listitem') + .map(el => el.textContent.trim()); + const initial = order(); + for (let i = 0; i < 3; i++) { + await updateProps({ mode: 'edit' }); + await updateProps({ mode: 'view' }); + expect(order()).toEqual(initial); + } + }); + it('hides the editing controls', () => { renderEditor(viewProps); expect(queryButton(tr.$tr('addPairBtn'))).not.toBeInTheDocument(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js index 3d296148ff..dbdfce7a4b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js @@ -3,6 +3,7 @@ import { choiceInteractionDescriptor } from './choice/Descriptor'; import { textEntryInteractionDescriptor } from './textEntry/Descriptor'; import { orderingInteractionDescriptor } from './ordering/Descriptor'; import { associateInteractionDescriptor } from './associate/Descriptor'; +import { matchInteractionDescriptor } from './match/Descriptor'; /** * Every interaction's descriptor: matching, parsing, building and validating XML. @@ -28,6 +29,7 @@ export const descriptors = [ textEntryInteractionDescriptor, orderingInteractionDescriptor, associateInteractionDescriptor, + matchInteractionDescriptor, ]; /** diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js index 1db98e2ce8..a20d0958f5 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js @@ -3,6 +3,7 @@ import ChoiceEditor from './choice/Editor.vue'; import TextEntryEditor from './textEntry/Editor.vue'; import OrderingEditor from './ordering/Editor.vue'; import AssociateEditor from './associate/Editor.vue'; +import MatchEditor from './match/Editor.vue'; /** * Entry point for the editor tree: the descriptors, plus the Vue component that edits each @@ -17,6 +18,7 @@ export const editors = Object.freeze({ [QtiInteraction.TEXT_ENTRY]: TextEntryEditor, [QtiInteraction.ORDER]: OrderingEditor, [QtiInteraction.ASSOCIATE]: AssociateEditor, + [QtiInteraction.MATCH]: MatchEditor, }); export { DEFAULT_INTERACTION, descriptors, registry } from './descriptors'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js new file mode 100644 index 0000000000..2318b037f0 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js @@ -0,0 +1,86 @@ +import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; +import { InteractionDescriptor } from '../InteractionDescriptor'; +import { parseMatchInteraction, buildMatchInteractionXML, hasTwoMatchSets } from './parse'; +import { validateMatchInteraction } from './validation'; + +/** + * Owns all match-specific interaction logic: schema, parse, buildXML, and validate. + */ +export class MatchInteractionDescriptor extends InteractionDescriptor { + constructor() { + super({ + type: QtiInteraction.MATCH, + questionTypes: [QuestionType.MATCH], + }); + this.convertsFrom = []; + } + + getTypeOptions(tr) { + return [ + { + value: QuestionType.MATCH, + label: tr.matchLabel$(), + description: tr.matchDescription$(), + }, + ]; + } + + /** + * Match always has exactly one question type. Throws on a shape the editor cannot + * represent, which resolveDescriptor reports as a parse error. + * + * @param {Element} el + * @returns {string} + */ + getQuestionType(el) { + if (!hasTwoMatchSets(el)) { + throw new Error('qti-match-interaction must have exactly two qti-simple-match-set'); + } + return QuestionType.MATCH; + } + + /** + * @returns {{ baseType: string, cardinality: string }} + */ + getResponseDeclarationSchema() { + return { + baseType: BaseType.DIRECTED_PAIR, + cardinality: Cardinality.MULTIPLE, + }; + } + + /** + * Parse body XML + response declarations → MatchState. + * + * @param {string} bodyXml + * @param {string[]} responseDeclarations + * @returns {object} MatchState + */ + parse(bodyXml, responseDeclarations) { + return parseMatchInteraction(bodyXml, responseDeclarations); + } + + /** + * Serialize MatchState → { bodyXml, responseDeclarations }. + * + * @param {object} state - MatchState + * @param {string} questionType + * @returns {{ bodyXml: string, responseDeclarations: string[] }} + */ + buildXML(state, questionType) { + return buildMatchInteractionXML(state, questionType, this.getResponseDeclarationSchema()); + } + + /** + * Validate MatchState → ValidationError[]. + * + * @param {object} state - MatchState + * @returns {Array<{ code: string, id?: string, index?: number, text?: string }>} + */ + validate(state) { + return validateMatchInteraction(state); + } +} + +/** Singleton — safe to import from any file in the match module tree. */ +export const matchInteractionDescriptor = new MatchInteractionDescriptor(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue new file mode 100644 index 0000000000..9206fb8950 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue @@ -0,0 +1,1010 @@ + + + + + + + {{ errorPromptRequired$() }} + + + {{ questionLabel$() }} + + + + + + + + + + + + + + + + + {{ errorTooFewRows$() }} + + + + + {{ matchingRowsLabel$() }} + + + {{ matchingRowsDescription$() }} + + + + + + {{ promptColumnLabel$() }} + {{ answersColumnLabel$() }} + + + + + + + {{ promptColumnLabel$() }} + + + {{ answersColumnLabel$() }} + + + + + + + + {{ rowPromptPlaceholder$() }} + + setRowContent(index, html)" + @minimize="closeOpenTarget" + /> + + + + addMatch(index, html)" + @update-chip="(position, html) => setMatchContent(index, position, html)" + @remove-chip="position => onRemoveMatch(index, position)" + /> + + + + + + + + + + + + + + + + + + + + {{ message }} + + + + + + + + + + + + + + + + {{ distractorsLabel$() }} + + + {{ distractorsDescription$() }} + + + + + + + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/Descriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/Descriptor.spec.js new file mode 100644 index 0000000000..004aa3c231 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/Descriptor.spec.js @@ -0,0 +1,12 @@ +import { matchInteractionDescriptor as descriptor } from '../Descriptor'; +import { QuestionType } from '../../../constants'; +import { MATCH_XML, MATCH_DECL_XML } from '../../../utils/testingFixtures'; + +describe('MatchInteractionDescriptor', () => { + it('buildXML() forwards its own declaration schema', () => { + const state = descriptor.parse(MATCH_XML, [MATCH_DECL_XML]); + const [declXml] = descriptor.buildXML(state, QuestionType.MATCH).responseDeclarations; + expect(declXml).toContain('base-type="directedPair"'); + expect(declXml).toContain('cardinality="multiple"'); + }); +}); 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 new file mode 100644 index 0000000000..3abed66ea3 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/Editor.spec.js @@ -0,0 +1,946 @@ +import { fireEvent, render, screen, 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 { + MATCH_XML, + MATCH_DECL_XML, + mockInteractionBlock as block, + mockInteractionBlockWithDecl as blockWithDecl, +} from '../../../utils/testingFixtures'; +import { QuestionType, ValidationError } from '../../../constants'; +import { qtiEditorStrings as tr } from '../../../qtiEditorStrings'; + +jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); + +let mockWindowIsLarge = true; +let mockWindowIsSmall = false; +jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { + const { ref } = require('vue'); + return { + __esModule: true, + default: () => ({ + windowIsLarge: ref(mockWindowIsLarge), + windowIsSmall: ref(mockWindowIsSmall), + }), + }; +}); + +const ONE_ROW_DECL_XML = ` + + row_dog choice_mammal + +`; + +// The second response is named by no value, so it is a blank distractor. +const BLANK_DISTRACTOR_XML = ` + Match each animal to its class. + + Dog + + + Mammal + + +`; + +const IMAGE_ANSWER_XML = ` + Match each animal to its class. + + Dog + + + + +`; + +// Mammal serves both rows; Reptile serves none. +const SHARED_ANSWER_XML = ` + Match each animal to its class. + + Dog + Cat + + + Mammal + Reptile + +`; + +const SHARED_ANSWER_DECL_XML = ` + + row_dog choice_mammal + row_cat choice_mammal + +`; + +const BLANK_ANSWER_XML = ` + Match each animal to its class. + + Dog + + + + +`; + +// The mock TipTapEditor renders a only for the card currently open. +const openTextareas = () => + screen.queryAllByRole('textbox').filter(el => el.tagName === 'TEXTAREA'); +const openTextarea = () => openTextareas()[0]; + +const button = name => screen.getByRole('button', { name }); +const queryButton = name => screen.queryByRole('button', { name }); + +const editRow = number => tr.$tr('editRowLabel', { number }); +const deleteRow = number => tr.$tr('deleteRowBtn', { number }); +const editMatch = (number, position) => tr.$tr('editMatchLabel', { number, position }); +const deleteMatch = (number, position) => tr.$tr('deleteMatchBtn', { number, position }); +const addMatch = number => tr.$tr('addMatchLabel', { number }); + +const rowsList = () => screen.getByRole('list', { name: tr.$tr('matchingRowsLabel') }); +// Each row holds its own answer list, so only the outer list's direct items are rows. +const rows = () => { + const list = rowsList(); + return within(list) + .getAllByRole('listitem') + .filter(el => el.parentElement === list); +}; +const rowAnswers = number => + screen.getByRole('list', { name: tr.$tr('rowAnswersLabel', { number }) }); +const answerItems = number => within(rowAnswers(number)).queryAllByRole('listitem'); +const distractorList = () => screen.getByRole('list', { name: tr.$tr('distractorsLabel') }); + +const renderEditor = (props = {}) => + render(MatchEditor, { + props: { + mode: 'edit', + questionType: QuestionType.MATCH, + interaction: blockWithDecl(MATCH_XML, MATCH_DECL_XML), + ...props, + }, + routes: new VueRouter(), + }); + +// Writes a new answer into a row, committing it by opening the question prompt. +const writeAnswer = async (user, rowNumber, text) => { + await user.click(rowAnswers(rowNumber)); + await user.type(openTextarea(), text); + await user.click(button(tr.$tr('editQuestionLabel'))); +}; + +const updates = emitted => emitted()['update:interaction'] || []; +const latestBodyXml = emitted => updates(emitted).at(-1)[0].bodyXml; +const latestErrorCodes = emitted => + emitted() + ['update:errors'].at(-1)[0] + .map(e => e.code); + +describe('MatchEditor', () => { + beforeEach(() => { + mockWindowIsLarge = true; + mockWindowIsSmall = false; + }); + + describe('edit mode rendering', () => { + it('renders the prompt text from the XML', () => { + renderEditor(); + expect(screen.getByText(/Match each animal/)).toBeInTheDocument(); + }); + + it('renders every row prompt from the XML', () => { + renderEditor(); + for (const content of ['Dog', 'Eagle', 'Frog']) { + expect(within(rowsList()).getByText(content)).toBeInTheDocument(); + } + expect(rows()).toHaveLength(3); + }); + + it('numbers no row as a pair', () => { + renderEditor(); + expect(screen.queryByText(tr.$tr('pairNumberLabel', { number: 1 }))).not.toBeInTheDocument(); + }); + + it('opens the first row prompt for editing when the question prompt is written', () => { + renderEditor(); + expect(queryButton(editRow(1))).not.toBeInTheDocument(); + expect(openTextarea()).toHaveValue('Dog'); + }); + + it('shows a placeholder in a blank row prompt until it opens', async () => { + const user = userEvent.setup(); + renderEditor({ interaction: block('') }); + const placeholder = tr.$tr('rowPromptPlaceholder'); + expect(within(rows()[0]).getByText(placeholder)).toBeInTheDocument(); + await user.click(button(editRow(1))); + expect(within(rows()[0]).queryByText(placeholder)).not.toBeInTheDocument(); + }); + + it('opens the question prompt for editing when it is blank', () => { + renderEditor({ interaction: block('') }); + expect(queryButton(tr.$tr('editQuestionLabel'))).not.toBeInTheDocument(); + expect(button(editRow(1))).toBeInTheDocument(); + }); + }); + + describe('editing content', () => { + it('moves the open editor to the row prompt that is clicked', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(editRow(2))); + expect(openTextarea()).toHaveValue('Eagle'); + expect(button(editRow(1))).toBeInTheDocument(); + }); + + it('writes the typed row prompt into the emitted XML', async () => { + const user = userEvent.setup(); + const { emitted } = renderEditor(); + await user.click(button(editRow(3))); + await user.type(openTextarea(), 'let'); + expect(latestBodyXml(emitted)).toContain('Froglet'); + }); + + it('writes the typed question prompt into the emitted XML', async () => { + const user = userEvent.setup(); + const { emitted } = renderEditor(); + await user.click(button(tr.$tr('editQuestionLabel'))); + await user.type(openTextarea(), 'Which class?'); + expect(latestBodyXml(emitted)).toContain('Which class?'); + }); + }); + + describe('rows', () => { + it('appends a row and opens its prompt when add row is clicked', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(tr.$tr('addRowBtn'))); + expect(rows()).toHaveLength(4); + expect(queryButton(editRow(4))).not.toBeInTheDocument(); + expect(openTextarea()).toHaveValue(''); + }); + + it('removes the row when its delete button is clicked', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(deleteRow(1))); + expect(screen.queryByText('Dog')).not.toBeInTheDocument(); + expect(rows()).toHaveLength(2); + }); + + it('disables the delete button when a single row remains', () => { + renderEditor({ interaction: block('') }); + expect(button(deleteRow(1))).toBeDisabled(); + }); + + it('keeps the last row when its delete button is clicked', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(deleteRow(1))); + await user.click(button(deleteRow(1))); + await user.click(button(deleteRow(1))); + expect(rows()).toHaveLength(1); + expect(screen.getByText('Frog')).toBeInTheDocument(); + }); + }); + + describe('row layout below the large breakpoint', () => { + it('stacks only the row whose editor is open', async () => { + mockWindowIsLarge = false; + const user = userEvent.setup(); + // Row 1's prompt opens on mount. + renderEditor(); + const [first, second] = rows(); + expect(first).toHaveClass('is-stacked'); + expect(second).not.toHaveClass('is-stacked'); + + await user.click(button(editRow(2))); + expect(first).not.toHaveClass('is-stacked'); + expect(second).toHaveClass('is-stacked'); + }); + + it('stacks no row on a large screen', () => { + renderEditor(); + for (const row of rows()) { + expect(row).not.toHaveClass('is-stacked'); + } + }); + + it('stacks every row on a small screen, open or not', () => { + mockWindowIsLarge = false; + mockWindowIsSmall = true; + renderEditor(); + for (const row of rows()) { + expect(row).toHaveClass('is-stacked'); + } + }); + + it('heads the two columns once above the rows while they sit side by side', () => { + renderEditor(); + expect(screen.getAllByText(tr.$tr('promptColumnLabel'))).toHaveLength(1); + expect(screen.getAllByText(tr.$tr('answersColumnLabel'))).toHaveLength(1); + expect(within(rowsList()).queryByText(tr.$tr('promptColumnLabel'))).not.toBeInTheDocument(); + }); + + it('labels the prompt and answers within each stacked row instead', () => { + mockWindowIsLarge = false; + mockWindowIsSmall = true; + renderEditor(); + for (const row of rows()) { + expect(within(row).getByText(tr.$tr('promptColumnLabel'))).toBeInTheDocument(); + expect(within(row).getByText(tr.$tr('answersColumnLabel'))).toBeInTheDocument(); + } + expect(screen.getAllByText(tr.$tr('promptColumnLabel'))).toHaveLength(rows().length); + }); + }); + + describe('row answers', () => { + it("lists each row's answers under that row", () => { + renderEditor(); + expect(answerItems(1)).toHaveLength(1); + expect(within(rowAnswers(1)).getByText('Mammal')).toBeInTheDocument(); + expect(within(rowAnswers(2)).getByText('Bird')).toBeInTheDocument(); + expect(within(rowAnswers(3)).getByText('Amphibian')).toBeInTheDocument(); + }); + + it('opens an answer when its chip is clicked and writes the typing into the XML', async () => { + const user = userEvent.setup(); + const { emitted } = renderEditor(); + await user.click(button(editMatch(2, 1))); + expect(openTextarea()).toHaveValue('Bird'); + await user.type(openTextarea(), 's'); + expect(latestBodyXml(emitted)).toContain('Birds'); + }); + + it('removes the answer when its delete button is clicked', async () => { + const user = userEvent.setup(); + renderEditor(); + await writeAnswer(user, 1, 'Canine'); + await user.click(button(deleteMatch(1, 1))); + expect(screen.queryByText('Mammal')).not.toBeInTheDocument(); + expect(answerItems(1)).toHaveLength(1); + }); + + it("disables the delete button of a row's only answer", () => { + renderEditor(); + expect(button(deleteMatch(1, 1))).toBeDisabled(); + }); + + it('shows a placeholder in a blank answer', () => { + renderEditor({ interaction: blockWithDecl(BLANK_ANSWER_XML, ONE_ROW_DECL_XML) }); + expect(within(rowAnswers(1)).getByText(tr.$tr('answerPlaceholder'))).toBeInTheDocument(); + }); + + it("opens an empty draft, without adding a chip, when the row's answer area is clicked", async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(rowAnswers(2)); + expect(openTextarea()).toHaveValue(''); + expect(answerItems(2)).toHaveLength(1); + }); + + it('adds the written draft to its row when Save is pressed', async () => { + const user = userEvent.setup(); + const { emitted } = renderEditor(); + await user.click(rowAnswers(2)); + await user.type(openTextarea(), 'Aves'); + expect(answerItems(2)).toHaveLength(1); + await user.click(button(tr.$tr('saveChipBtn'))); + + expect(openTextarea()).toBeUndefined(); + expect(within(rowAnswers(2)).getByText('Aves')).toBeInTheDocument(); + expect(latestBodyXml(emitted)).toContain('Aves'); + }); + + it('adds the written draft to its row when another editor opens', async () => { + const user = userEvent.setup(); + const { emitted } = renderEditor(); + await user.click(rowAnswers(2)); + await user.type(openTextarea(), 'Aves'); + await user.click(button(editRow(1))); + + expect(within(rowAnswers(2)).getByText('Aves')).toBeInTheDocument(); + expect(answerItems(2)).toHaveLength(2); + expect(latestBodyXml(emitted)).toContain('Aves'); + }); + + it('drops an empty draft when another editor opens', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(rowAnswers(2)); + await user.click(button(editRow(1))); + expect(answerItems(2)).toHaveLength(1); + }); + + it('opens a draft from the keyboard', async () => { + const user = userEvent.setup(); + renderEditor(); + button(addMatch(2)).focus(); + await user.keyboard('{Enter}'); + expect(openTextarea()).toHaveValue(''); + expect(button(deleteMatch(2, 2))).toBeInTheDocument(); + }); + + it('keeps an answer open, and opens no draft, when its editor is clicked', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(editMatch(1, 1))); + await user.click(openTextarea()); + expect(openTextareas()).toHaveLength(1); + expect(openTextarea()).toHaveValue('Mammal'); + }); + + it('drops an answer left blank when its editor closes', async () => { + const user = userEvent.setup(); + renderEditor(); + await writeAnswer(user, 1, 'Canine'); + await user.click(button(editMatch(1, 1))); + await user.clear(openTextarea()); + await user.click(button(editRow(2))); + expect(answerItems(1)).toHaveLength(1); + expect(within(rowAnswers(1)).getByText('Canine')).toBeInTheDocument(); + }); + + it("keeps a row's only answer when its editor closes blank", async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(tr.$tr('addRowBtn'))); + // The row's answer area is named after its blank answer too. + await user.click(within(rowAnswers(4)).getByRole('button', { name: editMatch(4, 1) })); + await user.click(button(editRow(1))); + expect(answerItems(4)).toHaveLength(1); + }); + + it('keeps an image-only answer when its editor closes', async () => { + const user = userEvent.setup(); + renderEditor({ interaction: blockWithDecl(IMAGE_ANSWER_XML, ONE_ROW_DECL_XML) }); + await user.click(button(editMatch(1, 1))); + await user.click(button(editRow(1))); + expect(answerItems(1)).toHaveLength(1); + }); + }); + + describe('adding a distractor', () => { + // The fixture holds one distractor, so a new one is number 2. + const removeNewDistractor = tr.$tr('deleteDistractorBtn', { number: 2 }); + + it('opens an empty editor with a remove button, without adding a chip', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(tr.$tr('addDistractorBtn'))); + expect(openTextarea()).toHaveValue(''); + expect(button(removeNewDistractor)).toBeInTheDocument(); + expect(within(distractorList()).getAllByRole('listitem')).toHaveLength(1); + }); + + it('adds the written distractor to the pool when its editor closes', async () => { + const user = userEvent.setup(); + const { emitted } = renderEditor(); + await user.click(button(tr.$tr('addDistractorBtn'))); + await user.type(openTextarea(), 'Fish'); + await user.click(button(editRow(1))); + + expect(within(distractorList()).getByText('Fish')).toBeInTheDocument(); + expect(latestBodyXml(emitted)).toContain('Fish'); + }); + + it('drops the new distractor when its editor closes with no content', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(tr.$tr('addDistractorBtn'))); + await user.click(button(editRow(1))); + expect(within(distractorList()).getAllByRole('listitem')).toHaveLength(1); + }); + }); + + describe('existing distractors', () => { + it('opens a distractor for editing when its chip is clicked', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(tr.$tr('editDistractorLabel', { number: 1 }))); + expect(openTextarea()).toHaveValue('Reptile'); + }); + + it('opens a distractor whose content is blank', async () => { + const user = userEvent.setup(); + renderEditor({ interaction: blockWithDecl(BLANK_DISTRACTOR_XML, ONE_ROW_DECL_XML) }); + await user.click(button(tr.$tr('editDistractorLabel', { number: 1 }))); + expect(queryButton(tr.$tr('editDistractorLabel', { number: 1 }))).not.toBeInTheDocument(); + expect(openTextarea()).toHaveValue(''); + }); + + it('writes edited distractor content into the emitted XML', async () => { + const user = userEvent.setup(); + const { emitted } = renderEditor(); + await user.click(button(tr.$tr('editDistractorLabel', { number: 1 }))); + await user.type(openTextarea(), 's'); + expect(latestBodyXml(emitted)).toContain('Reptiles'); + }); + + it('removes the distractor when its delete button is clicked', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(tr.$tr('deleteDistractorBtn', { number: 1 }))); + expect(screen.queryByText('Reptile')).not.toBeInTheDocument(); + }); + + it('closes the open editor when an earlier distractor is deleted', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(tr.$tr('addDistractorBtn'))); + await user.type(openTextarea(), 'Fish'); + await user.click(button(editRow(1))); + await user.click(button(tr.$tr('editDistractorLabel', { number: 2 }))); + expect(openTextarea()).toHaveValue('Fish'); + + await user.click(button(tr.$tr('deleteDistractorBtn', { number: 1 }))); + expect(openTextarea()).toBeUndefined(); + expect(screen.getByText('Fish')).toBeInTheDocument(); + }); + + it('drops a distractor whose content is cleared before its editor closes', async () => { + const user = userEvent.setup(); + renderEditor({ interaction: blockWithDecl(BLANK_DISTRACTOR_XML, ONE_ROW_DECL_XML) }); + await user.click(button(tr.$tr('editDistractorLabel', { number: 1 }))); + await user.click(button(editRow(1))); + expect(within(distractorList()).queryAllByRole('listitem')).toHaveLength(0); + }); + }); + + describe('removing while another editor is open', () => { + it('commits a draft to the row it was written in when an earlier row is deleted', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(rowAnswers(2)); + await user.type(openTextarea(), 'Aves'); + await user.click(button(deleteRow(1))); + + expect(within(rows()[0]).getByText('Eagle')).toBeInTheDocument(); + expect(within(rowAnswers(1)).getByText('Aves')).toBeInTheDocument(); + }); + + it('closes an open answer, keeping its content, when an earlier answer is deleted', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(rowAnswers(1)); + await user.type(openTextarea(), 'Canine'); + await user.click(button(editRow(2))); + await user.click(button(editMatch(1, 2))); + expect(openTextarea()).toHaveValue('Canine'); + + await user.click(button(deleteMatch(1, 1))); + expect(openTextarea()).toBeUndefined(); + expect(within(rowAnswers(1)).getByText('Canine')).toBeInTheDocument(); + expect(screen.queryByText('Mammal')).not.toBeInTheDocument(); + }); + + it('closes an open row prompt when a distractor is deleted', async () => { + const user = userEvent.setup(); + renderEditor(); + expect(openTextarea()).toHaveValue('Dog'); + await user.click(button(tr.$tr('deleteDistractorBtn', { number: 1 }))); + expect(openTextarea()).toBeUndefined(); + }); + + it('closes the question prompt when an answer is deleted', async () => { + const user = userEvent.setup(); + renderEditor(); + await writeAnswer(user, 1, 'Canine'); + expect(queryButton(tr.$tr('editQuestionLabel'))).not.toBeInTheDocument(); + await user.click(button(deleteMatch(1, 1))); + expect(openTextarea()).toBeUndefined(); + }); + + it("commits another row's draft when an answer is deleted", async () => { + const user = userEvent.setup(); + renderEditor(); + await writeAnswer(user, 1, 'Canine'); + await user.click(rowAnswers(2)); + await user.type(openTextarea(), 'Aves'); + await user.click(button(deleteMatch(1, 1))); + + expect(openTextarea()).toBeUndefined(); + expect(within(rowAnswers(2)).getByText('Aves')).toBeInTheDocument(); + expect(within(rowAnswers(1)).queryByText('Mammal')).not.toBeInTheDocument(); + }); + + it('deletes the pressed answer, not an open blank one, when only one can go', async () => { + const user = userEvent.setup(); + renderEditor(); + await writeAnswer(user, 1, 'Canine'); + await user.click(button(editMatch(1, 1))); + await user.clear(openTextarea()); + + await user.click(button(deleteMatch(1, 2))); + expect(answerItems(1)).toHaveLength(1); + expect(within(rowAnswers(1)).queryByText('Canine')).not.toBeInTheDocument(); + }); + + it("commits a draft and opens a fresh one when another row's answer area is clicked", async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(rowAnswers(1)); + await user.type(openTextarea(), 'Canine'); + await user.click(rowAnswers(2)); + + expect(within(rowAnswers(1)).getByText('Canine')).toBeInTheDocument(); + expect(openTextareas()).toHaveLength(1); + expect(openTextarea()).toHaveValue(''); + expect(button(deleteMatch(2, 2))).toBeInTheDocument(); + }); + }); + + describe('leaving edit mode', () => { + it('discards a written draft rather than committing it', async () => { + const user = userEvent.setup(); + const { emitted, updateProps } = renderEditor(); + await user.click(rowAnswers(1)); + await user.type(openTextarea(), 'Canine'); + await updateProps({ mode: 'view' }); + await updateProps({ mode: 'edit' }); + + expect(screen.queryByText('Canine')).not.toBeInTheDocument(); + expect(updates(emitted).some(([payload]) => payload.bodyXml.includes('Canine'))).toBe(false); + }); + + it('discards a written distractor draft and reopens with no distractor editor', async () => { + const user = userEvent.setup(); + const { emitted, updateProps } = renderEditor(); + await user.click(button(tr.$tr('addDistractorBtn'))); + await user.type(openTextarea(), 'Fish'); + await updateProps({ mode: 'view' }); + expect(openTextareas()).toHaveLength(0); + await updateProps({ mode: 'edit' }); + + expect(screen.queryByText('Fish')).not.toBeInTheDocument(); + expect(within(distractorList()).getAllByRole('listitem')).toHaveLength(1); + expect(button(tr.$tr('addDistractorBtn'))).toBeInTheDocument(); + expect(updates(emitted).some(([payload]) => payload.bodyXml.includes('Fish'))).toBe(false); + }); + }); + + describe('validation', () => { + it('shows no errors for a question that is already complete', () => { + renderEditor(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('reports the prompt and too-few-rows errors as soon as the state changes', async () => { + const user = userEvent.setup(); + const { emitted } = renderEditor({ interaction: block('') }); + await user.click(button(tr.$tr('addRowBtn'))); + await nextTick(); + expect(screen.getByText(tr.$tr('errorPromptRequired'))).toBeInTheDocument(); + expect(screen.getByText(tr.$tr('errorTooFewRows'))).toBeInTheDocument(); + + expect(latestErrorCodes(emitted)).toEqual( + expect.arrayContaining([ValidationError.PROMPT_REQUIRED, ValidationError.TOO_FEW_ROWS]), + ); + }); + + it('flags a row whose prompt is blank in that row', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(tr.$tr('addRowBtn'))); + await nextTick(); + expect(within(rows()[3]).getByText(tr.$tr('errorEmptyRowContent'))).toBeInTheDocument(); + expect(within(rows()[0]).queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('flags a row left without an answer in that row', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(editMatch(2, 1))); + await user.clear(openTextarea()); + await user.click(button(editRow(1))); + await nextTick(); + expect(within(rows()[1]).getByText(tr.$tr('errorRowWithoutMatch'))).toBeInTheDocument(); + expect(within(rows()[0]).queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('flags both rows that repeat a prompt', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(editRow(2))); + await user.clear(openTextarea()); + await user.type(openTextarea(), 'Dog'); + await nextTick(); + for (const row of rows().slice(0, 2)) { + expect(within(row).getByText(tr.$tr('errorDuplicateRowContent'))).toBeInTheDocument(); + } + expect(within(rows()[2]).queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('flags each answer a row repeats', async () => { + const user = userEvent.setup(); + renderEditor(); + await writeAnswer(user, 1, 'Mammal'); + expect(within(rowAnswers(1)).getAllByText(tr.$tr('errorDuplicateMatchContent'))).toHaveLength( + 2, + ); + }); + + it('holds a new repeat unflagged until the pointer comes up', async () => { + const user = userEvent.setup(); + renderEditor(); + await writeAnswer(user, 1, 'Canine'); + await user.click(button(editMatch(1, 2))); + await fireEvent.pointerDown(document.body); + await user.clear(openTextarea()); + await user.type(openTextarea(), 'Mammal', { skipClick: true }); + await nextTick(); + expect(within(rowAnswers(1)).queryByRole('alert')).not.toBeInTheDocument(); + + await fireEvent.pointerUp(document.body); + expect(within(rowAnswers(1)).getAllByText(tr.$tr('errorDuplicateMatchContent'))).toHaveLength( + 2, + ); + }); + + it('holds a new row message back until the pointer comes up', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(editRow(2))); + await fireEvent.pointerDown(document.body); + await user.clear(openTextarea()); + await user.type(openTextarea(), 'Dog', { skipClick: true }); + await nextTick(); + expect(within(rowsList()).queryByRole('alert')).not.toBeInTheDocument(); + + await fireEvent.pointerUp(document.body); + expect(within(rowsList()).getAllByText(tr.$tr('errorDuplicateRowContent'))).toHaveLength(2); + }); + + it('flags an answer parsed with no content', () => { + renderEditor({ interaction: blockWithDecl(BLANK_ANSWER_XML, ONE_ROW_DECL_XML) }); + expect( + within(rowAnswers(1)).getByText(tr.$tr('errorEmptyChoiceContent')), + ).toBeInTheDocument(); + }); + + it('flags a distractor repeating an answer on both the distractor and the answer', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(tr.$tr('addDistractorBtn'))); + await user.type(openTextarea(), 'Mammal'); + await user.click(button(editRow(1))); + await nextTick(); + expect( + within(distractorList()).getByText(tr.$tr('errorDuplicateDistractorContent')), + ).toBeInTheDocument(); + expect( + within(rowAnswers(1)).getByText(tr.$tr('errorDuplicateDistractorContent')), + ).toBeInTheDocument(); + expect(within(rowAnswers(2)).queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('accepts the same answer in two rows', async () => { + const user = userEvent.setup(); + renderEditor(); + await writeAnswer(user, 2, 'Mammal'); + expect(within(rowAnswers(2)).getByText('Mammal')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + }); + + describe('view mode', () => { + const renderView = (props = {}) => renderEditor({ mode: 'view', ...props }); + const pool = () => screen.getByRole('list', { name: tr.$tr('responsePoolLabel') }); + + it('repeats an answer in the pool once per row it serves, and each distractor once', () => { + renderView({ + interaction: blockWithDecl(SHARED_ANSWER_XML, SHARED_ANSWER_DECL_XML), + }); + expect(within(pool()).getAllByText('Mammal')).toHaveLength(2); + expect(within(pool()).getAllByText('Reptile')).toHaveLength(1); + }); + + it('lists every row prompt and no answer when answers are hidden', () => { + renderView(); + for (const content of ['Dog', 'Eagle', 'Frog']) { + expect(within(rowsList()).getByText(content)).toBeInTheDocument(); + } + expect(screen.getAllByText('Mammal')).toHaveLength(1); + expect(within(pool()).getByText('Mammal')).toBeInTheDocument(); + }); + + it('lists each row’s answers beside its prompt when answers are shown', () => { + renderView({ showAnswers: true }); + expect(within(rowAnswers(1)).getByText('Mammal')).toBeInTheDocument(); + expect(within(rowAnswers(2)).getByText('Bird')).toBeInTheDocument(); + expect(within(rowsList()).queryByText('Reptile')).not.toBeInTheDocument(); + }); + + it('paints the pool chip of an answer green, and not a distractor’s, when answers are shown', () => { + const palette = themePalette(); + renderView({ showAnswers: true }); + const chip = text => within(pool()).getByText(text).closest('li'); + expect(chip('Mammal')).toHaveStyle({ borderColor: palette.green.v_600 }); + expect(chip('Reptile')).not.toHaveStyle({ borderColor: palette.green.v_600 }); + }); + + it('leaves the answers listed beside their prompts unpainted', () => { + renderView({ showAnswers: true }); + const chip = within(rowAnswers(1)).getByText('Mammal').closest('li'); + expect(chip).toHaveStyle({ borderColor: themeTokens().fineLine }); + }); + + it('keeps the pool order across a round trip through edit mode', async () => { + const { updateProps } = renderView(); + const order = () => + within(pool()) + .getAllByRole('listitem') + .map(el => el.textContent.trim()); + const initial = order(); + for (let i = 0; i < 3; i++) { + await updateProps({ mode: 'edit' }); + await updateProps({ mode: 'view' }); + expect(order()).toEqual(initial); + } + }); + + it('heads the answers column only when answers are shown', async () => { + const { updateProps } = renderView(); + expect(screen.getByText(tr.$tr('promptColumnLabel'))).toBeInTheDocument(); + expect(screen.queryByText(tr.$tr('answersColumnLabel'))).not.toBeInTheDocument(); + await updateProps({ showAnswers: true }); + expect(screen.getByText(tr.$tr('answersColumnLabel'))).toBeInTheDocument(); + }); + + it('hides the editing controls', () => { + renderView(); + expect(queryButton(tr.$tr('addRowBtn'))).not.toBeInTheDocument(); + expect(queryButton(tr.$tr('addDistractorBtn'))).not.toBeInTheDocument(); + expect(queryButton(deleteRow(1))).not.toBeInTheDocument(); + expect(queryButton(editRow(1))).not.toBeInTheDocument(); + }); + + it('opens no editor when a row prompt is clicked', async () => { + const user = userEvent.setup(); + renderView({ showAnswers: true }); + await user.click(within(rowsList()).getByText('Dog')); + expect(openTextarea()).toBeUndefined(); + }); + }); + + describe('emits', () => { + it('does not emit update:interaction on mount in edit mode', () => { + const { emitted } = renderEditor(); + expect(updates(emitted)).toHaveLength(0); + }); + + it('does not emit update:interaction when moving between editors changes nothing', async () => { + const user = userEvent.setup(); + const { emitted } = renderEditor(); + await user.click(button(editRow(2))); + await user.click(button(tr.$tr('editQuestionLabel'))); + expect(updates(emitted)).toHaveLength(0); + }); + + it('does not emit update:interaction in view mode', () => { + const { emitted } = renderEditor({ mode: 'view', showAnswers: true }); + expect(updates(emitted)).toHaveLength(0); + }); + + it('emits update:interaction after adding a row', async () => { + const user = userEvent.setup(); + const { emitted } = renderEditor(); + await user.click(button(tr.$tr('addRowBtn'))); + const payload = updates(emitted).at(-1)[0]; + expect(payload.bodyXml.match(/ { + it('opens a row prompt from the keyboard', async () => { + const user = userEvent.setup(); + renderEditor(); + button(editRow(2)).focus(); + await user.keyboard('{Enter}'); + expect(queryButton(editRow(2))).not.toBeInTheDocument(); + }); + + it('adds a row from the keyboard', async () => { + const user = userEvent.setup(); + renderEditor(); + button(tr.$tr('addRowBtn')).focus(); + await user.keyboard('{Enter}'); + expect(rows()).toHaveLength(4); + }); + + it('moves focus to the previous row when the last row is deleted', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(deleteRow(3))); + await nextTick(); + expect(button(deleteRow(2))).toHaveFocus(); + }); + + it('moves focus to the row taking an open deleted row’s place below the large breakpoint', async () => { + mockWindowIsLarge = false; + const user = userEvent.setup(); + // Row 2's blank prompt shows a message; row 1 is open on mount. + renderEditor({ + interaction: blockWithDecl(MATCH_XML.replace('Eagle', ''), MATCH_DECL_XML), + }); + await user.click(button(deleteRow(1))); + await nextTick(); + expect(button(deleteRow(1))).toHaveFocus(); + }); + + it('moves focus to the add row button when no removable row is left', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(deleteRow(1))); + await user.click(button(deleteRow(1))); + await nextTick(); + expect(button(tr.$tr('addRowBtn'))).toHaveFocus(); + }); + + it('moves focus to the answer that takes a deleted answer’s place', async () => { + const user = userEvent.setup(); + renderEditor(); + await writeAnswer(user, 1, 'Canine'); + await writeAnswer(user, 1, 'Vertebrate'); + await user.click(button(deleteMatch(1, 1))); + await nextTick(); + expect(button(deleteMatch(1, 1))).toHaveFocus(); + }); + + it("moves focus to the row's add answer control when one answer is left", async () => { + const user = userEvent.setup(); + renderEditor(); + await writeAnswer(user, 2, 'Aves'); + await user.click(button(deleteMatch(2, 1))); + await nextTick(); + expect(button(addMatch(2))).toHaveFocus(); + }); + + it('moves focus to the distractor that takes a deleted one’s place', async () => { + const user = userEvent.setup(); + renderEditor(); + await user.click(button(tr.$tr('addDistractorBtn'))); + await user.type(openTextarea(), 'Fish'); + await user.click(button(editRow(1))); + await user.click(button(tr.$tr('deleteDistractorBtn', { number: 1 }))); + await nextTick(); + expect(button(tr.$tr('deleteDistractorBtn', { number: 1 }))).toHaveFocus(); + }); + }); + + describe('graceful fallback', () => { + it('renders one blank row when bodyXml is empty', () => { + renderEditor({ interaction: block('') }); + expect(rows()).toHaveLength(1); + expect(button(tr.$tr('addRowBtn'))).toBeInTheDocument(); + }); + + it('renders one row when the XML is malformed', () => { + renderEditor({ interaction: block(' choices.map(choice => choice.content); +const matchContents = rows => rows.map(row => contentsOf(row.matches)); + +const SCHEMA = { baseType: BaseType.DIRECTED_PAIR, cardinality: Cardinality.MULTIPLE }; + +const parseXmlString = xml => parseXML(xml).documentElement; + +const matchSet = choices => `${choices}`; +const choice = (id, content) => + `${content}`; +const interaction = (...sets) => + `${sets.join('')}`; +const declaration = (...values) => + ` + ${values.map(v => `${v}`).join('')} + `; + +const ROWS = matchSet(choice('row_dog', 'Dog') + choice('row_eagle', 'Eagle')); +const RESPONSES = matchSet( + choice('choice_mammal', 'Mammal') + choice('choice_bird', 'Bird') + choice('choice_fur', 'Fur'), +); + +describe('parse()', () => { + describe('fallbacks', () => { + const expectDefaultState = state => { + expect(state.rows).toHaveLength(1); + const [row] = state.rows; + expect(row.id).toMatch(/^row_/); + expect(row.content).toBe(''); + expect(row.matches).toHaveLength(1); + expect(row.matches[0].id).toMatch(/^choice_/); + expect(row.matches[0].content).toBe(''); + expect(state.distractors).toEqual([]); + }; + + it('seeds one row holding one blank answer when bodyXml is empty', () => { + expectDefaultState(parse('', [MATCH_DECL_XML])); + }); + + it('returns the default state when bodyXml is invalid XML', () => { + expectDefaultState(parse(' { + expectDefaultState(parse(interaction(ROWS), [])); + }); + + it('returns the default state for three match sets', () => { + expectDefaultState(parse(interaction(ROWS, RESPONSES, RESPONSES), [])); + }); + + it('defaults prompt to an empty string when is absent', () => { + expect(parse(interaction(ROWS, RESPONSES), []).prompt).toBe(''); + }); + }); + + describe('rows and answers', () => { + it('reads the prompt HTML', () => { + expect(parse(MATCH_XML, [MATCH_DECL_XML]).prompt).toContain('Match each animal'); + }); + + it('builds rows from the first set in document order', () => { + const state = parse(MATCH_XML, [MATCH_DECL_XML]); + expect(contentsOf(state.rows)).toEqual(['Dog', 'Eagle', 'Frog']); + expect(state.rows.map(row => row.id)).toEqual(['row_dog', 'row_eagle', 'row_frog']); + }); + + it('gives each row the answers the correct response names for it', () => { + const state = parse(MATCH_XML, [MATCH_DECL_XML]); + expect(matchContents(state.rows)).toEqual([['Mammal'], ['Bird'], ['Amphibian']]); + expect(state.rows[0].matches[0].id).toBe('choice_mammal'); + }); + + it('orders a row holding two answers by the correct response', () => { + const decl = declaration('row_dog choice_fur', 'row_dog choice_mammal'); + expect(matchContents(parse(interaction(ROWS, RESPONSES), [decl]).rows)).toEqual([ + ['Fur', 'Mammal'], + [], + ]); + }); + + it('puts a response named by two rows in both rows', () => { + const decl = declaration('row_dog choice_fur', 'row_eagle choice_fur'); + expect(matchContents(parse(interaction(ROWS, RESPONSES), [decl]).rows)).toEqual([ + ['Fur'], + ['Fur'], + ]); + }); + + it('drops a value naming the response before the row', () => { + const decl = declaration('choice_mammal row_dog', 'row_eagle choice_bird'); + expect(matchContents(parse(interaction(ROWS, RESPONSES), [decl]).rows)).toEqual([ + [], + ['Bird'], + ]); + }); + + it('drops a value naming an identifier absent from its set', () => { + const decl = declaration( + 'row_dog choice_missing', + 'row_missing choice_mammal', + 'row_eagle choice_bird', + ); + expect(matchContents(parse(interaction(ROWS, RESPONSES), [decl]).rows)).toEqual([ + [], + ['Bird'], + ]); + }); + + it('drops an empty ', () => { + const decl = declaration('', 'row_eagle choice_bird'); + expect(matchContents(parse(interaction(ROWS, RESPONSES), [decl]).rows)).toEqual([ + [], + ['Bird'], + ]); + }); + + it('leaves every row without answers when no declarations are passed', () => { + const state = parse(MATCH_XML, []); + expect(matchContents(state.rows)).toEqual([[], [], []]); + expect(contentsOf(state.distractors)).toEqual(['Bird', 'Mammal', 'Amphibian', 'Reptile']); + }); + }); + + describe('distractors', () => { + it('treats responses no value names as distractors, in document order', () => { + const decl = declaration('row_dog choice_bird'); + const state = parse(interaction(ROWS, RESPONSES), [decl]); + expect(state.distractors).toEqual([ + { id: 'choice_mammal', content: 'Mammal' }, + { id: 'choice_fur', content: 'Fur' }, + ]); + }); + + it('treats a response named only by a dropped value as a distractor', () => { + const decl = declaration('choice_mammal row_dog'); + const state = parse(interaction(ROWS, RESPONSES), [decl]); + expect(contentsOf(state.distractors)).toEqual(['Mammal', 'Bird', 'Fur']); + }); + + it('imports the unlimited match-max="0" by appearance', () => { + const responses = ` + Mammal + Bird + `; + const state = parse(interaction(ROWS, responses), [declaration('row_dog choice_mammal')]); + expect(matchContents(state.rows)).toEqual([['Mammal'], []]); + expect(contentsOf(state.distractors)).toEqual(['Bird']); + }); + + it('reads a named response with surplus match-max as one answer and no distractor', () => { + const responses = ` + Mammal + `; + const state = parse(interaction(ROWS, responses), [declaration('row_dog choice_mammal')]); + expect(matchContents(state.rows)).toEqual([['Mammal'], []]); + expect(state.distractors).toEqual([]); + }); + }); + + describe('identifiers', () => { + it('assigns row_ and choice_ slugs to choices without an identifier', () => { + const xml = interaction( + matchSet('Dog'), + matchSet('Mammal'), + ); + const state = parse(xml, []); + expect(state.rows[0].id).toMatch(/^row_/); + expect(state.distractors[0].id).toMatch(/^choice_/); + }); + + // Identifiers are used as lookup keys, so one that names an inherited Object + // member must not resolve to that member. + it('resolves identifiers that are Object.prototype member names', () => { + const xml = interaction( + matchSet(choice('constructor', 'Dog')), + matchSet(choice('toString', 'Mammal') + choice('choice_bird', 'Bird')), + ); + const state = parse(xml, [declaration('constructor toString')]); + expect(matchContents(state.rows)).toEqual([['Mammal']]); + expect(contentsOf(state.distractors)).toEqual(['Bird']); + }); + + it('resolves a value whose row and response share an identifier', () => { + const xml = interaction( + matchSet(choice('shared', 'Dog')), + matchSet(choice('shared', 'Mammal')), + ); + const state = parse(xml, [declaration('shared shared')]); + expect(matchContents(state.rows)).toEqual([['Mammal']]); + expect(state.distractors).toEqual([]); + }); + + it('reads responseIdentifier from the attribute', () => { + const xml = `${ROWS}${RESPONSES}`; + expect(parse(xml, []).responseIdentifier).toBe('RESPONSE_2'); + }); + }); +}); + +describe('buildXML()', () => { + const baseState = { + responseIdentifier: 'RESPONSE', + prompt: 'Match each animal to its class.', + rows: [ + { + id: 'row_dog', + content: 'Dog', + matches: [ + { id: 'choice_mammal', content: 'Mammal' }, + { id: 'choice_fur', content: 'Fur' }, + ], + }, + { id: 'row_eagle', content: 'Eagle', matches: [{ id: 'choice_bird', content: 'Bird' }] }, + ], + distractors: [{ id: 'choice_reptile', content: 'Reptile' }], + }; + + const withRows = (rows, distractors = []) => ({ ...baseState, rows, distractors }); + const row = (id, content, matches = []) => ({ id, content, matches }); + + const build = state => buildXML(state, QuestionType.MATCH, SCHEMA); + const setsOf = root => [...root.querySelectorAll('qti-simple-match-set')]; + const choicesIn = setEl => [...setEl.querySelectorAll('qti-simple-associable-choice')]; + const describeSet = setEl => + choicesIn(setEl).map(el => [ + el.getAttribute('identifier'), + el.textContent, + el.getAttribute('match-max'), + ]); + const rowsOf = result => describeSet(setsOf(parseXmlString(result.bodyXml))[0]); + const responsesOf = result => describeSet(setsOf(parseXmlString(result.bodyXml))[1]); + const valuesOf = result => + [...parseXmlString(result.responseDeclarations[0]).querySelectorAll('qti-value')].map(n => + n.textContent.trim(), + ); + + describe('interaction attributes', () => { + it('emits the response identifier, shuffle, and one association per answer', () => { + const root = parseXmlString(build(baseState).bodyXml); + expect(root.getAttribute('response-identifier')).toBe('RESPONSE'); + expect(root.getAttribute('shuffle')).toBe('true'); + expect(root.getAttribute('max-associations')).toBe('3'); + }); + + it('emits when the prompt is filled', () => { + const root = parseXmlString(build(baseState).bodyXml); + expect(root.querySelector('qti-prompt').textContent).toBe('Match each animal to its class.'); + }); + + it('omits when prompt is empty', () => { + const root = parseXmlString(build({ ...baseState, prompt: '' }).bodyXml); + expect(root.querySelector('qti-prompt')).toBeNull(); + }); + }); + + describe('match sets', () => { + it('emits the row set, then the response set, in state order', () => { + expect(rowsOf(build(baseState))).toEqual([ + ['row_dog', 'Dog', '2'], + ['row_eagle', 'Eagle', '1'], + ]); + expect(responsesOf(build(baseState))).toEqual([ + ['choice_mammal', 'Mammal', '1'], + ['choice_fur', 'Fur', '1'], + ['choice_bird', 'Bird', '1'], + ['choice_reptile', 'Reptile', '1'], + ]); + }); + + it('emits two sets for a state with no answers and no distractors', () => { + const root = parseXmlString(build(withRows([row('row_dog', 'Dog')])).bodyXml); + expect(setsOf(root)).toHaveLength(2); + expect(describeSet(setsOf(root)[0])).toEqual([['row_dog', 'Dog', '1']]); + expect(choicesIn(setsOf(root)[1])).toEqual([]); + }); + + it('emits two sets for a state with no rows', () => { + expect(setsOf(parseXmlString(build(withRows([])).bodyXml))).toHaveLength(2); + }); + }); + + describe('responses', () => { + const mammal = { id: 'choice_mammal', content: 'Mammal' }; + + it('merges an answer shared by two rows into one choice with match-max="2"', () => { + const state = withRows([ + row('row_dog', 'Dog', [mammal]), + row('row_whale', 'Whale', [{ id: 'choice_other', content: 'Mammal' }]), + ]); + const result = build(state); + expect(responsesOf(result)).toEqual([['choice_mammal', 'Mammal', '2']]); + expect(valuesOf(result)).toEqual(['row_dog choice_mammal', 'row_whale choice_mammal']); + }); + + it('counts an answer repeated within one row twice', () => { + const state = withRows([row('row_dog', 'Dog', [mammal, { ...mammal }])]); + expect(responsesOf(build(state))).toEqual([['choice_mammal', 'Mammal', '2']]); + }); + + it('keeps two equal distractors as two choices', () => { + const distractors = [ + { id: 'choice_a', content: 'Reptile' }, + { id: 'choice_b', content: 'Reptile' }, + ]; + expect(responsesOf(build(withRows([row('row_dog', 'Dog')], distractors)))).toEqual([ + ['choice_a', 'Reptile', '1'], + ['choice_b', 'Reptile', '1'], + ]); + }); + + it('keeps a distractor equal to an answer as a separate choice', () => { + const state = withRows( + [row('row_dog', 'Dog', [mammal])], + [{ id: 'choice_other', content: 'Mammal' }], + ); + expect(responsesOf(build(state))).toEqual([ + ['choice_mammal', 'Mammal', '1'], + ['choice_other', 'Mammal', '1'], + ]); + }); + + it('emits a row prompt equal to an answer in both sets', () => { + const state = withRows([row('row_mammal', 'Mammal', [mammal])]); + const result = build(state); + expect(rowsOf(result)).toEqual([['row_mammal', 'Mammal', '1']]); + expect(responsesOf(result)).toEqual([['choice_mammal', 'Mammal', '1']]); + }); + + it('never merges blank answers', () => { + const state = withRows([ + row('row_dog', 'Dog', [{ id: 'choice_a', content: '' }]), + row('row_eagle', 'Eagle', [{ id: 'choice_b', content: '' }]), + ]); + expect(responsesOf(build(state)).map(([id]) => id)).toEqual(['choice_a', 'choice_b']); + }); + + it('keeps two different image-only answers as two choices', () => { + const state = withRows([ + row('row_dog', 'Dog', [{ id: 'choice_a', content: '' }]), + row('row_eagle', 'Eagle', [{ id: 'choice_b', content: '' }]), + ]); + expect(responsesOf(build(state)).map(([id, , matchMax]) => [id, matchMax])).toEqual([ + ['choice_a', '1'], + ['choice_b', '1'], + ]); + }); + }); + + describe('identifiers', () => { + const idsIn = result => [ + ...rowsOf(result).map(([id]) => id), + ...responsesOf(result).map(([id]) => id), + ]; + + it('gives a later equal answer the id of its first appearance', () => { + const state = withRows([ + row('row_dog', 'Dog', [{ id: 'choice_first', content: 'Mammal' }]), + row('row_whale', 'Whale', [{ id: 'choice_second', content: 'Mammal' }]), + ]); + const result = build(state); + expect(idsIn(result)).not.toContain('choice_second'); + expect(valuesOf(result)).toEqual(['row_dog choice_first', 'row_whale choice_first']); + }); + + it('reassigns an answer reusing an id bound to other content', () => { + const state = withRows([ + row('row_dog', 'Dog', [{ id: 'choice_mammal', content: 'Mammal' }]), + row('row_eagle', 'Eagle', [{ id: 'choice_mammal', content: 'Bird' }]), + ]); + const result = build(state); + const [[mammalId], [birdId]] = responsesOf(result); + expect(mammalId).toBe('choice_mammal'); + expect(birdId).toMatch(/^choice_/); + expect(birdId).not.toBe('choice_mammal'); + expect(valuesOf(result)).toEqual(['row_dog choice_mammal', `row_eagle ${birdId}`]); + }); + + it('reassigns a response reusing a row id', () => { + const result = build(withRows([row('shared', 'Dog', [{ id: 'shared', content: 'Mammal' }])])); + const [[responseId]] = responsesOf(result); + expect(rowsOf(result)[0][0]).toBe('shared'); + expect(responseId).toMatch(/^choice_/); + expect(valuesOf(result)).toEqual([`shared ${responseId}`]); + }); + + it('reassigns a row reusing another row id', () => { + const state = withRows([ + row('row_dog', 'Dog', [{ id: 'choice_mammal', content: 'Mammal' }]), + row('row_dog', 'Eagle', [{ id: 'choice_bird', content: 'Bird' }]), + ]); + const result = build(state); + const [[first], [second]] = rowsOf(result); + expect(first).toBe('row_dog'); + expect(second).toMatch(/^row_/); + expect(second).not.toBe('row_dog'); + expect(valuesOf(result)).toEqual(['row_dog choice_mammal', `${second} choice_bird`]); + }); + + it('reassigns a distractor reusing an answer id', () => { + const state = withRows( + [row('row_dog', 'Dog', [{ id: 'choice_mammal', content: 'Mammal' }])], + [{ id: 'choice_mammal', content: 'Reptile' }], + ); + const [, [distractorId, content]] = responsesOf(build(state)); + expect(content).toBe('Reptile'); + expect(distractorId).toMatch(/^choice_/); + expect(distractorId).not.toBe('choice_mammal'); + }); + + it('assigns ids to a row and answer without one', () => { + const state = withRows([row('', 'Dog', [{ id: '', content: 'Mammal' }])]); + const result = build(state); + expect(rowsOf(result)[0][0]).toMatch(/^row_/); + expect(responsesOf(result)[0][0]).toMatch(/^choice_/); + }); + }); + + describe('response declaration', () => { + it('emits one "rowId responseId" per answer, by row then position', () => { + expect(valuesOf(build(baseState))).toEqual([ + 'row_dog choice_mammal', + 'row_dog choice_fur', + 'row_eagle choice_bird', + ]); + }); + + it('omits when no row has an answer', () => { + const state = withRows([row('row_dog', 'Dog')], baseState.distractors); + const { bodyXml, responseDeclarations } = build(state); + expect( + parseXmlString(responseDeclarations[0]).querySelector('qti-correct-response'), + ).toBeNull(); + expect(parseXmlString(bodyXml).getAttribute('max-associations')).toBe('0'); + }); + }); +}); + +describe('parse → buildXML → parse round-trip', () => { + const roundTrip = state => { + const { bodyXml, responseDeclarations } = buildXML(state, QuestionType.MATCH, SCHEMA); + return parse(bodyXml, responseDeclarations); + }; + + it('preserves the fixture', () => { + const original = parse(MATCH_XML, [MATCH_DECL_XML]); + expect(roundTrip(original)).toEqual(original); + }); + + it('preserves a row holding two answers', () => { + const original = parse(interaction(ROWS, RESPONSES), [ + declaration('row_dog choice_mammal', 'row_dog choice_fur'), + ]); + expect(roundTrip(original)).toEqual(original); + }); + + it('preserves a response shared by two rows', () => { + const original = parse(interaction(ROWS, RESPONSES), [ + declaration('row_dog choice_fur', 'row_eagle choice_fur'), + ]); + expect(roundTrip(original)).toEqual(original); + }); + + it('preserves two distractors with the same content', () => { + const state = { + responseIdentifier: 'RESPONSE', + prompt: '', + rows: [ + { id: 'row_dog', content: 'Dog', matches: [{ id: 'choice_mammal', content: 'Mammal' }] }, + ], + distractors: [ + { id: 'choice_a', content: 'Reptile' }, + { id: 'choice_b', content: 'Reptile' }, + ], + }; + expect(roundTrip(state)).toEqual(state); + }); + + it('preserves the default state', () => { + const original = parse('', []); + expect(roundTrip(original)).toEqual(original); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/validation.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/validation.spec.js new file mode 100644 index 0000000000..79e6d1546d --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/validation.spec.js @@ -0,0 +1,218 @@ +import { validateMatchInteraction as validate } from '../validation'; +import { ValidationError } from '../../../constants'; +import { richTextComparisonKey } from '../../../utils/richText'; + +const row = (index, content, matches) => ({ id: `row_${index}`, content, matches }); +const answer = (id, content) => ({ id, content }); + +function makeState(overrides = {}) { + return { + prompt: 'Match each animal to its biological classification class.', + rows: [ + row(0, 'Dog', [answer('choice_mammal', 'Mammal')]), + row(1, 'Eagle', [answer('choice_bird', 'Bird')]), + ], + distractors: [answer('choice_reptile', 'Reptile')], + ...overrides, + }; +} + +const errorCodes = errors => errors.map(e => e.code); +const errorsWith = (state, code) => validate(state).filter(e => e.code === code); + +describe('validateMatchInteraction()', () => { + it('returns an empty array for a valid state', () => { + expect(validate(makeState())).toEqual([]); + }); + + describe('PROMPT_REQUIRED', () => { + it('returns error when the prompt is empty', () => { + expect(errorCodes(validate(makeState({ prompt: '' })))).toContain( + ValidationError.PROMPT_REQUIRED, + ); + }); + + it('returns error when the prompt is tags-and-whitespace only', () => { + expect(errorCodes(validate(makeState({ prompt: ' ' })))).toContain( + ValidationError.PROMPT_REQUIRED, + ); + }); + }); + + describe('EMPTY_ROW_CONTENT', () => { + it('flags a blank row prompt by row index', () => { + const rows = [makeState().rows[0], row(1, '', [answer('choice_bird', 'Bird')])]; + expect(errorsWith(makeState({ rows }), ValidationError.EMPTY_ROW_CONTENT)).toEqual([ + { code: ValidationError.EMPTY_ROW_CONTENT, index: 1 }, + ]); + }); + + it('does not flag an image-only row prompt', () => { + const rows = [row(0, '', [answer('choice_mammal', 'Mammal')])]; + expect(validate(makeState({ rows }))).toEqual([]); + }); + }); + + describe('EMPTY_CHOICE_CONTENT', () => { + it('flags a blank answer by id', () => { + const rows = [ + row(0, 'Dog', [answer('choice_mammal', 'Mammal'), answer('choice_blank', ' ')]), + ]; + expect(errorsWith(makeState({ rows }), ValidationError.EMPTY_CHOICE_CONTENT)).toEqual([ + { code: ValidationError.EMPTY_CHOICE_CONTENT, id: 'choice_blank' }, + ]); + }); + + it('flags a blank distractor by id', () => { + const distractors = [answer('choice_blank', '')]; + expect(errorsWith(makeState({ distractors }), ValidationError.EMPTY_CHOICE_CONTENT)).toEqual([ + { code: ValidationError.EMPTY_CHOICE_CONTENT, id: 'choice_blank' }, + ]); + }); + + it('does not flag an image-only or formula-only answer', () => { + const rows = [ + row(0, 'Dog', [answer('choice_img', '')]), + row(1, 'Square', [answer('choice_math', '')]), + ]; + expect(validate(makeState({ rows }))).toEqual([]); + }); + }); + + describe('ROW_WITHOUT_MATCH', () => { + it('flags a row with no answers by row index', () => { + const rows = [makeState().rows[0], row(1, 'Eagle', [])]; + expect(errorsWith(makeState({ rows }), ValidationError.ROW_WITHOUT_MATCH)).toEqual([ + { code: ValidationError.ROW_WITHOUT_MATCH, index: 1 }, + ]); + }); + + it('flags a row whose only answers are blank', () => { + const rows = [makeState().rows[0], row(1, 'Eagle', [answer('choice_blank', '')])]; + expect(errorsWith(makeState({ rows }), ValidationError.ROW_WITHOUT_MATCH)).toEqual([ + { code: ValidationError.ROW_WITHOUT_MATCH, index: 1 }, + ]); + }); + }); + + describe('TOO_FEW_ROWS', () => { + it('fires when there are no rows', () => { + expect(errorCodes(validate(makeState({ rows: [] })))).toContain(ValidationError.TOO_FEW_ROWS); + }); + + it('fires when no row has both a filled prompt and a filled answer', () => { + const rows = [ + row(0, '', [answer('choice_mammal', 'Mammal')]), + row(1, 'Eagle', [answer('choice_blank', '')]), + ]; + expect(errorCodes(validate(makeState({ rows })))).toContain(ValidationError.TOO_FEW_ROWS); + }); + + it('does not fire when one valid row sits among invalid ones', () => { + const rows = [ + row(0, '', [answer('choice_mammal', 'Mammal')]), + row(1, 'Eagle', [answer('choice_bird', 'Bird')]), + row(2, 'Frog', []), + ]; + expect(errorCodes(validate(makeState({ rows })))).not.toContain(ValidationError.TOO_FEW_ROWS); + }); + }); + + describe('DUPLICATE_MATCH_CONTENT', () => { + it('flags two answers in one row that differ only in markup', () => { + const rows = [ + makeState().rows[0], + row(1, 'Eagle', [ + answer('choice_a', 'Bird'), + answer('choice_b', 'Bird'), + ]), + ]; + expect(errorsWith(makeState({ rows }), ValidationError.DUPLICATE_MATCH_CONTENT)).toEqual([ + { + code: ValidationError.DUPLICATE_MATCH_CONTENT, + index: 1, + text: richTextComparisonKey('Bird'), + }, + ]); + }); + + it('does not treat two blank answers as duplicates', () => { + const rows = [ + row(0, 'Dog', [ + answer('choice_mammal', 'Mammal'), + answer('choice_a', ''), + answer('choice_b', ''), + ]), + ]; + expect(errorCodes(validate(makeState({ rows })))).not.toContain( + ValidationError.DUPLICATE_MATCH_CONTENT, + ); + }); + }); + + describe('DUPLICATE_ROW_CONTENT', () => { + it('flags each row sharing a prompt', () => { + const rows = [ + row(0, 'Dog', [answer('choice_mammal', 'Mammal')]), + row(1, 'Eagle', [answer('choice_bird', 'Bird')]), + row(2, 'Dog', [answer('choice_fur', 'Fur')]), + ]; + expect(errorsWith(makeState({ rows }), ValidationError.DUPLICATE_ROW_CONTENT)).toEqual([ + { code: ValidationError.DUPLICATE_ROW_CONTENT, index: 0 }, + { code: ValidationError.DUPLICATE_ROW_CONTENT, index: 2 }, + ]); + }); + + it('does not treat two blank prompts as duplicates', () => { + const rows = [...makeState().rows, row(2, '', []), row(3, '', [])]; + expect(errorCodes(validate(makeState({ rows })))).not.toContain( + ValidationError.DUPLICATE_ROW_CONTENT, + ); + }); + }); + + describe('DUPLICATE_DISTRACTOR_CONTENT', () => { + const duplicates = state => errorsWith(state, ValidationError.DUPLICATE_DISTRACTOR_CONTENT); + + it('flags a distractor repeating another distractor, once per text', () => { + const distractors = [ + answer('choice_a', 'Reptile'), + answer('choice_b', 'Reptile'), + answer('choice_c', 'Reptile'), + ]; + expect(duplicates(makeState({ distractors }))).toEqual([ + { + code: ValidationError.DUPLICATE_DISTRACTOR_CONTENT, + text: richTextComparisonKey('Reptile'), + }, + ]); + }); + + it('flags a distractor repeating an answer in any row', () => { + const distractors = [answer('choice_other', 'Bird')]; + expect(duplicates(makeState({ distractors }))).toEqual([ + { code: ValidationError.DUPLICATE_DISTRACTOR_CONTENT, text: richTextComparisonKey('Bird') }, + ]); + }); + + it('does not treat two blank distractors as duplicates', () => { + const distractors = [answer('choice_a', ''), answer('choice_b', '')]; + expect(duplicates(makeState({ distractors }))).toEqual([]); + }); + }); + + describe('not errors', () => { + it('allows the same answer in two rows', () => { + const rows = [ + row(0, 'Dog', [answer('choice_mammal', 'Mammal')]), + row(1, 'Whale', [answer('choice_mammal', 'Mammal')]), + ]; + expect(validate(makeState({ rows }))).toEqual([]); + }); + + it('allows a row prompt equal to an answer', () => { + const rows = [row(0, 'Mammal', [answer('choice_mammal', 'Mammal')])]; + expect(validate(makeState({ rows }))).toEqual([]); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js new file mode 100644 index 0000000000..87e2ebb825 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js @@ -0,0 +1,224 @@ +import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; +import { getPromptHTML, parseXML } from '../../serialization/xml'; +import { buildXmlNode } from '../../serialization/assembleItem'; +import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; +import { generateRandomSlug } from '../../utils/generateRandomSlug'; +import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText'; +import { RESPONSE_IDENTIFIER } from '../../constants'; +import { _extractCorrectPairIds, newChoice } from '../associate/parse'; + +const serializer = new XMLSerializer(); + +/** + * @typedef {object} MatchChoice + * @property {string} id - QTI identifier, e.g. "choice_xlqTuVoq" + * @property {string} content - HTML content of the + */ + +/** + * @typedef {object} MatchRow + * @property {string} id - QTI identifier of the row's first-set choice + * @property {string} content - HTML content of the row prompt + * @property {MatchChoice[]} matches - Second-set choices this row is correctly matched to + */ + +/** + * @typedef {object} MatchState + * @property {string} responseIdentifier - Response identifier attribute + * @property {string} prompt - HTML content of ; default "" + * @property {MatchRow[]} rows - Matching rows, in authored order + * @property {MatchChoice[]} distractors - Response-set choices matched by no row + */ + +/** + * @returns {MatchRow} + */ +export const newRow = () => ({ + id: generateRandomSlug('row'), + content: '', + matches: [newChoice()], +}); + +/** + * @returns {MatchState} + */ +export function _defaultState() { + return { + responseIdentifier: RESPONSE_IDENTIFIER, + prompt: '', + rows: [newRow()], + distractors: [], + }; +} + +/** + * The row set and the response set. Any other count is not an item this editor can author. + * + * @param {Element} el - element + * @returns {boolean} + */ +export function hasTwoMatchSets(el) { + return matchSets(el).length === 2; +} + +function matchSets(el) { + return el.querySelectorAll(':scope > qti-simple-match-set'); +} + +/** + * @param {Element} setEl - + * @param {string} prefix - slug prefix for a choice without an identifier + * @returns {MatchChoice[]} + */ +function readSet(setEl, prefix) { + return [...setEl.querySelectorAll('qti-simple-associable-choice')].map(el => ({ + id: el.getAttribute('identifier') || generateRandomSlug(prefix), + content: el.innerHTML, + })); +} + +/** + * Parse body XML + response declarations → MatchState. + * + * match-max is never read: a response is an answer exactly when a correct-response + * value names it, and a distractor otherwise. + * + * @param {string} bodyXml + * @param {string[]} responseDeclarations + * @returns {MatchState} + */ +export function parseMatchInteraction(bodyXml, responseDeclarations) { + if (!bodyXml) return _defaultState(); + + let root; + try { + root = parseXML(bodyXml).documentElement; + } catch { + return _defaultState(); + } + if (!hasTwoMatchSets(root)) return _defaultState(); + + const [rowSet, responseSet] = matchSets(root); + const rows = readSet(rowSet, 'row').map(row => ({ ...row, matches: [] })); + const responses = readSet(responseSet, 'choice'); + const rowById = new Map(rows.map(row => [row.id, row])); + const responseById = new Map(responses.map(response => [response.id, response])); + + const matched = new Set(); + // A null pair is an empty , which names no row. + for (const [rowId, responseId] of _extractCorrectPairIds(responseDeclarations).filter(Boolean)) { + const row = rowById.get(rowId); + const response = responseById.get(responseId); + if (row && response) { + row.matches.push({ ...response }); + matched.add(response); + } + } + + return { + responseIdentifier: root.getAttribute('response-identifier') || RESPONSE_IDENTIFIER, + prompt: getPromptHTML(root), + rows, + distractors: responses.filter(response => !matched.has(response)), + }; +} + +/** + * Serialize MatchState → { bodyXml, responseDeclarations }. + * + * @param {MatchState} state + * @param {string} _questionType - unused (match has one question type); kept for API parity + * @param {object} declarationSchema - { baseType: string, cardinality: string } + * @returns {{ bodyXml: string, responseDeclarations: string[] }} + */ +export function buildMatchInteractionXML(state, _questionType, declarationSchema) { + const { responseIdentifier = RESPONSE_IDENTIFIER, prompt, rows = [], distractors = [] } = state; + + // Ids are unique across both match sets; the first appearance keeps its id. + const usedIds = new Set(); + function claim(id, prefix) { + const resolved = id && !usedIds.has(id) ? id : generateRandomSlug(prefix); + usedIds.add(resolved); + return resolved; + } + + // Rows and distractors are never merged, so any repeat of their id is a conflict. + const rowIds = rows.map(row => claim(row.id, 'row')); + + // Answers of equal content are one choice, serving every row that names it; + // blanks never are. + const idByAnswerKey = new Map(); + const answers = new Map(); + const correctPairs = []; + rows.forEach((row, index) => { + for (const { id, content } of row.matches) { + const key = hasRichTextContent(content) ? richTextComparisonKey(content) : ''; + const resolved = (key && idByAnswerKey.get(key)) || claim(id, 'choice'); + if (key) idByAnswerKey.set(key, resolved); + + const answer = answers.get(resolved); + if (answer) { + answer.matchMax += 1; + } else { + answers.set(resolved, { id: resolved, content, matchMax: 1 }); + } + correctPairs.push([rowIds[index], resolved]); + } + }); + + const distractorChoices = distractors.map(({ id, content }) => ({ + id: claim(id, 'choice'), + content, + matchMax: 1, + })); + + const choiceNode = ({ id, content, matchMax }) => + buildXmlNode({ + tag: 'qti-simple-associable-choice', + attrs: { identifier: id, 'match-max': matchMax }, + innerHTML: content, + }); + + const rowSet = buildXmlNode({ + tag: 'qti-simple-match-set', + children: rows.map(({ content, matches }, index) => + choiceNode({ id: rowIds[index], content, matchMax: Math.max(matches.length, 1) }), + ), + }); + const responseSet = buildXmlNode({ + tag: 'qti-simple-match-set', + children: [...answers.values(), ...distractorChoices].map(choiceNode), + }); + + const children = []; + if (prompt) { + children.push(buildXmlNode({ tag: 'qti-prompt', innerHTML: prompt })); + } + children.push(rowSet, responseSet); + + const interactionEl = buildXmlNode({ + tag: 'qti-match-interaction', + attrs: { + 'response-identifier': responseIdentifier, + shuffle: 'true', + 'max-associations': correctPairs.length, + }, + children, + }); + + const { cardinality, baseType } = declarationSchema; + const declaration = new QTIDeclaration({ + identifier: responseIdentifier, + baseType, + cardinality, + tag: 'qti-response-declaration', + }); + if (correctPairs.length > 0) { + new CorrectResponse(correctPairs, declaration); + } + + return { + bodyXml: serializer.serializeToString(interactionEl), + responseDeclarations: [serializer.serializeToString(declaration.getXML())], + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/validation.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/validation.js new file mode 100644 index 0000000000..a2e93bf78a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/validation.js @@ -0,0 +1,98 @@ +import { ValidationError } from '../../constants'; +import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText'; + +/** + * Validate MatchState → ValidationError[]. + * + * Choice-scoped errors carry `id`; row-scoped errors carry the row's `index`; + * duplication errors carry the repeated `text`, because an id may be repeated + * across choices and so cannot single one out. + * + * @param {object} state - MatchState + * @returns {Array<{ code: string, id?: string, index?: number, text?: string }>} + */ +export function validateMatchInteraction(state) { + const errors = []; + const { prompt, rows = [], distractors = [] } = state; + + if (!hasRichTextContent(prompt)) { + errors.push({ code: ValidationError.PROMPT_REQUIRED }); + } + + // Every rule below asks the same two questions of a choice, and answering + // either one parses its HTML — so each choice is read once, here. + const read = ({ id, content }) => ({ + id, + filled: hasRichTextContent(content), + key: richTextComparisonKey(content), + }); + const readRows = rows.map(row => ({ ...read(row), matches: row.matches.map(read) })); + const readDistractors = distractors.map(read); + const readChoices = [...readRows.flatMap(row => row.matches), ...readDistractors]; + + readRows.forEach(({ filled }, index) => { + if (!filled) { + errors.push({ code: ValidationError.EMPTY_ROW_CONTENT, index }); + } + }); + + for (const { id, filled } of readChoices) { + if (!filled) { + errors.push({ code: ValidationError.EMPTY_CHOICE_CONTENT, id }); + } + } + + let validRows = 0; + readRows.forEach(({ filled, matches }, index) => { + const matchKeyCounts = countFilledKeys(matches); + if (matchKeyCounts.size === 0) { + errors.push({ code: ValidationError.ROW_WITHOUT_MATCH, index }); + } else if (filled) { + validRows += 1; + } + for (const [text, count] of matchKeyCounts) { + if (count > 1) { + errors.push({ code: ValidationError.DUPLICATE_MATCH_CONTENT, index, text }); + } + } + }); + + if (validRows < 1) { + errors.push({ code: ValidationError.TOO_FEW_ROWS }); + } + + const rowKeyCounts = countFilledKeys(readRows); + readRows.forEach(({ filled, key }, index) => { + if (filled && rowKeyCounts.get(key) > 1) { + errors.push({ code: ValidationError.DUPLICATE_ROW_CONTENT, index }); + } + }); + + // An answer may serve several rows, so only distractors are flagged: one + // repeating another distractor or any answer cannot be told apart from it. + const choiceKeyCounts = countFilledKeys(readChoices); + const repeated = new Set( + readDistractors + .filter(({ filled, key }) => filled && choiceKeyCounts.get(key) > 1) + .map(({ key }) => key), + ); + for (const text of repeated) { + errors.push({ code: ValidationError.DUPLICATE_DISTRACTOR_CONTENT, text }); + } + + return errors; +} + +/** + * @param {Array<{ filled: boolean, key: string }>} readChoices + * @returns {Map} + */ +function countFilledKeys(readChoices) { + const counts = new Map(); + for (const { filled, key } of readChoices) { + if (filled) { + counts.set(key, (counts.get(key) || 0) + 1); + } + } + return counts; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index 71d674f63a..45d78c4b00 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -159,6 +159,11 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Delete distractor {number}', context: 'Accessible label for the delete icon button on a distractor', }, + saveChipBtn: { + message: 'Save', + context: + 'Button that saves the answer or distractor being edited and closes its editor; a new one joins its list', + }, editPairItemLabel: { message: 'Edit pair {number}, item {position}', context: 'Accessible label for the clickable region to edit one item of a pair', @@ -180,9 +185,87 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { context: 'Validation error when a distractor has the same content as another distractor or as an item in a pair', }, + matchingRowsLabel: { + message: 'Prompts and responses', + context: 'Section header above the list of prompts and their correct answers', + }, + matchingRowsDescription: { + message: + 'Learners match responses from a shuffled set to a fixed prompt. Include distractors to increase difficulty.', + context: 'Subtitle under the matching rows header', + }, + addRowBtn: { + message: 'Add row', + context: 'Button that appends a new matching row', + }, + deleteRowBtn: { + message: 'Delete row {number}', + context: 'Accessible label for the delete icon button next to a matching row', + }, + editRowLabel: { + message: 'Edit row {number} prompt', + context: "Accessible label for the clickable region to edit a matching row's prompt", + }, + promptColumnLabel: { + message: 'Prompt', + context: 'Column header above the prompts of the matching rows', + }, + answersColumnLabel: { + message: 'Correct matches', + context: 'Column header above the correct answers of the matching rows', + }, + answerPlaceholder: { + message: 'Enter an answer', + context: 'Placeholder shown in a matching row answer that has no content yet', + }, + rowPromptPlaceholder: { + message: 'Enter a prompt', + context: 'Placeholder shown in a matching row prompt that has no content yet', + }, + rowAnswersLabel: { + message: 'Answers for row {number}', + context: 'Accessible label for the list of correct answers in a matching row', + }, + addMatchLabel: { + message: 'Add answer to row {number}', + context: 'Accessible label for the area that starts a new answer in a matching row', + }, + editMatchLabel: { + message: 'Edit row {number}, answer {position}', + context: 'Accessible label for the clickable region to edit one answer of a matching row', + }, + deleteMatchBtn: { + message: 'Delete row {number}, answer {position}', + context: "Accessible label for the delete icon button on a matching row's answer", + }, + errorEmptyRowContent: { + message: 'Prompt cannot be blank', + context: "Validation error when a matching row's prompt is empty", + }, + errorRowWithoutMatch: { + message: 'Add at least one answer', + context: 'Validation error when a matching row has no non-blank answer', + }, + errorTooFewRows: { + message: '1 or more valid rows are required', + context: 'Validation error when no row has both a prompt and an answer', + }, + errorDuplicateMatchContent: { + message: 'Answers within a row cannot be the same', + context: 'Validation error when two answers in one row have identical content', + }, + errorDuplicateRowContent: { + message: 'Rows cannot repeat the same prompt', + context: 'Validation error when two rows have identical prompts', + }, matchLabel: { message: 'Match', - context: 'Display name for a match question type', + context: 'Display name for a match question type shown in the question type selector', + }, + matchDescription: { + message: + 'Learners match each item in one list to one or more correct items in another, where items can have multiple valid matches, and distractors can be included to increase difficulty.', + context: 'Description for the match question type in the info modal', }, extendedTextLabel: { message: 'Extended text', diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js index 409fff95b7..600d7fd911 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js @@ -78,6 +78,36 @@ export const ASSOCIATE_DECL_XML = ` + Match each animal to its biological classification class. + + Dog + Eagle + Frog + + + Bird + Mammal + Amphibian + Reptile + +`; + +export const MATCH_DECL_XML = ` + + row_dog choice_mammal + row_eagle choice_bird + row_frog choice_amphibian + +`; + +// A third match set is outside what the editor can represent. +export const MATCH_THREE_SETS_XML = MATCH_XML.replace( + '', + '', +); + // Full QTI Assessment Item XML Documents export const VALID_CHOICE_ITEM_DOCUMENT = ` @@ -314,6 +344,22 @@ export const VALID_ASSOCIATE_ITEM_DOCUMENT = ` `; +export const VALID_MATCH_ITEM_DOCUMENT = ` + + ${MATCH_DECL_XML} + + + ${MATCH_XML} + +`; + export const TWO_INTERACTIONS_DOCUMENT = ` ['markdown', 'html'].includes(v), }, + /** + * Space around the content: 'default', 'small' (8px) or 'none', for cards + * and chips. Below default, view mode also drops paragraph margins. + */ + padding: { + type: String, + default: 'default', + validator: v => ['default', 'small', 'none'].includes(v), + }, }, emits: ['update', 'minimize', 'open-editor'], }); @@ -487,4 +497,10 @@ margin: 4px 0; } + /* A card or chip with reduced padding shows its content as one line, which + paragraph margins would push past that padding. */ + .editor-container.view-mode:not(.is-padded) p { + margin: 0; + } + diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorContentWrapper.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorContentWrapper.vue index 64095e6f5f..e8d060ff4f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorContentWrapper.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorContentWrapper.vue @@ -1,6 +1,9 @@ - + @@ -48,6 +57,14 @@ overflow: hidden; } + .editor-content.padding-small { + padding: 8px; + } + + .editor-content.padding-none { + padding: 0; + } + .tiptap-editor { outline: none; }
Match each animal with its traits:
Hawk
Vertebrate
Canine
Fish
Updated
Match each animal to its class.
Mammal
Match each animal to its biological classification class.
Bird
Dog
Reptile