Skip to content

[QTI] Implement Ordering Interaction editor #6085

Description

@Abhishek-Punhani

This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.

Overview

Complete the ordering interaction plugin end-to-end: XML parsing, XML assembly, validation, a useOrderingInteraction composable built on useInteraction, and a working OrderingInteractionEditor.vue.

This task depends on the choice and text-entry interaction plugins being in place (for useInteraction, generateRandomSlug, and defineInteraction).

Complexity: Medium
Target branch: unstable


Context

The ordering interaction maps to <qti-order-interaction> and covers one question type:

  • ordering — the author enters items in the correct order; the learner sees them in the initial display order (which may be shuffled by a delivery engine) and must reorder them.

The ordering interaction is a block-level element — it stands alone in the item body, consistent with the choice interaction.

The response declaration uses cardinality="ordered" and base-type="identifier", listing choice identifiers in the correct order.


State shape

Defined via JSDoc in interactions/ordering/parse.js. parse produces (and buildXML consumes) this flat state object:

/**
 * @typedef {object} OrderingItem
 * @property {string}  id      - QTI identifier, e.g. "order_xlqTuVoq"
 * @property {string}  content - HTML content of the <qti-simple-choice>
 * @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 <qti-prompt>; default ""
 * @property {OrderingItem[]}   items              - Items in the CORRECT order
 * @property {string}           orientation        - From orientation attribute; default "vertical"
 * @property {number|undefined} maxChoices         - From max-choices attribute; undefined when absent
 * @property {number|undefined} minChoices         - From min-choices attribute; undefined when absent
 * @property {boolean}          shuffle            - From shuffle attribute; default true for new items
 */

QTI XML reference

Official spec example (§3.2.10, IMS Global BPIG):

<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
  identifier="QTI3-order" title="Order Interaction Example"
  time-dependent="false" xml:lang="en-US">

  <qti-response-declaration identifier="RESPONSE"
    cardinality="ordered" base-type="identifier">
    <qti-correct-response>
      <qti-value>DriverC</qti-value>
      <qti-value>DriverA</qti-value>
      <qti-value>DriverB</qti-value>
    </qti-correct-response>
  </qti-response-declaration>

  <qti-outcome-declaration identifier="SCORE"
    cardinality="single" base-type="float"/>

  <qti-item-body>
    <p>The following F1 drivers finished on the podium in the first ever Grand Prix of Bahrain.</p>
    <qti-order-interaction response-identifier="RESPONSE" orientation="horizontal">
      <qti-prompt>
        <p>Rearrange them into the correct finishing order.</p>
      </qti-prompt>
      <qti-simple-choice identifier="DriverA">Rubens Barrichello</qti-simple-choice>
      <qti-simple-choice identifier="DriverB">Jenson Button</qti-simple-choice>
      <qti-simple-choice identifier="DriverC">Michael Schumacher</qti-simple-choice>
    </qti-order-interaction>
  </qti-item-body>

  <qti-response-processing
    template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct.xml"/>
</qti-assessment-item>

Studio body XML only (what the interaction block stores):

<qti-order-interaction response-identifier="RESPONSE" orientation="vertical">
  <qti-prompt><p>Arrange the planets in order from closest to farthest from the Sun.</p></qti-prompt>
  <qti-simple-choice identifier="order_abc12345">Mercury</qti-simple-choice>
  <qti-simple-choice identifier="order_def67890">Venus</qti-simple-choice>
  <qti-simple-choice identifier="order_ghi11223">Earth</qti-simple-choice>
  <qti-simple-choice identifier="order_jkl44556">Mars</qti-simple-choice>
</qti-order-interaction>

Response declaration:

<qti-response-declaration identifier="RESPONSE" cardinality="ordered" base-type="identifier">
  <qti-correct-response>
    <qti-value>order_abc12345</qti-value>
    <qti-value>order_def67890</qti-value>
    <qti-value>order_ghi11223</qti-value>
    <qti-value>order_jkl44556</qti-value>
  </qti-correct-response>
</qti-response-declaration>

Rules:

  • cardinality is always "ordered" — this is what makes it an ordering interaction.
  • base-type is always "identifier".
  • <qti-correct-response> lists item identifiers in the correct order — the order the learner must match.
  • shuffle is supported and controls whether the delivery engine randomizes the initial display order. While the QTI spec defaults this to false, Studio defaults to true for newly authored items.
  • orientation defaults to "vertical" when absent.
  • Items without an identifier attribute must be assigned a generated slug (generateRandomSlug('order')).

The Change

1. interactions/ordering/parse.js

Export parseOrderingInteraction(bodyXml, responseDeclarations)OrderingState:

  • Parse bodyXml with parseXML.
  • Read orientation, max-choices, min-choices from the <qti-order-interaction> element's attributes. Default orientation to "vertical"; leave maxChoices/minChoices as undefined when absent.
  • Extract <qti-prompt> inner HTML → prompt via the shared getPromptHTML helper.
  • Collect <qti-simple-choice> elements → items array (each with id, content, fixed).
  • Parse the response declaration string with QTIDeclaration.fromXML to extract the ordered list of correct identifiers. Reorder items to match the <qti-correct-response> identifier sequence so the editor always shows the canonical correct order.

Export buildOrderingInteractionXML(state, questionType, declarationSchema){ bodyXml: string, responseDeclarations: string[] }:

  • Serialize state.prompt HTML as the <qti-prompt> child.
  • Render each item in state.items order as a <qti-simple-choice>.
  • Emit orientation attribute; omit max-choices/min-choices when they are undefined.
  • Build a <qti-response-declaration> using QTIDeclaration with cardinality="ordered", base-type="identifier", and a <qti-correct-response> listing item identifiers in state.items order.
  • Return { bodyXml, responseDeclarations: [declarationXml] }.

Export _defaultState() for use in tests and the descriptor. Crucial: It must seed the items array with two empty items (each with a generated ID). This ensures the component renders correctly for a new item, prevents an empty <qti-correct-response/> schema error, and satisfies the "requires at least 2 items" rule out of the box.

2. interactions/ordering/validate.js

Export validateOrderingInteraction(state)ValidationError[]:

Rule Condition
Prompt required state.prompt is empty or whitespace-only
Empty item content Any item has empty or whitespace-only content
Too few items Fewer than 2 items in state.items (spec: at least 2 to be useful)

3. interactions/ordering/OrderingInteractionDescriptor.js

Define the descriptor class following the same pattern as ChoiceInteractionDescriptor:

  • type: QtiInteraction.ORDER ('qti-order-interaction')
  • placement: 'block'
  • questionTypes: [QuestionType.ORDERING]
  • matches(el): el.tagName.toLowerCase() === QtiInteraction.ORDER
  • getQuestionType(): always returns QuestionType.ORDERING
  • getResponseDeclarationSchema(): returns { baseType: BaseType.IDENTIFIER, cardinality: Cardinality.ORDERED }
  • parse(bodyXml, responseDeclarations): delegates to parseOrderingInteraction
  • buildXML(state, questionType): delegates to buildOrderingInteractionXML
  • validate(state): delegates to validateOrderingInteraction
  • convertsFrom: []

Export the singleton orderingInteractionDescriptor.

4. interactions/ordering/index.js

import defineInteraction from '../defineInteraction';
import OrderingInteractionEditor from './OrderingInteractionEditor.vue';
import { orderingInteractionDescriptor } from './OrderingInteractionDescriptor';

export default defineInteraction(orderingInteractionDescriptor, OrderingInteractionEditor);

5. interactions/index.js — register the descriptor

Add the ordering descriptor to the descriptors array alongside choiceDescriptor and textEntryDescriptor.

6. constants.js & qtiEditorStrings.js

  • Add ORDERING: 'ordering' to the QuestionType freeze object in constants.js.
  • Add an explicit translation key (e.g., orderingLabel$: 'Ordering') to qtiEditorStrings.js and map it explicitly in the UI string lookup. Do not dynamically concatenate translation keys.

7. composables/useOrderingInteraction.js

Build on useInteraction and expose state-mutation methods:

  • addItem() — appends a new item with generateRandomSlug('order') id and empty content.
  • removeItem(id) — no-op when only one item remains.
  • moveItemUp(id) / moveItemDown(id) — swap array positions; no-op at boundaries.
  • setItemContent(id, html) — updates the item's content field.
  • setShuffle(shuffle) — toggles the shuffle boolean in the state.

8. interactions/ordering/OrderingInteractionEditor.vue

A Vue SFC wiring the composable to the UI.

Props:

props: {
  interaction: Object,     // { bodyXml, responseDeclarations }
  questionType: String,    // 'ordering'
  mode: String,            // 'edit' | 'view'
  showAnswers: Boolean,
  teleportTargetId: String,
}

Emits: 'update:interaction'{ bodyXml, responseDeclarations } whenever state changes.

UI behaviour:

  • Renders the prompt RTE at the top.
  • Renders a "Correct order" header with subtext "Learners will see these shuffled" above the items list.
  • Renders a list of item rows. Each row shows:
    • A drag handle (6 dots) on the far left.
    • A green numbered box indicating its position in the correct order (1, 2, 3...).
    • A content RTE (same TipTapEditor used in ChoiceInteractionEditor). Must have :readonly="mode !== 'edit'".
    • Move-up (^), move-down (v), and delete (X) icon buttons on the right (delete is disabled when only one item remains). A11y: Tooltips/aria-labels must be parameterized with the item's position (e.g., "Delete option {number}").
  • Use the shared AddListItemButton component for the "Add option" button appended below the list.
  • Focus Management: When addItem is clicked, use Vue template refs (e.g., an array of refs via v-for) to set focus on the newly added item's text editor. Do not use document.getElementById.
  • In view mode with showAnswers: true: items shown in correct order, read-only, under an "Answers" header. The drag handles, up/down/delete buttons, and "Add option" button are hidden.
  • In view mode with showAnswers: false: items hidden.
  • Emits 'update:interaction'. Bug prevention: The watcher triggering this emit must include an early return (if (props.mode !== 'edit') return;) so view mode doesn't swallow or mutate XML state.
  • Calls runValidation() on prompt blur and each item content RTE blur. Validation UX: Do not run validation immediately on addItem(), as it creates a harsh UX by turning a pristine input red.
  • Note on shuffle: The design currently assumes shuffle is active. We will use the standard AnswerSettings component (via teleport) to provide a toggle for the shuffle attribute, pending final design confirmation on how it should be presented.

Acceptance Criteria

parse.js

  • parseOrderingInteraction(bodyXml, responseDeclarations) returns an OrderingState with correct prompt, items, and orientation values.
  • Items in state.items are ordered to match the <qti-correct-response> identifier sequence when a declaration is present.
  • Items missing an identifier attribute are assigned a generated order_<8chars> slug.
  • maxChoices and minChoices are preserved from the XML when present; undefined when absent.
  • buildOrderingInteractionXML returns { bodyXml, responseDeclarations } that round-trips: parse(buildXML(state)) produces an equivalent state.
  • buildOrderingInteractionXML emits identifiers in state.items order inside <qti-correct-response>.
  • orientation defaults to "vertical" when absent; max-choices/min-choices are omitted from the serialized XML when undefined.
  • shuffle attribute is read during parsing and emitted correctly by buildOrderingInteractionXML.

validate.js

  • Returns PROMPT_REQUIRED when state.prompt is empty or whitespace-only.
  • Returns EMPTY_ANSWER_CONTENT for each item with empty or whitespace-only content.
  • Returns TOO_FEW_CHOICES when fewer than 2 items are present.
  • Returns an empty array when the state is valid.

Descriptor + registry

  • OrderingInteractionDescriptor passes defineInteraction validation without errors.
  • getQuestionType() always returns QuestionType.ORDERING.
  • The descriptor is included in the interactions/index.js registry.
  • QuestionType.ORDERING is added to constants.js.

useOrderingInteraction

  • addItem() appends a new item with a generated order_<8 chars> identifier and empty content.
  • removeItem(id) is a no-op when only one item remains.
  • moveItemUp(id) / moveItemDown(id) swap positions correctly; no-op at first/last position.
  • setItemContent(id, html) updates only the targeted item's content.
  • errors starts empty; runValidation() populates it via validateOrderingInteraction.

OrderingInteractionEditor.vue

  • In edit mode: renders the prompt RTE, numbered item list with move/delete controls, and "Add option" button.
  • In view mode with showAnswers: true: items are shown in correct order, read-only.
  • In view mode with showAnswers: false: items are hidden.
  • Emits update:interaction whenever state changes, with an early return blocking emits in view mode.
  • Calls runValidation() on blur, never immediately on addItem().
  • Works end-to-end: loading a <qti-order-interaction> XML renders pre-filled; reordering items updates the live XML.

Testing

  • Unit tests for parseOrderingInteraction: round-trip for ordering items, correct-response ordering, orientation default, absent max-choices/min-choicesundefined, and shuffle attribute preservation.
  • Unit tests for buildOrderingInteractionXML: identifier order in declaration matches state.items order; shuffle attribute emitted correctly.
  • Unit tests for validateOrderingInteraction: each error condition covered, valid state returns [].
  • Unit tests for useOrderingInteraction: each mutation method produces the expected state change.
  • OrderingInteractionEditor.spec.js: edit mode rendering, view mode (showAnswers on/off), validation display, update:interaction emit on mutation.
  • Existing lint and test suites pass.

References

  • QTI 3.0 spec §3.2.10 Order Interaction: IMS Global BPIG and QTI 3.0 XSD schema (OrderInteractionDType) which explicitly defines the shuffle attribute.
  • Architecture: shared/views/QTIEditor/ — see interactions/choice/ as the reference implementation
  • Existing reordering utilities: composables/useChoiceInteraction.jsmoveChoiceUp / moveChoiceDown
  • Backend QTI reference: contentcuration/utils/assessment/qti/archive.py

AI usage

I used Claude (Antigravity) to draft this issue from design decisions and the QTI editor architecture.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions