Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## QTI Editor

Edits an exercise's assessment items stored as [QTI 3](https://www.imsglobal.org/spec/qti/v3p0/impl) XML in `raw_data`. Rendered by `channelEdit/components/AssessmentTab/AssessmentTab.vue`.

### Flow
1. `index.vue` lists the items; each is a `components/QTIItemEditor`.
2. `composables/useQtiItem.js` parses `raw_data` with `serialization/parseItem.js` into interaction blocks (`bodyXml` + `responseDeclarations`), hints and item metadata.
3. `components/InteractionSection` resolves each block's descriptor and question type with `composables/useInteractionDescriptor.js`.
4. The interaction's `Editor.vue` (`interactions/index.js`) edits a plain state object through `composables/useInteraction.js`: `descriptor.parse` → state → `descriptor.buildXML` → `descriptor.validate`.
5. `useQtiItem` rebuilds the full item with `serialization/assembleItem.js`; `QTIItemEditor` emits `update:rawData`.

`validateItem.js` `validateQtiItem` runs the same parse and validation headless, without the Vue editors.

### Layout
- `interactions/<type>/` — one per QTI interaction:
- `Descriptor.js` — subclass of `InteractionDescriptor`: matches the element, picks the question type, delegates to `parse.js` and `validation.js`.
- `parse.js` — XML ↔ state.
- `validation.js` — state → error codes.
- `Editor.vue` — the UI; absent for headless interactions (`HEADLESS_INTERACTIONS`).
- `interactions/descriptors.js` — descriptor registry, free of `.vue` imports. `interactions/index.js` adds the editors.
- `serialization/` — item-level parse/assemble, hints (`qti-catalog-info`), XML helpers (`xml.js`), response declarations (`qti/`).
- `components/` — shared UI: hints, chip lists, toolbars, question type selector.

### Adding an interaction
1. Create `interactions/<type>/` with `Descriptor.js`, `parse.js`, `validation.js` and `Editor.vue`.
2. Register the descriptor in `interactions/descriptors.js` and the editor in `interactions/index.js`.
3. Add its question types to `constants.js` and strings to `qtiEditorStrings.js`.

### Rich text fields
Prompts, choices, items and hints are edited in `TipTapEditor` with `format="html"`.

> [!IMPORTANT]
> In `parse`, read any field TipTap loads as HTML with `getContentHTML` (or `getPromptHTML`) from `serialization/xml.js`, never `innerHTML` or `XMLSerializer`. XML serialization self-closes empty elements (`<span data-latex="…"/>`); the HTML parser doesn't treat `/>` as closing, so everything after it is nested inside and lost.
>
> On build, pass the HTML to `buildXmlNode({ innerHTML })`, which parses it as HTML and re-creates it as XML.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import flatMap from 'lodash/flatMap';
import flatten from 'lodash/flatten';
import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration';
import { buildXmlNode, getPromptHTML, parseXML } from '../../serialization/xml';
import { buildXmlNode, getContentHTML, getPromptHTML, parseXML } from '../../serialization/xml';
import CorrectResponse from '../../serialization/qti/declarations/correctResponse';
import { generateRandomSlug } from '../../utils/generateRandomSlug';
import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText';
Expand Down Expand Up @@ -82,7 +82,7 @@ export function parseAssociateInteraction(bodyXml, responseDeclarations) {

const pool = [...root.querySelectorAll('qti-simple-associable-choice')].map(el => ({
id: el.getAttribute('identifier') || generateRandomSlug('choice'),
content: el.innerHTML,
content: getContentHTML(el),
matchMax: parseInt(el.getAttribute('match-max'), 10) || 1,
}));
const poolById = new Map(pool.map(choice => [choice.id, choice]));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,25 @@ describe('validate()', () => {
expect(errorIds).not.toContain('e');
});

it('flags a stored formula option and the same formula typed in again', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (pre-existing, follow-up): This makes a formula-only option match its retyped twin, which is right. The opposite case is still broken: richTextComparisonKey uses only the text whenever there is any, and formulas contribute no text. So <p>Solve <span data-latex="x^2"></span> for x</p> and <p>Solve <span data-latex="y^3"></span> for x</p> both key to "Solve for x". I confirmed that buildMatchInteractionXML then merges two rows' answers x² is it / y³ is it into a single choice (m1, match-max="2"), and the y³ content is lost. Choice/ordering validation will also report them as duplicates. Now that formula text survives reopening, authors are more likely to hit this. Could we file an issue to include data-latex (and maybe img src) in the key? Not for this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outside this PR's scope — tracked as #6301 under #6103.


Written by rtibblesbot, an LLM-based coding agent.

const { choices } = choiceInteractionDescriptor.parse(
`<qti-choice-interaction response-identifier="RESPONSE" max-choices="1">
<qti-simple-choice identifier="a"><p><span data-latex="x^2"/></p></qti-simple-choice>
</qti-choice-interaction>`,
[],
);
const state = makeState({
choices: [
...choices,
makeAnswer({ id: 'z', content: '<p><span data-latex="x^2"></span></p>' }),
],
});
const errors = validate(state, QuestionType.SINGLE_SELECT);
expect(
errors.filter(e => e.code === ValidationError.DUPLICATE_CHOICE_CONTENT).map(e => e.id),
).toEqual(['a', 'z']);
});

it('does not return error when choices have unique text content', () => {
const state = makeState({
choices: [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration';
import { buildXmlNode, getPromptHTML, parseXML } from '../../serialization/xml';
import { buildXmlNode, getContentHTML, getPromptHTML, parseXML } from '../../serialization/xml';
import CorrectResponse from '../../serialization/qti/declarations/correctResponse';
import { generateRandomSlug } from '../../utils/generateRandomSlug';
import { Orientation, QuestionType, RESPONSE_IDENTIFIER } from '../../constants';
Expand Down Expand Up @@ -90,7 +90,7 @@ export function parseChoiceInteraction(bodyXml, responseDeclarations) {

const choices = [...root.querySelectorAll('qti-simple-choice')].map(el => ({
id: el.getAttribute('identifier') || generateRandomSlug('choice'),
content: el.innerHTML,
content: getContentHTML(el),
correct: correctIds.has(el.getAttribute('identifier') ?? ''),
fixed: el.getAttribute('fixed') === 'true',
}));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration';
import { buildXmlNode, getPromptHTML, parseXML } from '../../serialization/xml';
import { buildXmlNode, getContentHTML, getPromptHTML, parseXML } from '../../serialization/xml';
import CorrectResponse from '../../serialization/qti/declarations/correctResponse';
import { generateRandomSlug } from '../../utils/generateRandomSlug';
import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText';
Expand Down Expand Up @@ -72,7 +72,7 @@ function matchSets(el) {
function readSet(setEl, prefix) {
return [...setEl.querySelectorAll('qti-simple-associable-choice')].map(el => ({
id: el.getAttribute('identifier') || generateRandomSlug(prefix),
content: el.innerHTML,
content: getContentHTML(el),
}));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration';
import { buildXmlNode, getPromptHTML, parseXML } from '../../serialization/xml';
import { buildXmlNode, getContentHTML, getPromptHTML, parseXML } from '../../serialization/xml';
import CorrectResponse from '../../serialization/qti/declarations/correctResponse';
import { generateRandomSlug } from '../../utils/generateRandomSlug';
import { Orientation, RESPONSE_IDENTIFIER } from '../../constants';
Expand Down Expand Up @@ -79,7 +79,7 @@ export function parseOrderingInteraction(bodyXml, responseDeclarations) {

const rawItems = [...root.querySelectorAll('qti-simple-choice')].map(el => ({
id: el.getAttribute('identifier') || generateRandomSlug('order'),
content: el.innerHTML,
content: getContentHTML(el),
fixed: el.getAttribute('fixed') === 'true',
}));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { assembleItemXml } from '../assembleItem';
import { parseItem } from '../parseItem';
import { choiceInteractionDescriptor } from '../../interactions/choice/Descriptor';
import { orderingInteractionDescriptor } from '../../interactions/ordering/Descriptor';
import { matchInteractionDescriptor } from '../../interactions/match/Descriptor';
import { associateInteractionDescriptor } from '../../interactions/associate/Descriptor';
import { useEditor } from '../../../TipTapEditor/TipTapEditor/composables/useEditor';
import { QuestionType } from '../../constants';

const CONTENT = '<p>Solve <span data-latex="x^2"></span> for x</p>';

const INTERACTIONS = [
{
name: 'choice',
descriptor: choiceInteractionDescriptor,
questionType: QuestionType.SINGLE_SELECT,
state: {
prompt: CONTENT,
choices: [
{ id: 'a', content: CONTENT, correct: true, fixed: false },
{ id: 'b', content: '<p>Other</p>', correct: false, fixed: false },
],
shuffle: false,
orientation: 'vertical',
showAnswerCount: true,
},
option: state => state.choices[0].content,
},
{
name: 'ordering',
descriptor: orderingInteractionDescriptor,
questionType: QuestionType.ORDERING,
state: {
prompt: CONTENT,
items: [
{ id: 'a', content: CONTENT },
{ id: 'b', content: '<p>Other</p>' },
],
orientation: 'vertical',
shuffle: true,
},
option: state => state.items[0].content,
},
{
name: 'match',
descriptor: matchInteractionDescriptor,
questionType: QuestionType.MATCH,
state: {
prompt: CONTENT,
rows: [{ id: 'row_a', content: CONTENT, matches: [{ id: 'b', content: '<p>Other</p>' }] }],
distractors: [],
},
option: state => state.rows[0].content,
},
{
name: 'associate',
descriptor: associateInteractionDescriptor,
questionType: QuestionType.ASSOCIATE,
state: {
prompt: CONTENT,
pairs: [
[
{ id: 'a', content: CONTENT },
{ id: 'b', content: '<p>Other</p>' },
],
],
distractors: [],
},
option: state => state.pairs[0][0].content,
},
];

const load = html => {
const { initializeEditor, editor } = useEditor();
initializeEditor(html, 'edit');
const loaded = editor.value.getHTML();
editor.value.destroy();
return loaded;
};

const buildItem = interaction => {
const { bodyXml, responseDeclarations } = interaction.descriptor.buildXML(
interaction.state,
interaction.questionType,
);
return parseItem(
assembleItemXml({
identifier: 'item_1',
title: 'Question',
language: 'en',
bodyXml,
responseDeclarations,
hints: [{ id: 'hint_1', content: CONTENT }],
}),
);
};

describe.each(INTERACTIONS)('a $name item with text after a formula', interaction => {
let state;

beforeAll(() => {
const [parsed] = buildItem(interaction).interactions;
state = interaction.descriptor.parse(parsed.bodyXml, parsed.responseDeclarations);
});

it.each([
['prompt', () => state.prompt],
['option', () => interaction.option(state)],
])('keeps the text in its %s once loaded into the editor', (_, read) => {
expect(load(read())).toBe(CONTENT);
});
});

it('keeps the text after a formula in a hint once loaded into the editor', () => {
const item = buildItem(INTERACTIONS[0]);
expect(load(item.hints[0].content)).toBe(CONTENT);
});
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ describe('assembleItemXml with hints', () => {
hints: [{ id: 'a', content: '<p><img src="abc123.png" alt=""/></p>' }],
});
expect(parseHints(parseXML(xml)).map(h => h.content)).toEqual([
'<p><img src="abc123.png" alt=""/></p>',
'<p><img src="abc123.png" alt=""></p>',
]);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,27 @@ describe('serializeAsHtml', () => {
expect(math.namespaceURI).toBe(MATHML_NS);
});

const breakout = tag => `&lt;/${tag}&gt;&lt;img src="x"/&gt;`;

it.each(['noscript', 'script', 'style'])('drops a <%s>', tag => {
const html = serializeAsHtml(
children(`<root><p><${tag}>${breakout(tag)}</${tag}>after</p></root>`),
);
expect(html).toBe('<p>after</p>');
expect(parseXML(html, 'text/html').querySelector('img')).toBeNull();
});

it.each(['iframe', 'noembed', 'noframes', 'plaintext', 'xmp'])(
'keeps the text of a <%s> as escaped text, so markup in it does not come back live',
tag => {
const html = serializeAsHtml(
children(`<root><p>a<${tag}>${breakout(tag)}</${tag}>after</p></root>`),
);
expect(html).toBe(`<p>a${breakout(tag)}after</p>`);
expect(parseXML(html, 'text/html').querySelector('img')).toBeNull();
},
);

it('skips comments', () => {
expect(serializeAsHtml(children('<root><!-- note --><p>a</p></root>'))).toBe('<p>a</p>');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,14 @@

import { generateRandomSlug } from '../utils/generateRandomSlug';
import { hasRichTextContent } from '../utils/richText';
import { getContentHTML } from './xml';

/** The catalog this editor writes hints into. */
export const HINT_CATALOG_ID = 'kolibri-hints';

/** The support value that marks a card as a hint. Mirrors qti/catalog.py. */
export const HINT_SUPPORT = 'ext:kolibri-hint';

/**
* The item's own namespace, which its content inherits from the root and therefore does
* not declare. Serializing a subtree on its own re-declares it on every top-level
* element, so reading a card's markup back out of the document reintroduces a
* declaration that was never in the stored XML. Dropped by value rather than by pattern,
* so a foreign namespace a hint legitimately carries — MathML from the formula button —
* is left alone.
*/
const QTI_NAMESPACE_DECLARATION = / xmlns="http:\/\/www\.imsglobal\.org\/xsd\/imsqtiasi_v3p0"/g;

/**
* Read the item's hints, in document order.
*
Expand All @@ -52,9 +43,7 @@ export function parseHints(doc) {
id: generateRandomSlug('hint'),
// Pretty-printed XML puts the card's indentation inside the element, and the
// editor would otherwise open on a stray blank line.
content: htmlContent
? htmlContent.innerHTML.replace(QTI_NAMESPACE_DECLARATION, '').trim()
: '',
content: htmlContent ? getContentHTML(htmlContent).trim() : '',
};
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,32 @@ export function parseXML(xmlString, mimeType = 'text/xml') {
}

/**
* Extract the inner HTML of the first <qti-prompt> child of an interaction element.
* Extract the markup of the first <qti-prompt> child of an interaction element, as HTML.
* Returns an empty string when no prompt element is present.
* Using innerHTML (not textContent) preserves rich inline markup (<p>, <strong>, etc.)
* for round-trip fidelity.
*
* @param {Element} interactionEl - The <qti-*-interaction> root element
* @returns {string}
*/
export function getPromptHTML(interactionEl) {
const promptEl = interactionEl.querySelector('qti-prompt');
return promptEl ? promptEl.innerHTML : '';
return promptEl ? getContentHTML(promptEl) : '';
}

/**
* An element's content as HTML, for a rich text editor to load. Not `innerHTML`: see
* serializeAsHtml.
*
* @param {Element} el
* @returns {string}
*/
export function getContentHTML(el) {
return serializeAsHtml([...el.childNodes], el.namespaceURI);
}

const xmlDoc = parser.parseFromString('<root/>', 'text/xml');
const XHTML_NS = 'http://www.w3.org/1999/xhtml';
const DROPPED_ELEMENTS = new Set(['noscript', 'script', 'style']);
const RAW_TEXT_ELEMENTS = new Set(['iframe', 'noembed', 'noframes', 'plaintext', 'xmp']);

/**
* Re-create a node parsed from HTML inside the XML document.
Expand All @@ -74,7 +85,8 @@ const XHTML_NS = 'http://www.w3.org/1999/xhtml';
* @param {Document} [doc] - Document to re-create the node in
* @param {string|null} [plainNamespace] - Namespace whose elements become the document's
* default ones; any other is kept
* @returns {Node|null} null for node types that carry no content (comments, etc.)
* @returns {Node|null} null for node types that carry no content (comments, etc.) and, when
* re-creating into an HTML document, for noscript, script and style elements
*/
function adoptNode(node, doc = xmlDoc, plainNamespace = XHTML_NS) {
if (node.nodeType === Node.TEXT_NODE || node.nodeType === Node.CDATA_SECTION_NODE) {
Expand All @@ -90,6 +102,18 @@ function adoptNode(node, doc = xmlDoc, plainNamespace = XHTML_NS) {
? doc.createElement(node.localName)
: doc.createElementNS(namespace, node.tagName);

// The HTML serializer writes these elements' text unescaped, so escaped markup in it
// would come back live. TipTap ignores noscript, script and style, and keeps only the
// others' text.
if (el.namespaceURI === XHTML_NS) {
if (DROPPED_ELEMENTS.has(el.localName)) {
return null;
}
if (RAW_TEXT_ELEMENTS.has(el.localName)) {
return doc.createTextNode(node.textContent);
}
}

for (const attr of node.attributes) {
// A literal xmlns attribute would re-introduce the namespace we just dropped.
if (attr.name !== 'xmlns') {
Expand Down Expand Up @@ -138,9 +162,9 @@ export function isContentNode(node) {
* Serialize XML nodes as an HTML string, for state that a rich text editor parses as HTML.
*
* XMLSerializer writes an empty element as `<x/>`, and the HTML parser does not treat `/>` as
* self-closing on unknown elements such as QTI's, so the following siblings end up nested
* inside it. It also writes `xmlns` on elements in the item's namespace. Re-creating the nodes
* in an HTML document gives every element an explicit end tag and no `xmlns`; foreign
* self-closing on non-void elements such as `<span>` or QTI's, so the following siblings end up
* nested inside it. It also writes `xmlns` on elements in the item's namespace. Re-creating the
* nodes in an HTML document gives every element an explicit end tag and no `xmlns`; foreign
* subtrees (MathML, SVG) keep their namespace.
*
* @param {Node[]} nodes
Expand Down
Loading
Loading