emit('update:interaction', newVal), { immediate: true });
+ // Errors are reported the same way, for the card to show that the question needs work.
+ watch(errors, newVal => emit('update:errors', newVal), { immediate: true });
+
const answersDescription = computed(() =>
isSingleSelect.value
? answersDescriptionSingleChoice$()
@@ -553,7 +556,7 @@
},
},
- emits: ['update:interaction'],
+ emits: ['update:interaction', 'update:errors'],
};
@@ -567,8 +570,9 @@
gap: 16px;
}
+ /* A heading, so its own margins are set rather than inherited from the UA stylesheet */
.field-label {
- margin-bottom: 8px;
+ margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Descriptor.spec.js
similarity index 96%
rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionDescriptor.spec.js
rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Descriptor.spec.js
index bd321d465d..60dc3a9bca 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionDescriptor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Descriptor.spec.js
@@ -1,4 +1,4 @@
-import { ChoiceInteractionDescriptor } from '../ChoiceInteractionDescriptor';
+import { ChoiceInteractionDescriptor } from '../Descriptor';
import { BaseType, Cardinality, QtiInteraction, QuestionType } from '../../../constants';
describe('ChoiceInteractionDescriptor', () => {
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Editor.spec.js
similarity index 95%
rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js
rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Editor.spec.js
index 7cf59d9378..5a85fbafee 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Editor.spec.js
@@ -1,7 +1,7 @@
import { render, screen, fireEvent, within } from '@testing-library/vue';
import { nextTick } from 'vue';
import VueRouter from 'vue-router';
-import ChoiceInteractionEditor from '../ChoiceInteractionEditor.vue';
+import ChoiceInteractionEditor from '../Editor.vue';
import {
CHOICE_SINGLE_SELECT_XML,
@@ -282,46 +282,49 @@ describe('ChoiceInteractionEditor', () => {
});
describe('validation', () => {
- it('does not show errors before any field is touched', () => {
+ it('reports what is missing as soon as it renders', () => {
+ // Validation is not debounced, so errors describe the state on screen from the start:
+ // this fixture has no declaration, so no choice is marked correct.
renderEditor({
interaction: block(CHOICE_SINGLE_SELECT_XML),
questionType: QuestionType.SINGLE_SELECT,
});
+
+ expect(screen.getByText(tr.errorNoCorrectAnswer$())).toBeInTheDocument();
+ });
+
+ it('shows no errors for a question that is already complete', () => {
+ renderEditor({
+ interaction: blockWithDecl(CHOICE_SINGLE_SELECT_XML, SINGLE_DECL),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('shows global errors (no correct choice) after a structural mutation', async () => {
- jest.useFakeTimers();
// Add a choice so we have 2+ choices — then the only error is no correct choice.
renderEditor({
interaction: block(CHOICE_SINGLE_SELECT_XML),
questionType: QuestionType.SINGLE_SELECT,
});
- // Clicking Add choice mutates state → debounced validate fires.
+ // Clicking Add choice mutates state, which validates straight away.
await fireEvent.click(screen.getByRole('button', { name: /add choice/i }));
- // Flush Vue watcher queue.
- await nextTick();
- // Advance past the 400ms debounce, then flush the resulting DOM update.
- jest.advanceTimersByTime(400);
await nextTick();
- jest.useRealTimers();
+
// NO_CORRECT_ANSWER (and potentially others) should be shown after validation runs.
expect(screen.getAllByRole('alert').length).toBeGreaterThan(0);
});
- it('shows no-correct-choice error after toggling and running validation', async () => {
- jest.useFakeTimers();
+ it('shows the empty-choice error as soon as a choice is added', async () => {
renderEditor({
interaction: blockWithDecl(CHOICE_SINGLE_SELECT_XML, SINGLE_DECL),
questionType: QuestionType.SINGLE_SELECT,
});
- // Trigger validation via add-choice which mutates state → debounced validate fires.
await fireEvent.click(screen.getByRole('button', { name: /add choice/i }));
await nextTick();
- jest.advanceTimersByTime(400);
- await nextTick();
- jest.useRealTimers();
- // Validate fires; errors should appear (e.g. empty choice content).
+
+ expect(screen.getByText(tr.errorEmptyChoiceContent$())).toBeInTheDocument();
});
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js
index 53f5f286bf..0ba74d36ed 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js
@@ -2,7 +2,7 @@
// The eslint-dom matchers reject XML nodes produced by DOMParser(..., 'text/xml').
// Native DOM APIs (getAttribute, textContent) work correctly on XML elements.
-import { choiceInteractionDescriptor } from '../ChoiceInteractionDescriptor';
+import { choiceInteractionDescriptor } from '../Descriptor';
import {
CHOICE_SINGLE_SELECT_XML,
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.spec.js
similarity index 98%
rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js
rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.spec.js
index b333a3bee3..bcf9119cc5 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.spec.js
@@ -1,4 +1,4 @@
-import { choiceInteractionDescriptor } from '../ChoiceInteractionDescriptor';
+import { choiceInteractionDescriptor } from '../Descriptor';
import { ValidationError, QuestionType, Orientation } from '../../../constants';
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js
deleted file mode 100644
index 966cc2dd7e..0000000000
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import defineInteraction from '../defineInteraction';
-import ChoiceInteractionEditor from './ChoiceInteractionEditor.vue';
-import { choiceInteractionDescriptor } from './ChoiceInteractionDescriptor';
-
-export default defineInteraction(choiceInteractionDescriptor, ChoiceInteractionEditor);
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js
index d9c83d4872..4293ef5d40 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js
@@ -1,5 +1,5 @@
import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration';
-import { getPromptHTML, parseXML } from '../../serialization/parseItem';
+import { getPromptHTML, parseXML } from '../../serialization/xml';
import { buildXmlNode } from '../../serialization/assembleItem';
import CorrectResponse from '../../serialization/qti/declarations/correctResponse';
import { generateRandomSlug } from '../../utils/generateRandomSlug';
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js
deleted file mode 100644
index f08d273877..0000000000
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Required keys every interaction descriptor must provide.
- * Validated at import time so missing fields surface immediately during development.
- */
-const REQUIRED_KEYS = [
- 'type',
- 'placement',
- 'questionTypes',
- 'editorComponent',
- 'convertsFrom',
- 'matches',
- 'getQuestionType',
- 'getResponseDeclarationSchema',
- 'parse',
- 'buildXML',
- 'validate',
-];
-
-/**
- * Validates that a descriptor has every required key and returns it unchanged.
- * Throws at call-time (i.e. module import time) if any key is absent.
- *
- * Pass the Vue editor component as the second argument to attach it to the
- * descriptor here rather than mutating the descriptor after construction.
- *
- * @template {object} T
- * @param {T} descriptor - The interaction descriptor to validate
- * @param {object} editorComponent - The Vue component that edits this interaction
- * @returns {T} The same descriptor, with editorComponent attached
- * @throws {Error} If any required key is missing from the descriptor
- */
-export default function defineInteraction(descriptor, editorComponent) {
- // Attach editorComponent before validation so the required-key check can
- // confirm it is present even when the descriptor class does not set it.
- descriptor.editorComponent = editorComponent;
-
- for (const key of REQUIRED_KEYS) {
- // Use a truthiness check for editorComponent (a Vue component object) so
- // that passing `undefined` as the second argument is caught as missing.
- const isMissing = key === 'editorComponent' ? !descriptor[key] : !(key in descriptor);
- if (isMissing) {
- const name = descriptor.type ?? '(unknown)';
- throw new Error(`defineInteraction: missing required key "${key}" on descriptor "${name}"`);
- }
- }
- return descriptor;
-}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js
new file mode 100644
index 0000000000..2b9fadd2e1
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js
@@ -0,0 +1,46 @@
+import { Placement, QtiInteraction } from '../constants';
+import { choiceInteractionDescriptor } from './choice/Descriptor';
+import { textEntryInteractionDescriptor } from './textEntry/Descriptor';
+import { orderingInteractionDescriptor } from './ordering/Descriptor';
+
+/**
+ * Every interaction's descriptor: matching, parsing, building and validating XML.
+ *
+ * This module imports `Descriptor.js` files only so that headless validation can be done withou
+ * the bundle size cost of the editors.
+ *
+ * Registering a new interaction means adding its descriptor here and its editor in index.js
+ */
+
+/**
+ * The default interaction type used as fallback when no descriptor matches
+ * the interaction element found in the XML body.
+ */
+export const DEFAULT_INTERACTION = QtiInteraction.CHOICE;
+
+/**
+ * Ordered list of all registered interaction descriptors.
+ * Searched in order; the first whose `matches(el)` returns true wins.
+ */
+export const descriptors = [
+ choiceInteractionDescriptor,
+ textEntryInteractionDescriptor,
+ orderingInteractionDescriptor,
+];
+
+/**
+ * @type {Object.
}
+ */
+export const registry = Object.fromEntries(descriptors.map(d => [d.type, d]));
+
+/**
+ * Whether an interaction is authored inline, and so needs the whole item body to parse
+ * rather than its own element. Read off the descriptor's placement, so declaring it there
+ * is all a new inline interaction has to do.
+ *
+ * @param {string} tagName - The interaction's XML tag name, lower-cased
+ * @returns {boolean}
+ */
+export function isInlineInteraction(tagName) {
+ return registry[tagName]?.placement === Placement.INLINE;
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js
index 107a549a6b..1bad224177 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js
@@ -1,34 +1,20 @@
import { QtiInteraction } from '../constants';
-import choiceDescriptor from './choice/index';
-import textEntryDescriptor from './textEntry/index';
-import orderingDescriptor from './ordering/index';
+import ChoiceEditor from './choice/Editor.vue';
+import TextEntryEditor from './textEntry/Editor.vue';
+import OrderingEditor from './ordering/Editor.vue';
/**
- * The default interaction type used as fallback when no descriptor matches
- * the interaction element found in the XML body.
- */
-export const DEFAULT_INTERACTION = QtiInteraction.CHOICE;
-
-/**
- * Ordered list of all registered interaction descriptors.
- * Searched in order; the first whose `matches(el)` returns true wins.
- */
-export const descriptors = [choiceDescriptor, textEntryDescriptor, orderingDescriptor];
-
-/**
- * Registry map keyed by descriptor.type for O(1) direct lookup.
- * Built from the descriptors array — do not populate manually.
+ * Entry point for the editor tree: the descriptors, plus the Vue component that edits each
+ * interaction.
*
- * @type {Object.}
+ * The editors live here rather than on the descriptors themselves so that `./descriptors`
+ * stays free of `.vue` files — see the note there. Import this module when something is
+ * going to be rendered, and `./descriptors` when it is not.
*/
-export const registry = Object.fromEntries(descriptors.map(d => [d.type, d]));
+export const editors = Object.freeze({
+ [QtiInteraction.CHOICE]: ChoiceEditor,
+ [QtiInteraction.TEXT_ENTRY]: TextEntryEditor,
+ [QtiInteraction.ORDER]: OrderingEditor,
+});
-/**
- * Find the interaction descriptor that supports a given question type.
- *
- * @param {string} questionType
- * @returns {import('./defineInteraction').InteractionDescriptor|undefined}
- */
-export function getDescriptorForQuestionType(questionType) {
- return descriptors.find(d => d.questionTypes.includes(questionType));
-}
+export { DEFAULT_INTERACTION, descriptors, registry } from './descriptors';
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js
similarity index 81%
rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js
rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js
index d6f281780d..58bce294de 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js
@@ -1,16 +1,17 @@
import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants';
+import { InteractionDescriptor } from '../InteractionDescriptor';
import { parseOrderingInteraction, buildOrderingInteractionXML } from './parse';
-import { validateOrderingInteraction } from './validate';
+import { validateOrderingInteraction } from './validation';
/**
* Owns all ordering-specific interaction logic: schema, parse, buildXML, and validate.
*/
-export class OrderingInteractionDescriptor {
- constructor({ editorComponent = null } = {}) {
- this.type = QtiInteraction.ORDER;
- this.placement = 'block';
- this.questionTypes = [QuestionType.ORDERING];
- this.editorComponent = editorComponent;
+export class OrderingInteractionDescriptor extends InteractionDescriptor {
+ constructor() {
+ super({
+ type: QtiInteraction.ORDER,
+ questionTypes: [QuestionType.ORDERING],
+ });
this.convertsFrom = [];
}
@@ -24,11 +25,6 @@ export class OrderingInteractionDescriptor {
];
}
- /** @param {Element} el */
- matches(el) {
- return el.tagName.toLowerCase() === QtiInteraction.ORDER;
- }
-
/**
* Ordering always has exactly one question type.
*
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue
similarity index 96%
rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue
rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue
index cc1a74d44f..e5fa53f492 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue
@@ -6,12 +6,12 @@
{{ errorPromptRequired$() }}
-
{{ questionLabel$() }}
-
+