Skip to content

Commit 7dcd09e

Browse files
AlexVelezLlclaude
andcommitted
feat: read and edit the hints of a converted legacy question
QTI has no hint element, so the conversion carries a legacy question's hints in the item's <qti-catalog-info> — dormant content the delivery engine never renders on its own — as cards tagged with a Kolibri support value. Studio already writes them there on read and reads them back out on publish; the editor in between did neither, so opening a converted question and saving it dropped every hint it had. Hints belong to the item rather than to any one interaction, so they come out of parseItem beside `interactions` rather than inside them, and useQtiItem holds them next to identifier and title — a ref the card mutates and the rawData computed reads. Cards are matched on their support value rather than the catalog they sit in, the same way the publish-side derivation does: a catalog id is a name, the support value is the contract. assembleItemXml writes the catalog after the item body, which is where the schema wants it — the one mistake this shape can make and still look right. A hint with nothing written in it is left out, and an item whose hints are all empty carries no catalog at all rather than an empty one, since a catalog has to hold at least one card. Two backend tests pin the result: the document this editor emits is schema-valid, and moving the catalog ahead of the body is not; and derive_perseus_item still recovers the hints from it, which is what makes editing a converted question safe to publish. Only a question that arrived with hints offers the section. That is a scope limit, not a technical one: assembleItemXml writes the catalog for whatever hints it is handed, and publishing derives legacy hints from the cards' support value without asking where the item came from, so hints authored here would survive too — except on the shapes Perseus cannot express, ordering and free response, where the QTI package would keep them and the derived Perseus item would not. Whether to offer them everywhere waits on that. The gate is read once from the parsed item rather than from the live list, so removing the last hint does not take the section away while the author is still working in it. A hint is the only thing an author can change on a question with nothing to answer, and that turned out to be enough to lose the question. Such an item has a body and no interaction, so no interaction editor mounts and the card held no body of its own; reassembling from that wrote an empty <qti-item-body/> over the question's text. The body is read from the item now and seeded whether or not an interaction was found. HintsSection keeps the shape and styling of the HintsEditor it replaces: bordered cards that open one at a time into a rich-text editor, and the same move/delete actions and dashed add button, now drawn with the components the interaction editors already share. Its header is labelled like the "Question" and "Answers" fields beside it and tints on hover the way a flat button does, so it reads as one of the editor's own sections rather than something bolted underneath. A card is only inviting where clicking it does something: in view mode the reader gets no pointer and no hover tint, because there is nothing to open. Its heading joins the ones already in the card: the field labels the interaction editors put above the question and the answers become headings at the same level, so a question reads as a section with named parts rather than a run of bold text. They set their own margins, since a heading brings the browser stylesheet's. The header is a disclosure, built like the one the community-library modal uses: a button that says whether what it controls is showing, inside a heading, so the hints sit under the question in the document outline. The controlled element stays in the document and is hidden rather than removed, so aria-controls always resolves, while what it holds is mounted only while open — a rich-text editor per hint per question is not worth paying for unseen. Theme values are bound in the style block rather than assembled in JavaScript, so the component needs no instance handle: the focus ring is :focus-visible, which distinguishes keyboard from pointer focus the same way $coreOutline does, without asking the theme plugin. Hints are supplementary to the question, so a closed card keeps them out of the way until the reader asks for answers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bcfe223 commit 7dcd09e

18 files changed

Lines changed: 1303 additions & 32 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { render, screen, fireEvent } from '@testing-library/vue';
2+
import VueRouter from 'vue-router';
3+
import HintsSection from '../index.vue';
4+
import { qtiEditorStrings } from '../../../qtiEditorStrings';
5+
6+
jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
7+
jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
8+
const { ref } = require('vue');
9+
return {
10+
__esModule: true,
11+
default: () => ({ windowIsSmall: ref(false) }),
12+
};
13+
});
14+
15+
const {
16+
hintsLabel$,
17+
noHintsPlaceholder$,
18+
hintPlaceholder$,
19+
editHintLabel$,
20+
addHintBtn$,
21+
deleteHintBtn$,
22+
moveHintUpBtn$,
23+
moveHintDownBtn$,
24+
} = qtiEditorStrings;
25+
26+
const HINTS = [
27+
{ id: 'hint_a', content: '<p>test</p>' },
28+
{ id: 'hint_b', content: '<p>test2 2</p>' },
29+
{ id: 'hint_c', content: '<p>test3 3</p>' },
30+
];
31+
32+
const renderComponent = (props = {}) =>
33+
render(HintsSection, {
34+
props: { hints: HINTS, mode: 'edit', ...props },
35+
routes: new VueRouter(),
36+
});
37+
38+
const expand = async () => {
39+
await fireEvent.click(screen.getByRole('button', { name: hintsLabel$() }));
40+
};
41+
42+
describe('HintsSection', () => {
43+
it('starts collapsed, showing only the header', () => {
44+
renderComponent();
45+
expect(screen.getByText(hintsLabel$())).toBeInTheDocument();
46+
expect(screen.queryAllByTestId('hint')).toHaveLength(0);
47+
});
48+
49+
it('lists the hints once expanded', async () => {
50+
renderComponent();
51+
await expand();
52+
expect(screen.getAllByTestId('hint')).toHaveLength(3);
53+
});
54+
55+
it('reports the section as expanded to assistive technology', async () => {
56+
renderComponent();
57+
const toggle = screen.getByRole('button', { name: hintsLabel$() });
58+
expect(toggle).toHaveAttribute('aria-expanded', 'false');
59+
await expand();
60+
expect(toggle).toHaveAttribute('aria-expanded', 'true');
61+
});
62+
63+
it('says so when the question has no hints left', async () => {
64+
renderComponent({ hints: [] });
65+
await expand();
66+
expect(screen.getByText(noHintsPlaceholder$())).toBeInTheDocument();
67+
});
68+
69+
it('shows a placeholder for a hint with nothing written in it', async () => {
70+
renderComponent({ hints: [{ id: 'hint_a', content: '' }] });
71+
await expand();
72+
expect(screen.getByText(hintPlaceholder$({ index: 1 }))).toBeInTheDocument();
73+
});
74+
75+
describe('editing', () => {
76+
it('appends an empty hint', async () => {
77+
const { emitted } = renderComponent();
78+
await expand();
79+
await fireEvent.click(screen.getByRole('button', { name: addHintBtn$() }));
80+
81+
const [hints] = emitted()['update:hints'].at(-1);
82+
expect(hints).toHaveLength(4);
83+
expect(hints[3].content).toBe('');
84+
});
85+
86+
it('removes the hint whose delete action was used', async () => {
87+
const { emitted } = renderComponent();
88+
await expand();
89+
await fireEvent.click(screen.getAllByRole('button', { name: deleteHintBtn$() })[1]);
90+
91+
const [hints] = emitted()['update:hints'].at(-1);
92+
expect(hints.map(h => h.id)).toEqual(['hint_a', 'hint_c']);
93+
});
94+
95+
it('moves a hint up', async () => {
96+
const { emitted } = renderComponent();
97+
await expand();
98+
await fireEvent.click(screen.getAllByRole('button', { name: moveHintUpBtn$() })[2]);
99+
100+
const [hints] = emitted()['update:hints'].at(-1);
101+
expect(hints.map(h => h.id)).toEqual(['hint_a', 'hint_c', 'hint_b']);
102+
});
103+
104+
it('moves a hint down', async () => {
105+
const { emitted } = renderComponent();
106+
await expand();
107+
await fireEvent.click(screen.getAllByRole('button', { name: moveHintDownBtn$() })[0]);
108+
109+
const [hints] = emitted()['update:hints'].at(-1);
110+
expect(hints.map(h => h.id)).toEqual(['hint_b', 'hint_a', 'hint_c']);
111+
});
112+
113+
it('cannot move the first hint up or the last one down', async () => {
114+
renderComponent();
115+
await expand();
116+
expect(screen.getAllByRole('button', { name: moveHintUpBtn$() })[0]).toBeDisabled();
117+
expect(screen.getAllByRole('button', { name: moveHintDownBtn$() })[2]).toBeDisabled();
118+
});
119+
});
120+
121+
describe('opening a hint from the keyboard', () => {
122+
it('reaches each hint through a button that names which one it is', async () => {
123+
renderComponent();
124+
await expand();
125+
expect(
126+
screen.getByRole('button', { name: editHintLabel$({ number: 2 }) }),
127+
).toBeInTheDocument();
128+
});
129+
130+
it('opens the hint that button belongs to, and stops offering it', async () => {
131+
renderComponent();
132+
await expand();
133+
await fireEvent.click(screen.getByRole('button', { name: editHintLabel$({ number: 2 }) }));
134+
expect(
135+
screen.queryByRole('button', { name: editHintLabel$({ number: 2 }) }),
136+
).not.toBeInTheDocument();
137+
expect(
138+
screen.getByRole('button', { name: editHintLabel$({ number: 1 }) }),
139+
).toBeInTheDocument();
140+
});
141+
});
142+
143+
describe('view mode', () => {
144+
it('offers no way to change the hints', async () => {
145+
renderComponent({ mode: 'view' });
146+
await expand();
147+
expect(screen.getAllByTestId('hint')).toHaveLength(3);
148+
expect(screen.queryByRole('button', { name: addHintBtn$() })).not.toBeInTheDocument();
149+
expect(screen.queryByRole('button', { name: deleteHintBtn$() })).not.toBeInTheDocument();
150+
});
151+
152+
it('offers no clickable region for a hint that cannot be edited', async () => {
153+
renderComponent({ mode: 'view' });
154+
await expand();
155+
expect(
156+
screen.queryByRole('button', { name: editHintLabel$({ number: 1 }) }),
157+
).not.toBeInTheDocument();
158+
});
159+
});
160+
});

0 commit comments

Comments
 (0)