@@ -389,6 +391,9 @@
}));
watch(workingInteraction, newVal => 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$()
@@ -597,7 +602,7 @@
},
},
- emits: ['update:interaction'],
+ emits: ['update:interaction', 'update:errors'],
};
@@ -611,8 +616,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;
}
@@ -674,7 +680,7 @@
padding: 7.5px;
}
- /* Flex row: [drag] [selection] [content] [actions] */
+ /* Flex row: [[drag] [selection]] [content] [actions] */
.choice-layout {
display: flex;
align-items: center;
@@ -703,6 +709,12 @@
}
}
}
+
+ .choice-handlers {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ }
}
.choice-drag {
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 93%
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 d7beebbf50..5389d5a562 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
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
import { nextTick } from 'vue';
import VueRouter from 'vue-router';
import useKLiveRegion from 'kolibri-design-system/lib/composables/useKLiveRegion';
-import ChoiceInteractionEditor from '../ChoiceInteractionEditor.vue';
+import ChoiceInteractionEditor from '../Editor.vue';
import {
CHOICE_SINGLE_SELECT_XML,
@@ -215,7 +215,9 @@ describe('ChoiceInteractionEditor', () => {
questionType: QuestionType.SINGLE_SELECT,
});
await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addChoiceBtn') }));
- expect(screen.getAllByRole('radio')).toHaveLength(4);
+ // Counted by row rather than by radio: the added choice is empty, so validation —
+ // no longer debounced — puts the error icon where its radio would be.
+ expect(screen.getAllByRole('button', { name: tr.$tr('deleteChoiceBtn') })).toHaveLength(4);
});
it('gives every choice row its own delete button', () => {
@@ -432,65 +434,64 @@ 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('replaces the selection control with the error icon on an invalid choice', async () => {
- jest.useFakeTimers();
- const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ const user = userEvent.setup();
renderEditor({
interaction: block(CHOICE_SINGLE_SELECT_XML),
questionType: QuestionType.SINGLE_SELECT,
});
expect(screen.getAllByRole('radio')).toHaveLength(3);
- // The added choice starts empty, so the debounced validation flags it.
+ // The added choice starts empty, so validation flags it straight away.
await user.click(screen.getByRole('button', { name: /add choice/i }));
await nextTick();
- jest.advanceTimersByTime(400);
- await nextTick();
- jest.useRealTimers();
// Four rows, but the invalid one shows the error icon where its radio was
expect(screen.getAllByRole('button', { name: tr.$tr('deleteChoiceBtn') })).toHaveLength(4);
expect(screen.getAllByRole('radio')).toHaveLength(3);
});
- 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();
});
});
@@ -661,15 +662,18 @@ describe('ChoiceInteractionEditor', () => {
});
});
+ // The default state is one empty choice, which validation flags immediately now that it
+ // is not debounced — so the row is counted by its delete button, its radio having been
+ // replaced by the error icon.
describe('graceful fallback', () => {
it('renders default interaction state when bodyXml is empty', () => {
renderEditor({ interaction: block(''), questionType: QuestionType.SINGLE_SELECT });
- expect(screen.getAllByRole('radio')).toHaveLength(1);
+ expect(screen.getAllByRole('button', { name: tr.$tr('deleteChoiceBtn') })).toHaveLength(1);
});
it('renders default interaction state when XML is malformed', () => {
renderEditor({ interaction: block('
}
+ */
+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 97%
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 a01d149292..f528f6ec11 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$() }}
-
+