Skip to content

[QTI] Support legacy assessment item types in the QTI Editor #5980

@AlexVelezLl

Description

@AlexVelezLl

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

Overview

Extend the QTIEditor to accept and render legacy assessment items (those stored with the old AssessmentItemTypestrue_false, single_selection, multiple_selection, input_question, free_response) alongside new QTI items (type: 'QTI'). On the user's first edit of a legacy item, the editor migrates it in-place to the QTI format and emits the updated item up the component tree.

This is a bridge task: it requires the choice and textEntry interaction plugins to be in place and does not touch any other interaction type.

Complexity: Medium
Target branch: unstable
Depends on: #5978 and #5979

Context

The backend AssessmentItem model has two representations:

Field Legacy QTI
type 'true_false' / 'single_selection' / 'multiple_selection' / 'input_question' / 'free_response' 'QTI'
question Markdown string empty / absent
answers [{ answer, correct, order }] empty / absent
raw_data absent { bodyXml, responseDeclarations }

The QTIEditor currently only knows how to deal with items where type is QTI. This task teaches it to accept the full legacy shape as well, route each item to the right interaction plugin, and migrate it on first edit.

true_false items are migrated to singleSelect choice interactions: the legacy { answer: 'True', correct: true } / { answer: 'False', correct: false } answers are preserved as-is and become regular choice answers after markdown-to-HTML conversion. There is no special UI treatment — once migrated the item is an ordinary single-select question.

perseus_question items are rendered as a read-only placeholder card labeled "Perseus question". No editor is shown and no migration happens — any emit from this card re-emits the original item data unchanged.


What needs to change

1. Extract preprocessMarkdown to shared/utils/markdown.js

preprocessMarkdown currently lives in shared/views/TipTapEditor/TipTapEditor/utils/markdown.js. The parse.js modules for choice and textEntry need it, but importing from deep inside TipTap creates an awkward circular-looking dependency.

Copy preprocessMarkdown (and its helpers IMAGE_REGEX, imageMdToParams, MATH_REGEX, mathMdToParams, UNDERLINE_REGEX) to shared/utils/markdown.js (In the future, RTE won't support markdown processing anymore, so will be removed from there).

2. Legacy type → descriptor mapping

We should have a single LEGACY_TYPE_MAP to get the decriptor and questionType when the assessment item's type is different from QTI. And instead of using the descriptor.matches we can use a map like:

import choiceDescriptor from './choice/index.js';
import textEntryDescriptor from './textEntry/index.js';
import { AssessmentItemTypes } from './constants.js'; // copy this constant from shared/constants.js if not already present.

export const LEGACY_TYPE_MAP = {
  [AssessmentItemTypes.TRUE_FALSE]:        { descriptor: choiceDescriptor,    questionType: 'singleSelect' },
  [AssessmentItemTypes.SINGLE_SELECTION]:  { descriptor: choiceDescriptor,    questionType: 'singleSelect' },
  [AssessmentItemTypes.MULTIPLE_SELECTION]:{ descriptor: choiceDescriptor,    questionType: 'multiSelect'  },
  [AssessmentItemTypes.INPUT_QUESTION]:    { descriptor: textEntryDescriptor, questionType: 'numeric'      },
  [AssessmentItemTypes.FREE_RESPONSE]:     { descriptor: textEntryDescriptor, questionType: 'freeResponse' },
};

true_false maps to singleSelectparseLegacyChoice treats it identically to single_selection and the existing "True"/"False" answer strings become regular choice content after markdown conversion. perseus_question is intentionally absent from this map; see point 4 for how QTIEditor handles it.

3. QTIEditor — accept both item shapes

Update the QTIEditor.assessments prop to accept items with either the legacy or QTI shape. The discriminator is the type field:

// Legacy shape (accepted, migrated on edit)
{ assessment_id, type: 'single_selection', question: '...', answers: [...], ... }

// QTI shape (current)
{ id, type: 'QTI', title: '', raw_data: '<qti-assessment-item>...</qti-assessment-item>' }

For display purposes, compute a normalized interactionType per item:

  • If type === 'QTI' → derive from raw_data via parseItem as before.
  • If type is in LEGACY_TYPE_MAP → use LEGACY_TYPE_MAP[type].descriptor.parse.
  • If type === 'perseus_question' → pass through to QTIItemEditor which renders a read-only placeholder body.
  • Otherwise → null (do not render).

Use assessment_id as the stable key for legacy items (instead of id). All items — including perseus_question — go through QTIItemEditor unchanged; QTIEditor passes only the item object and QTIItemEditor inspects item.type itself.

addItem() always creates a new QTI item (no change needed there).

4. QTIItemEditor — handle Perseus placeholder and derive legacyQuestion internally

// inside QTIItemEditor setup
const isLegacy = computed(() => item.type !== 'QTI');
const legacyQuestion = computed(() =>
  isLegacy.value
    ? { type: item.type, question: item.question, answers: item.answers ?? [] }
    : null,
);

When item.type === 'perseus_question', QTIItemEditor skips the interaction editor entirely and renders a static "Perseus question — cannot be edited here" notice in the card body instead. All toolbar actions (move-up, move-down, delete) still work normally and re-emit the original item data unchanged.

For all other item types, QTIItemEditor passes legacyQuestion through to InteractionSection.

5. InteractionSection — pass legacyQuestion to the editor

InteractionSection already receives the descriptor and questionType (computed from the item or LEGACY_TYPE_MAP). Extend its props with legacyQuestion: Object | null. Pass it as a prop to the interaction editor component (ChoiceEditor, TextEntryEditor).

6. useInteraction — accept legacyQuestion

Extend the base composable signature:

/**
 * useInteraction(descriptor, interactionBlock, questionType, legacyQuestion?)
 *
 * @param {Ref<object|null>} legacyQuestion — when provided, parse() uses it
 *   instead of bodyXml/responseDeclarations for the initial state.
 */
export function useInteraction(descriptor, interactionBlock, questionType, legacyQuestion = null) {
  const initialState = descriptor.parse(interactionBlock?.bodyXml, interactionBlock?.responseDeclarations, legacyQuestion);

  const state = ref(initialState);
  // ...rest unchanged
}

7. parse.js — add parseLegacy branch

Both interactions/choice/parse.js and interactions/textEntry/parse.js need to handle the optional third argument.

export function parse(bodyXml, responseDeclarations, legacyQuestion = null) {
  if (legacyQuestion) return parseLegacyChoice(legacyQuestion);
  // ...existing XML parse logic
}

It imports preprocessMarkdown from shared/utils/markdown.js (after the extraction in step 2).

8. Interaction editors — accept and forward legacyQuestion

ChoiceEditor.vue and TextEntryEditor.vue accept a new prop legacyQuestion: Object | null (default null) and forward it to their respective composables:

const { state, ... } = useChoiceInteraction(props, toRef(props, 'questionType'), toRef(props, 'legacyQuestion'));

useChoiceInteraction and useTextEntryInteraction forward it to useInteraction.

9. Migration on first XML emit

Migration happens smoothly on the first interaction update event after the user edits a legacy item. The editor emits the updated item with type: 'QTI' and the new raw_data containing the migrated XML. Subsequent edits emit normal XML updates without changing the item type again.

What does NOT change

  • perseus_question items render a read-only placeholder card; they are never edited and their data is never mutated by QTIEditor.
  • No new validation rules — the existing validate.js logic already covers both QTI and legacy-derived states.
  • No UI changes to the interaction editors themselves — they are agnostic to whether their initial state came from XML or a legacy object.

Out of Scope

  • Editing or converting perseus_question items — they are permanently read-only in this editor.
  • Bulk migration or background conversion of existing legacy items.
  • Any new interaction type beyond choice and textEntry.

Acceptance Criteria

shared/utils/markdown.js

  • preprocessMarkdown(markdown) is exported from shared/utils/markdown.js.
  • The old import path (TipTapEditor/.../utils/markdown.js) still works via re-export so no existing code breaks.

interactions/legacyTypeMap.js

  • LEGACY_TYPE_MAP['true_false'] returns { descriptor: choiceDescriptor, questionType: 'singleSelect' }.
  • LEGACY_TYPE_MAP['single_selection'] returns { descriptor: choiceDescriptor, questionType: 'singleSelect' }.
  • LEGACY_TYPE_MAP['multiple_selection'] returns { descriptor: choiceDescriptor, questionType: 'multiSelect' }.
  • LEGACY_TYPE_MAP['input_question'] returns { descriptor: textEntryDescriptor, questionType: 'numeric' }.
  • LEGACY_TYPE_MAP['free_response'] returns { descriptor: textEntryDescriptor, questionType: 'freeResponse' }.

QTIEditor

  • Accepts items with either the legacy shape (question, answers, legacy type) or the QTI shape (raw_data, type: 'QTI').
  • Routes all items — including perseus_question — through QTIItemEditor with just the item object; no legacyQuestion prop is computed or passed by QTIEditor.
  • Items with an unrecognized type (not in LEGACY_TYPE_MAP, not 'QTI', not 'perseus_question') are silently excluded from rendering.
  • Handles the update:item event from QTIItemEditor by replacing the item in its list and emitting update.

QTIItemEditor

  • When item.type === 'perseus_question', renders a static "Perseus question — cannot be edited here" notice in the card body; no interaction editor is mounted.
  • Move-up, move-down, and delete toolbar actions still work for Perseus items and re-emit the original item data unchanged.
  • No legacyQuestion or migration logic is triggered for Perseus items.

parse.js (choice and textEntry)

  • parse(null, null, legacyQuestion) returns the correct ChoiceState / TextEntryState without attempting to parse XML.
  • question markdown is converted to HTML via preprocessMarkdown.
  • answers[i].answer markdown is converted to HTML via preprocessMarkdown (choice) or kept as a plain string (text entry numeric).
  • Text entry parseLegacy for free_response returns answers: [] and expectedLength: 50.
  • Text entry parseLegacy for input_question includes only answers where correct === true.

Testing

  • Unit tests for parseLegacyChoice: true-false, single-select, and multi-select cases, markdown-to-HTML conversion, empty question/answers.
  • Unit tests for parseLegacyTextEntry: free-response and numeric cases, only-correct-answers included for numeric.
  • Integration test (or demo-page smoke test): load a legacy single_selection item in QTIEditor, verify the choice editor renders pre-filled, make a change, verify the emitted item has type: 'QTI' and correct raw_data.
  • Existing lint and test suites pass.

References

  • Legacy answer model: frontend/channelEdit/utils.jsfloatOrIntRegex, updateAnswersToQuestionType
  • preprocessMarkdown: frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js
  • Legacy validation: frontend/shared/utils/validation.jsgetAssessmentItemErrors
  • AssessmentItemTypes constants: frontend/shared/constants.js
  • Architecture proposal: QTI Editor — Frontend Architecture Description (Notion)

AI usage

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No fields configured for Task.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions