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..efa58d3170
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue
@@ -0,0 +1,596 @@
+
+
+
+
+
+
+ {{ errorPromptRequired$() }}
+
+
+ {{ questionLabel$() }}
+
+
+
+
+
+
+
+ {{ errorTooFewChoices$() }}
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+ {{ index + 1 }}
+
+
+ setItemContent(item.id, html)"
+ @blur="runValidation"
+ @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..9018bcbada
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js
@@ -0,0 +1,240 @@
+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');
+
+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,
+ });
+ const moveUpBtns = screen.getAllByRole('button', {
+ name: name => name.includes('up'),
+ });
+ expect(moveUpBtns[0]).toBeDisabled();
+ expect(moveUpBtns[1]).toBeEnabled();
+ });
+
+ it('disables move-down button for the last item', () => {
+ renderEditor({
+ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML),
+ questionType: QuestionType.ORDERING,
+ });
+ const moveDownBtns = screen.getAllByRole('button', {
+ name: name => name.includes('down'),
+ });
+ expect(moveDownBtns[moveDownBtns.length - 1]).toBeDisabled();
+ expect(moveDownBtns[0]).toBeEnabled();
+ });
+
+ it('disables delete button when only one item remains', async () => {
+ const singleItemXml = `
+ Mercury
+ `;
+ renderEditor({
+ interaction: block(singleItemXml),
+ questionType: QuestionType.ORDERING,
+ });
+ const deleteBtns = screen.getAllByRole('button', { name: name => name.includes('Delete') });
+ expect(deleteBtns[0]).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: name => name.includes('Delete') }),
+ ).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 0 items
+ expect(screen.queryByText('1')).not.toBeInTheDocument();
+ // Should show the 'Add option' button
+ expect(screen.getByRole('button', { name: 'Add option' })).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..bab4513620
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js
@@ -0,0 +1,225 @@
+/* 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 { _defaultState } from '../parse';
+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('_defaultState()', () => {
+ it('seeds items with an empty array', () => {
+ const state = _defaultState();
+ expect(state.items).toHaveLength(0);
+ });
+
+ it('sets shuffle to true by default', () => {
+ expect(_defaultState().shuffle).toBe(true);
+ });
+
+ it('sets orientation to vertical by default', () => {
+ expect(_defaultState().orientation).toBe(Orientation.VERTICAL);
+ });
+});
+
+describe('parse()', () => {
+ describe('attribute defaults', () => {
+ it('returns _defaultState() when bodyXml is empty', () => {
+ const state = parse('', []);
+ expect(state.items).toHaveLength(0);
+ });
+
+ 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..7177d88835
--- /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: [],
+ 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/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
index b7d4a8f6c7..68f03e3de9 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
@@ -57,6 +57,51 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', {
message: 'Order',
context: 'Display name for an order question type',
},
+ orderingLabel: {
+ message: 'Ordering',
+ context: 'Display name for an ordering question type shown in the question type selector',
+ },
+ orderingDescription: {
+ message: 'Learners must arrange items into the correct order.',
+ context: 'Description for the ordering question type in the info modal',
+ },
+ correctOrderLabel: {
+ message: 'Correct order',
+ context: 'Section header above the ordering item list — items are shown in the correct order',
+ },
+ correctOrderDescription: {
+ message: 'Learners will see these shuffled',
+ context:
+ 'Subtitle under the correct order header explaining that items will be shuffled for learners',
+ },
+ addItemBtn: {
+ message: 'Add option',
+ context: 'Button that appends a new ordering item',
+ },
+ deleteItemBtn: {
+ message: 'Delete option {number}',
+ context: 'Accessible label for the delete icon button next to an ordering item row',
+ },
+ moveItemUpBtn: {
+ message: 'Move option {number} up',
+ context: 'Accessible label for the move-up icon button next to an ordering item row',
+ },
+ moveItemDownBtn: {
+ message: 'Move option {number} down',
+ context: 'Accessible label for the move-down icon button next to an ordering item row',
+ },
+ errorTooFewChoices: {
+ message: 'At least 2 items are required for an ordering question.',
+ context: 'Validation error when fewer than 2 ordering items are present',
+ },
+ errorEmptyItemContent: {
+ message: 'Item cannot be blank',
+ context: 'Validation error when an ordering item has no content',
+ },
+ errorDuplicateItemContent: {
+ message: 'Duplicate items are not allowed',
+ context: 'Validation error when two or more ordering items have identical content',
+ },
matchLabel: {
message: 'Match',
context: 'Display name for a match question type',
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
index 2bd903afbc..24c12a77c2 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
@@ -46,6 +46,21 @@ export const CHOICE_MULTI_DECL_XML = `
+ Arrange the planets in order from closest to farthest from the Sun.
+ Mercury
+ Venus
+ Earth
+`;
+
+export const ORDERING_DECL_XML = `
+
+ order_aaa11111
+ order_bbb22222
+ order_ccc33333
+
+`;
+
// Full QTI Assessment Item XML Documents
export const VALID_CHOICE_ITEM_DOCUMENT = `