diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js
index 0260e0fbfc..107a549a6b 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js
@@ -1,6 +1,7 @@
import { QtiInteraction } from '../constants';
import choiceDescriptor from './choice/index';
import textEntryDescriptor from './textEntry/index';
+import orderingDescriptor from './ordering/index';
/**
* The default interaction type used as fallback when no descriptor matches
@@ -12,7 +13,7 @@ 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];
+export const descriptors = [choiceDescriptor, textEntryDescriptor, orderingDescriptor];
/**
* Registry map keyed by descriptor.type for O(1) direct lookup.
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js
new file mode 100644
index 0000000000..d6f281780d
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js
@@ -0,0 +1,85 @@
+import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants';
+import { parseOrderingInteraction, buildOrderingInteractionXML } from './parse';
+import { validateOrderingInteraction } from './validate';
+
+/**
+ * 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;
+ this.convertsFrom = [];
+ }
+
+ getTypeOptions(tr) {
+ return [
+ {
+ value: QuestionType.ORDERING,
+ label: tr.orderingLabel$(),
+ description: tr.orderingDescription$(),
+ },
+ ];
+ }
+
+ /** @param {Element} el */
+ matches(el) {
+ return el.tagName.toLowerCase() === QtiInteraction.ORDER;
+ }
+
+ /**
+ * Ordering always has exactly one question type.
+ *
+ * @returns {string}
+ */
+ getQuestionType() {
+ return QuestionType.ORDERING;
+ }
+
+ /**
+ * @returns {{ baseType: string, cardinality: string }}
+ */
+ getResponseDeclarationSchema() {
+ return {
+ baseType: BaseType.IDENTIFIER,
+ cardinality: Cardinality.ORDERED,
+ };
+ }
+
+ /**
+ * Parse
body XML + response declarations → OrderingState.
+ *
+ * @param {string} bodyXml
+ * @param {string[]} responseDeclarations
+ * @returns {object} OrderingState
+ */
+ parse(bodyXml, responseDeclarations) {
+ return parseOrderingInteraction(bodyXml, responseDeclarations);
+ }
+
+ /**
+ * Serialize OrderingState → { bodyXml, responseDeclarations }.
+ *
+ * @param {object} state - OrderingState
+ * @param {string} questionType
+ * @returns {{ bodyXml: string, responseDeclarations: string[] }}
+ */
+ buildXML(state, questionType) {
+ return buildOrderingInteractionXML(state, questionType, this.getResponseDeclarationSchema());
+ }
+
+ /**
+ * Validate OrderingState → ValidationError[].
+ *
+ * @param {object} state - OrderingState
+ * @returns {Array<{ code: string, id?: string }>}
+ */
+ validate(state) {
+ return validateOrderingInteraction(state);
+ }
+}
+
+/** Singleton — safe to import from any file in the ordering module tree. */
+export const orderingInteractionDescriptor = new OrderingInteractionDescriptor();
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue
new file mode 100644
index 0000000000..cc1a74d44f
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue
@@ -0,0 +1,570 @@
+
+
+
+
+
+
+ {{ errorPromptRequired$() }}
+
+
+ {{ questionLabel$() }}
+
+
+
+
+
+
+
+ {{ errorTooFewChoices$() }}
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+ {{ index + 1 }}
+
+
+ setItemContent(item.id, html)"
+ @minimize="closeItem"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {{ errorEmptyItemContent$() }}
+
+
+ {{ errorDuplicateItemContent$() }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js
new file mode 100644
index 0000000000..6e082e94cf
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js
@@ -0,0 +1,250 @@
+import { render, screen, fireEvent } from '@testing-library/vue';
+import { nextTick } from 'vue';
+import VueRouter from 'vue-router';
+import OrderingInteractionEditor from '../OrderingInteractionEditor.vue';
+
+import {
+ ORDERING_XML,
+ ORDERING_DECL_XML,
+ mockInteractionBlock as block,
+ mockInteractionBlockWithDecl as blockWithDecl,
+} from '../../../utils/testingFixtures';
+import { QuestionType } from '../../../constants';
+import { qtiEditorStrings as tr } from '../../../qtiEditorStrings';
+
+jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
+jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
+ const { ref } = require('vue');
+ return {
+ __esModule: true,
+ default: () => ({ windowIsSmall: ref(false) }),
+ };
+});
+
+const renderEditor = (props = {}) =>
+ render(OrderingInteractionEditor, {
+ props: { mode: 'edit', ...props },
+ routes: new VueRouter(),
+ });
+
+describe('OrderingInteractionEditor', () => {
+ describe('edit mode rendering', () => {
+ it('renders the prompt text from the XML', () => {
+ renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ });
+ // TipTapEditor mock renders `value` as-is in a ; use partial text match.
+ expect(screen.getByText(/Arrange the planets/)).toBeInTheDocument();
+ });
+
+ it('renders a numbered position badge for each item', () => {
+ renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ });
+ // Items fixture has 3 items — badges "1", "2", "3"
+ expect(screen.getByText('1')).toBeInTheDocument();
+ expect(screen.getByText('2')).toBeInTheDocument();
+ expect(screen.getByText('3')).toBeInTheDocument();
+ });
+
+ it('renders item content text for each item', () => {
+ renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ });
+ expect(screen.getByText('Mercury')).toBeInTheDocument();
+ expect(screen.getByText('Venus')).toBeInTheDocument();
+ expect(screen.getByText('Earth')).toBeInTheDocument();
+ });
+
+ it('renders the correct order header', () => {
+ renderEditor({
+ interaction: block(ORDERING_XML),
+ questionType: QuestionType.ORDERING,
+ });
+ expect(screen.getByText(tr.$tr('correctOrderLabel'))).toBeInTheDocument();
+ });
+
+ it('renders the "Learners will see these shuffled" description', () => {
+ renderEditor({
+ interaction: block(ORDERING_XML),
+ questionType: QuestionType.ORDERING,
+ });
+ expect(screen.getByText(tr.$tr('correctOrderDescription'))).toBeInTheDocument();
+ });
+
+ it('renders the Add option button', () => {
+ renderEditor({
+ interaction: block(ORDERING_XML),
+ questionType: QuestionType.ORDERING,
+ });
+ expect(screen.getByRole('button', { name: tr.$tr('addItemBtn') })).toBeInTheDocument();
+ });
+
+ it('adds a new item row when Add option is clicked', async () => {
+ renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ });
+ await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addItemBtn') }));
+ // 3 original + 1 new = position badge "4"
+ expect(screen.getByText('4')).toBeInTheDocument();
+ });
+
+ it('disables move-up button for the first item', () => {
+ renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ });
+ expect(
+ screen.getByRole('button', { name: tr.$tr('moveItemUpBtn', { number: 1 }) }),
+ ).toBeDisabled();
+ expect(
+ screen.getByRole('button', { name: tr.$tr('moveItemUpBtn', { number: 2 }) }),
+ ).toBeEnabled();
+ });
+
+ it('disables move-down button for the last item', () => {
+ renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ });
+ expect(
+ screen.getByRole('button', { name: tr.$tr('moveItemDownBtn', { number: 3 }) }),
+ ).toBeDisabled();
+ expect(
+ screen.getByRole('button', { name: tr.$tr('moveItemDownBtn', { number: 1 }) }),
+ ).toBeEnabled();
+ });
+
+ it('disables delete button when only one item remains', async () => {
+ const singleItemXml = `
+ Mercury
+ `;
+ renderEditor({
+ interaction: block(singleItemXml),
+ questionType: QuestionType.ORDERING,
+ });
+ expect(
+ screen.getByRole('button', { name: tr.$tr('deleteItemBtn', { number: 1 }) }),
+ ).toBeDisabled();
+ });
+ });
+
+ describe('view mode', () => {
+ it('hides items when mode=view and showAnswers=false', () => {
+ renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ mode: 'view',
+ showAnswers: false,
+ });
+ expect(screen.queryByText('Mercury')).not.toBeInTheDocument();
+ });
+
+ it('shows items in correct order when mode=view and showAnswers=true', () => {
+ renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ mode: 'view',
+ showAnswers: true,
+ });
+ expect(screen.getByText('Mercury')).toBeInTheDocument();
+ expect(screen.getByText('Venus')).toBeInTheDocument();
+ expect(screen.getByText('Earth')).toBeInTheDocument();
+ });
+
+ it('hides the Add option button in view mode', () => {
+ renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ mode: 'view',
+ showAnswers: true,
+ });
+ expect(screen.queryByRole('button', { name: tr.$tr('addItemBtn') })).not.toBeInTheDocument();
+ });
+
+ it('hides move/delete buttons in view mode', () => {
+ renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ mode: 'view',
+ showAnswers: true,
+ });
+ expect(
+ screen.queryByRole('button', { name: tr.$tr('deleteItemBtn', { number: 1 }) }),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe('emits', () => {
+ it('emits update:interaction on initial mount in edit mode', () => {
+ const { emitted } = renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ });
+ expect(emitted()['update:interaction']).toBeTruthy();
+ const payload = emitted()['update:interaction'][0][0];
+ expect(typeof payload.bodyXml).toBe('string');
+ expect(Array.isArray(payload.responseDeclarations)).toBe(true);
+ });
+
+ it('emits update:interaction after adding an item', async () => {
+ const { emitted } = renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ });
+ const before = emitted()['update:interaction'].length;
+ await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addItemBtn') }));
+ expect(emitted()['update:interaction'].length).toBeGreaterThan(before);
+ });
+
+ it('does not emit update:interaction in view mode', async () => {
+ const { emitted } = renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ mode: 'view',
+ showAnswers: true,
+ });
+ // Clear mount-time emissions — none should fire in view mode
+ expect(emitted()['update:interaction']).toBeFalsy();
+ });
+ });
+
+ describe('validation', () => {
+ it('does not show errors before any field is touched', () => {
+ renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ });
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('shows errors after runValidation is triggered by state mutation', async () => {
+ jest.useFakeTimers();
+ renderEditor({
+ interaction: block(''),
+ questionType: QuestionType.ORDERING,
+ });
+ await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addItemBtn') }));
+ await nextTick();
+ jest.advanceTimersByTime(400);
+ await nextTick();
+ jest.useRealTimers();
+ // Prompt is empty → should show prompt required error
+ expect(screen.getAllByRole('alert').length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('graceful fallback', () => {
+ it('renders default state when bodyXml is empty', () => {
+ renderEditor({ interaction: block(''), questionType: QuestionType.ORDERING });
+ // Default state now seeds 1 item
+ expect(screen.getByText('1')).toBeInTheDocument();
+ // Should show the 'Add option' button
+ expect(screen.getByRole('button', { name: tr.$tr('addItemBtn') })).toBeInTheDocument();
+ });
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js
new file mode 100644
index 0000000000..0cafc5b252
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js
@@ -0,0 +1,209 @@
+/* eslint-disable jest-dom/prefer-to-have-attribute, jest-dom/prefer-to-have-text-content */
+// The eslint-dom matchers reject XML nodes produced by DOMParser(..., 'text/xml').
+
+import { orderingInteractionDescriptor } from '../OrderingInteractionDescriptor';
+import { ORDERING_XML, ORDERING_DECL_XML } from '../../../utils/testingFixtures';
+import { QuestionType, Orientation } from '../../../constants';
+
+const parse = orderingInteractionDescriptor.parse.bind(orderingInteractionDescriptor);
+const buildXML = orderingInteractionDescriptor.buildXML.bind(orderingInteractionDescriptor);
+
+function parseXmlString(xml) {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(xml, 'text/xml');
+ const err = doc.querySelector('parseerror, parsererror');
+ if (err) throw new Error(`Invalid XML: ${err.textContent}`);
+ return doc.documentElement;
+}
+
+describe('parse()', () => {
+ describe('attribute defaults', () => {
+ it('returns _defaultState() when bodyXml is empty', () => {
+ const state = parse('', []);
+ expect(state.items).toHaveLength(1);
+ });
+
+ it('returns _defaultState() when bodyXml is invalid XML', () => {
+ const state = parse(' {
+ const xml = `
+ A
+ `;
+ expect(parse(xml, []).orientation).toBe(Orientation.VERTICAL);
+ });
+
+ it('defaults shuffle to false when attribute is absent', () => {
+ const xml = `
+ A
+ `;
+ expect(parse(xml, []).shuffle).toBe(false);
+ });
+
+ it('defaults prompt to empty string when is absent', () => {
+ const xml = `
+ A
+ `;
+ expect(parse(xml, []).prompt).toBe('');
+ });
+ });
+
+ describe('attribute reading', () => {
+ it('reads orientation="horizontal"', () => {
+ const xml = `
+ A
+ `;
+ expect(parse(xml, []).orientation).toBe('horizontal');
+ });
+
+ it('reads shuffle="true"', () => {
+ const state = parse(ORDERING_XML, []);
+ expect(state.shuffle).toBe(true);
+ });
+
+ it('reads the prompt HTML', () => {
+ const state = parse(ORDERING_XML, []);
+ expect(state.prompt).toContain('Arrange the planets');
+ });
+ });
+
+ describe('items parsing', () => {
+ it('parses items from elements', () => {
+ const state = parse(ORDERING_XML, []);
+ expect(state.items).toHaveLength(3);
+ });
+
+ it('assigns a generated order_ slug to items without an identifier', () => {
+ const xml = `
+ No ID
+ `;
+ const state = parse(xml, []);
+ expect(state.items[0].id).toMatch(/^order_/);
+ });
+
+ it('reads the fixed attribute', () => {
+ const xml = `
+ A
+ `;
+ expect(parse(xml, []).items[0].fixed).toBe(true);
+ });
+
+ it('reorders items to match the correct-response declaration sequence', () => {
+ const xml = `
+ Mercury
+ Venus
+ Earth
+ `;
+ const decl = `
+
+ order_ccc33333
+ order_aaa11111
+ order_bbb22222
+
+ `;
+ const state = parse(xml, [decl]);
+ expect(state.items.map(i => i.id)).toEqual([
+ 'order_ccc33333',
+ 'order_aaa11111',
+ 'order_bbb22222',
+ ]);
+ });
+
+ it('does not reorder when no declaration is present', () => {
+ const state = parse(ORDERING_XML, []);
+ expect(state.items.map(i => i.id)).toEqual([
+ 'order_aaa11111',
+ 'order_bbb22222',
+ 'order_ccc33333',
+ ]);
+ });
+ });
+});
+
+describe('buildXML()', () => {
+ const baseState = {
+ responseIdentifier: 'RESPONSE',
+ prompt: 'Order these planets.',
+ items: [
+ { id: 'order_aaa11111', content: 'Mercury', fixed: false },
+ { id: 'order_bbb22222', content: 'Venus', fixed: false },
+ { id: 'order_ccc33333', content: 'Earth', fixed: false },
+ ],
+ orientation: Orientation.VERTICAL,
+ shuffle: true,
+ };
+
+ it('emits orientation attribute', () => {
+ const { bodyXml } = buildXML(baseState, QuestionType.ORDERING);
+ const root = parseXmlString(bodyXml);
+ expect(root.getAttribute('orientation')).toBe('vertical');
+ });
+
+ it('emits shuffle="true"', () => {
+ const { bodyXml } = buildXML(baseState, QuestionType.ORDERING);
+ const root = parseXmlString(bodyXml);
+ expect(root.getAttribute('shuffle')).toBe('true');
+ });
+
+ it('emits shuffle="false" when state.shuffle is false', () => {
+ const { bodyXml } = buildXML({ ...baseState, shuffle: false }, QuestionType.ORDERING);
+ const root = parseXmlString(bodyXml);
+ expect(root.getAttribute('shuffle')).toBe('false');
+ });
+
+ it('emits for each item in state.items order', () => {
+ const { bodyXml } = buildXML(baseState, QuestionType.ORDERING);
+ const root = parseXmlString(bodyXml);
+ const choices = root.querySelectorAll('qti-simple-choice');
+ expect(choices).toHaveLength(3);
+ expect(choices[0].getAttribute('identifier')).toBe('order_aaa11111');
+ expect(choices[1].getAttribute('identifier')).toBe('order_bbb22222');
+ expect(choices[2].getAttribute('identifier')).toBe('order_ccc33333');
+ });
+
+ it('emits identifiers in state.items order inside ', () => {
+ const { responseDeclarations } = buildXML(baseState, QuestionType.ORDERING);
+ const decl = parseXmlString(responseDeclarations[0]);
+ const values = [...decl.querySelectorAll('qti-value')].map(n => n.textContent.trim());
+ expect(values).toEqual(['order_aaa11111', 'order_bbb22222', 'order_ccc33333']);
+ });
+
+ it('sets cardinality="ordered" on the declaration', () => {
+ const { responseDeclarations } = buildXML(baseState, QuestionType.ORDERING);
+ const decl = parseXmlString(responseDeclarations[0]);
+ expect(decl.getAttribute('cardinality')).toBe('ordered');
+ });
+
+ it('sets base-type="identifier" on the declaration', () => {
+ const { responseDeclarations } = buildXML(baseState, QuestionType.ORDERING);
+ const decl = parseXmlString(responseDeclarations[0]);
+ expect(decl.getAttribute('base-type')).toBe('identifier');
+ });
+
+ it('omits when prompt is empty', () => {
+ const { bodyXml } = buildXML({ ...baseState, prompt: '' }, QuestionType.ORDERING);
+ const root = parseXmlString(bodyXml);
+ expect(root.querySelector('qti-prompt')).toBeNull();
+ });
+
+ it('omits when items array is empty', () => {
+ const { responseDeclarations } = buildXML({ ...baseState, items: [] }, QuestionType.ORDERING);
+ const decl = parseXmlString(responseDeclarations[0]);
+ expect(decl.querySelector('qti-correct-response')).toBeNull();
+ });
+});
+
+describe('parse → buildXML → parse round-trip', () => {
+ it('re-parsed state matches original for a full ordering XML', () => {
+ const original = parse(ORDERING_XML, [ORDERING_DECL_XML]);
+ const { bodyXml, responseDeclarations } = buildXML(original, QuestionType.ORDERING);
+ const reparsed = parse(bodyXml, responseDeclarations);
+
+ expect(reparsed.orientation).toBe(original.orientation);
+ expect(reparsed.shuffle).toBe(original.shuffle);
+ expect(reparsed.items.map(i => i.id)).toEqual(original.items.map(i => i.id));
+ expect(reparsed.items.map(i => i.content)).toEqual(original.items.map(i => i.content));
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validate.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validate.spec.js
new file mode 100644
index 0000000000..f480cd7fcd
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validate.spec.js
@@ -0,0 +1,130 @@
+import { validateOrderingInteraction } from '../validate';
+import { ValidationError, Orientation } from '../../../constants';
+
+function makeItem(overrides = {}) {
+ return { id: 'order_aaa11111', content: 'Mercury', fixed: false, ...overrides };
+}
+
+function makeState(overrides = {}) {
+ return {
+ prompt: 'Order the planets.',
+ items: [
+ makeItem({ id: 'order_aaa11111', content: 'Mercury' }),
+ makeItem({ id: 'order_bbb22222', content: 'Venus' }),
+ ],
+ orientation: Orientation.VERTICAL,
+ shuffle: true,
+ ...overrides,
+ };
+}
+
+const errorCodes = errors => errors.map(e => e.code);
+
+describe('validateOrderingInteraction()', () => {
+ it('returns an empty array for a valid state', () => {
+ expect(validateOrderingInteraction(makeState())).toEqual([]);
+ });
+
+ describe('PROMPT_REQUIRED', () => {
+ it('returns error when prompt is empty', () => {
+ expect(errorCodes(validateOrderingInteraction(makeState({ prompt: '' })))).toContain(
+ ValidationError.PROMPT_REQUIRED,
+ );
+ });
+
+ it('returns error when prompt is whitespace only', () => {
+ expect(errorCodes(validateOrderingInteraction(makeState({ prompt: ' ' })))).toContain(
+ ValidationError.PROMPT_REQUIRED,
+ );
+ });
+
+ it('returns error when prompt is tags-only with no visible text', () => {
+ expect(errorCodes(validateOrderingInteraction(makeState({ prompt: '
' })))).toContain(
+ ValidationError.PROMPT_REQUIRED,
+ );
+ });
+
+ it('does not return error when prompt has visible text', () => {
+ expect(
+ errorCodes(validateOrderingInteraction(makeState({ prompt: 'Arrange these.
' }))),
+ ).not.toContain(ValidationError.PROMPT_REQUIRED);
+ });
+ });
+
+ describe('TOO_FEW_CHOICES', () => {
+ it('returns error when fewer than 2 items', () => {
+ const state = makeState({ items: [makeItem()] });
+ expect(errorCodes(validateOrderingInteraction(state))).toContain(
+ ValidationError.TOO_FEW_CHOICES,
+ );
+ });
+
+ it('returns error when items list is empty', () => {
+ const state = makeState({ items: [] });
+ expect(errorCodes(validateOrderingInteraction(state))).toContain(
+ ValidationError.TOO_FEW_CHOICES,
+ );
+ });
+
+ it('does not return error with 2 or more items', () => {
+ expect(errorCodes(validateOrderingInteraction(makeState()))).not.toContain(
+ ValidationError.TOO_FEW_CHOICES,
+ );
+ });
+ });
+
+ describe('EMPTY_CHOICE_CONTENT', () => {
+ it('returns error for each item with empty content', () => {
+ const state = makeState({
+ items: [makeItem({ id: 'a', content: '' }), makeItem({ id: 'b', content: ' ' })],
+ });
+ const errors = validateOrderingInteraction(state).filter(
+ e => e.code === ValidationError.EMPTY_CHOICE_CONTENT,
+ );
+ expect(errors).toHaveLength(2);
+ expect(errors.map(e => e.id)).toContain('a');
+ expect(errors.map(e => e.id)).toContain('b');
+ });
+
+ it('does not flag items with content wrapped in HTML tags', () => {
+ const state = makeState({
+ items: [
+ makeItem({ id: 'a', content: 'Mercury' }),
+ makeItem({ id: 'b', content: 'Venus' }),
+ ],
+ });
+ expect(errorCodes(validateOrderingInteraction(state))).not.toContain(
+ ValidationError.EMPTY_CHOICE_CONTENT,
+ );
+ });
+ });
+
+ describe('DUPLICATE_CHOICE_CONTENT', () => {
+ it('flags all items with identical normalised text content', () => {
+ const state = makeState({
+ items: [
+ makeItem({ id: 'a', content: 'Mercury' }),
+ makeItem({ id: 'b', content: 'Mercury' }),
+ makeItem({ id: 'c', content: ' Mercury ' }),
+ makeItem({ id: 'd', content: 'Mercury
' }),
+ makeItem({ id: 'e', content: 'Venus' }),
+ ],
+ });
+ const errors = validateOrderingInteraction(state).filter(
+ e => e.code === ValidationError.DUPLICATE_CHOICE_CONTENT,
+ );
+ const ids = errors.map(e => e.id);
+ expect(ids).toContain('a');
+ expect(ids).toContain('b');
+ expect(ids).toContain('c');
+ expect(ids).toContain('d');
+ expect(ids).not.toContain('e');
+ });
+
+ it('does not return error for unique content', () => {
+ expect(errorCodes(validateOrderingInteraction(makeState()))).not.toContain(
+ ValidationError.DUPLICATE_CHOICE_CONTENT,
+ );
+ });
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/index.js
new file mode 100644
index 0000000000..2a16ab7fcc
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/index.js
@@ -0,0 +1,5 @@
+import defineInteraction from '../defineInteraction';
+import OrderingInteractionEditor from './OrderingInteractionEditor.vue';
+import { orderingInteractionDescriptor } from './OrderingInteractionDescriptor';
+
+export default defineInteraction(orderingInteractionDescriptor, OrderingInteractionEditor);
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js
new file mode 100644
index 0000000000..7b30cc8472
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js
@@ -0,0 +1,161 @@
+import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration';
+import { getPromptHTML, parseXML } from '../../serialization/parseItem';
+import { buildXmlNode } from '../../serialization/assembleItem';
+import CorrectResponse from '../../serialization/qti/declarations/correctResponse';
+import { generateRandomSlug } from '../../utils/generateRandomSlug';
+import { Orientation, RESPONSE_IDENTIFIER } from '../../constants';
+
+/**
+ * @typedef {object} OrderingItem
+ * @property {string} id - QTI identifier, e.g. "order_xlqTuVoq"
+ * @property {string} content - HTML content of the
+ * @property {boolean} fixed - Whether this item is fixed in place
+ * (round-trip only; not editable in UI)
+ */
+
+/**
+ * @typedef {object} OrderingState
+ * @property {string} responseIdentifier - Response identifier attribute
+ * @property {string} prompt - HTML content of ; default ""
+ * @property {OrderingItem[]} items - Items in the CORRECT order
+ * @property {string} orientation - From orientation attribute; default "vertical"
+ * @property {boolean} shuffle - From shuffle attribute;
+ * default true for new items
+ */
+
+const serializer = new XMLSerializer();
+
+export function _defaultState() {
+ return {
+ responseIdentifier: RESPONSE_IDENTIFIER,
+ prompt: '',
+ items: [{ id: generateRandomSlug('order'), content: '' }],
+ orientation: Orientation.VERTICAL,
+ shuffle: true,
+ };
+}
+
+/**
+ * Extract the ordered list of correct identifiers from a response declaration string.
+ * Returns an array (ordered) rather than a Set.
+ *
+ * @param {string[]} declarations
+ * @returns {string[]}
+ */
+export function _extractOrderedCorrectIds(declarations) {
+ const [declXml] = declarations || [];
+ if (!declXml) return [];
+
+ try {
+ const declEl = parseXML(declXml).documentElement;
+ const declaration = QTIDeclaration.fromXML(declEl);
+ const correct = declaration.correctResponse;
+ return correct ? [...correct] : [];
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Parse body XML + response declarations → OrderingState.
+ *
+ * @param {string} bodyXml
+ * @param {string[]} responseDeclarations
+ * @returns {object} OrderingState
+ */
+export function parseOrderingInteraction(bodyXml, responseDeclarations) {
+ if (!bodyXml) return _defaultState();
+
+ let root;
+ try {
+ root = parseXML(bodyXml).documentElement;
+ } catch {
+ return _defaultState();
+ }
+
+ const responseIdentifier = root.getAttribute('response-identifier') || RESPONSE_IDENTIFIER;
+ const orientation = root.getAttribute('orientation') ?? Orientation.VERTICAL;
+ const shuffle = root.getAttribute('shuffle') === 'true';
+ const prompt = getPromptHTML(root);
+
+ const rawItems = [...root.querySelectorAll('qti-simple-choice')].map(el => ({
+ id: el.getAttribute('identifier') || generateRandomSlug('order'),
+ content: el.innerHTML,
+ fixed: el.getAttribute('fixed') === 'true',
+ }));
+
+ const correctOrder = _extractOrderedCorrectIds(responseDeclarations);
+
+ let items;
+ if (correctOrder.length > 0) {
+ const itemById = Object.fromEntries(rawItems.map(item => [item.id, item]));
+ const ordered = correctOrder.map(id => itemById[id]).filter(Boolean);
+ const declaredIds = new Set(correctOrder);
+ const remainder = rawItems.filter(item => !declaredIds.has(item.id));
+ items = [...ordered, ...remainder];
+ } else {
+ items = rawItems;
+ }
+
+ return {
+ responseIdentifier,
+ prompt,
+ items,
+ orientation,
+ shuffle,
+ };
+}
+
+/**
+ * Serialize OrderingState → { bodyXml, responseDeclarations }.
+ *
+ * @param {object} state - OrderingState
+ * @param {string} _questionType - unused (ordering has only one question type); kept for API parity
+ * @param {object} declarationSchema - { baseType: string, cardinality: string }
+ * @returns {{ bodyXml: string, responseDeclarations: string[] }}
+ */
+export function buildOrderingInteractionXML(state, _questionType, declarationSchema) {
+ const { responseIdentifier = RESPONSE_IDENTIFIER, prompt, items, orientation, shuffle } = state;
+
+ const attrs = {
+ 'response-identifier': responseIdentifier,
+ orientation,
+ shuffle: String(shuffle),
+ };
+
+ const children = [];
+
+ if (prompt) {
+ children.push(buildXmlNode({ tag: 'qti-prompt', innerHTML: prompt }));
+ }
+
+ for (const item of items) {
+ const itemAttrs = { identifier: item.id };
+ if (item.fixed) itemAttrs.fixed = 'true';
+ children.push(
+ buildXmlNode({
+ tag: 'qti-simple-choice',
+ attrs: itemAttrs,
+ innerHTML: item.content,
+ }),
+ );
+ }
+
+ const interactionEl = buildXmlNode({ tag: 'qti-order-interaction', attrs, children });
+ const bodyXml = serializer.serializeToString(interactionEl);
+
+ const { cardinality, baseType } = declarationSchema;
+ const declaration = new QTIDeclaration({
+ identifier: responseIdentifier,
+ baseType,
+ cardinality,
+ tag: 'qti-response-declaration',
+ });
+ const correctIds = items.map(item => item.id);
+ if (correctIds.length > 0) {
+ new CorrectResponse(correctIds, declaration);
+ }
+
+ const declarationXml = serializer.serializeToString(declaration.getXML());
+ return { bodyXml, responseDeclarations: [declarationXml] };
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validate.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validate.js
new file mode 100644
index 0000000000..3b9b3df163
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validate.js
@@ -0,0 +1,42 @@
+import { ValidationError } from '../../constants';
+import { stripTags } from '../../utils/stripTags';
+
+/**
+ * Validate OrderingState → ValidationError[].
+ *
+ * @param {object} state - OrderingState
+ * @returns {Array<{ code: string, id?: string }>}
+ */
+export function validateOrderingInteraction(state) {
+ const errors = [];
+ const { prompt, items } = state;
+
+ if (!stripTags(prompt).trim()) {
+ errors.push({ code: ValidationError.PROMPT_REQUIRED });
+ }
+
+ if (items.length < 2) {
+ errors.push({ code: ValidationError.TOO_FEW_CHOICES });
+ }
+
+ const firstSeenId = new Map();
+ const duplicateIds = new Set();
+
+ for (const item of items) {
+ const textContent = stripTags(item.content).trim();
+ if (!textContent) {
+ errors.push({ code: ValidationError.EMPTY_CHOICE_CONTENT, id: item.id });
+ } else if (firstSeenId.has(textContent)) {
+ duplicateIds.add(firstSeenId.get(textContent));
+ duplicateIds.add(item.id);
+ } else {
+ firstSeenId.set(textContent, item.id);
+ }
+ }
+
+ for (const duplicateId of duplicateIds) {
+ errors.push({ code: ValidationError.DUPLICATE_CHOICE_CONTENT, id: duplicateId });
+ }
+
+ return errors;
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue
index 9ddc4aa727..3f4967574a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue
@@ -55,6 +55,7 @@
{{ acceptableAnswersLabel$() }}