
❌ 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 AssessmentItemTypes — true_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 singleSelect — parseLegacyChoice 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
interactions/legacyTypeMap.js
QTIEditor
QTIItemEditor
parse.js (choice and textEntry)
Testing
References
- Legacy answer model:
frontend/channelEdit/utils.js → floatOrIntRegex, updateAnswersToQuestionType
preprocessMarkdown: frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js
- Legacy validation:
frontend/shared/utils/validation.js → getAssessmentItemErrors
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.
❌ 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
QTIEditorto accept and render legacy assessment items (those stored with the oldAssessmentItemTypes—true_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
choiceandtextEntryinteraction plugins to be in place and does not touch any other interaction type.Complexity: Medium
Target branch:
unstableDepends on: #5978 and #5979
Context
The backend
AssessmentItemmodel has two representations:type'true_false'/'single_selection'/'multiple_selection'/'input_question'/'free_response''QTI'questionanswers[{ answer, correct, order }]raw_data{ bodyXml, responseDeclarations }The
QTIEditorcurrently only knows how to deal with items wheretypeisQTI. 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_falseitems are migrated tosingleSelectchoice 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_questionitems 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
preprocessMarkdowntoshared/utils/markdown.jspreprocessMarkdowncurrently lives inshared/views/TipTapEditor/TipTapEditor/utils/markdown.js. Theparse.jsmodules forchoiceandtextEntryneed it, but importing from deep inside TipTap creates an awkward circular-looking dependency.Copy
preprocessMarkdown(and its helpersIMAGE_REGEX,imageMdToParams,MATH_REGEX,mathMdToParams,UNDERLINE_REGEX) toshared/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 thedescriptor.matcheswe can use a map like:true_falsemaps tosingleSelect—parseLegacyChoicetreats it identically tosingle_selectionand the existing "True"/"False" answer strings become regular choice content after markdown conversion.perseus_questionis intentionally absent from this map; see point 4 for howQTIEditorhandles it.3.
QTIEditor— accept both item shapesUpdate the
QTIEditor.assessmentsprop to accept items with either the legacy or QTI shape. The discriminator is thetypefield:For display purposes, compute a normalized
interactionTypeper item:type === 'QTI'→ derive fromraw_dataviaparseItemas before.typeis inLEGACY_TYPE_MAP→ useLEGACY_TYPE_MAP[type].descriptor.parse.type === 'perseus_question'→ pass through toQTIItemEditorwhich renders a read-only placeholder body.null(do not render).Use
assessment_idas the stable key for legacy items (instead ofid). All items — includingperseus_question— go throughQTIItemEditorunchanged;QTIEditorpasses only theitemobject andQTIItemEditorinspectsitem.typeitself.addItem()always creates a new QTI item (no change needed there).4.
QTIItemEditor— handle Perseus placeholder and derivelegacyQuestioninternallyWhen
item.type === 'perseus_question',QTIItemEditorskips 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,
QTIItemEditorpasseslegacyQuestionthrough toInteractionSection.5.
InteractionSection— passlegacyQuestionto the editorInteractionSectionalready receives thedescriptorandquestionType(computed from the item orLEGACY_TYPE_MAP). Extend its props withlegacyQuestion: Object | null. Pass it as a prop to the interaction editor component (ChoiceEditor,TextEntryEditor).6.
useInteraction— acceptlegacyQuestionExtend the base composable signature:
7.
parse.js— addparseLegacybranchBoth
interactions/choice/parse.jsandinteractions/textEntry/parse.jsneed to handle the optional third argument.It imports
preprocessMarkdownfromshared/utils/markdown.js(after the extraction in step 2).8. Interaction editors — accept and forward
legacyQuestionChoiceEditor.vueandTextEntryEditor.vueaccept a new proplegacyQuestion: Object | null(defaultnull) and forward it to their respective composables:useChoiceInteractionanduseTextEntryInteractionforward it touseInteraction.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 newraw_datacontaining the migrated XML. Subsequent edits emit normal XML updates without changing the item type again.What does NOT change
perseus_questionitems render a read-only placeholder card; they are never edited and their data is never mutated byQTIEditor.validate.jslogic already covers both QTI and legacy-derived states.Out of Scope
perseus_questionitems — they are permanently read-only in this editor.choiceandtextEntry.Acceptance Criteria
shared/utils/markdown.jspreprocessMarkdown(markdown)is exported fromshared/utils/markdown.js.TipTapEditor/.../utils/markdown.js) still works via re-export so no existing code breaks.interactions/legacyTypeMap.jsLEGACY_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' }.QTIEditorquestion,answers, legacytype) or the QTI shape (raw_data,type: 'QTI').perseus_question— throughQTIItemEditorwith just theitemobject; nolegacyQuestionprop is computed or passed byQTIEditor.type(not inLEGACY_TYPE_MAP, not'QTI', not'perseus_question') are silently excluded from rendering.update:itemevent fromQTIItemEditorby replacing the item in its list and emittingupdate.QTIItemEditoritem.type === 'perseus_question', renders a static "Perseus question — cannot be edited here" notice in the card body; no interaction editor is mounted.legacyQuestionor migration logic is triggered for Perseus items.parse.js(choice and textEntry)parse(null, null, legacyQuestion)returns the correctChoiceState/TextEntryStatewithout attempting to parse XML.questionmarkdown is converted to HTML viapreprocessMarkdown.answers[i].answermarkdown is converted to HTML viapreprocessMarkdown(choice) or kept as a plain string (text entry numeric).parseLegacyforfree_responsereturnsanswers: []andexpectedLength: 50.parseLegacyforinput_questionincludes only answers wherecorrect === true.Testing
parseLegacyChoice: true-false, single-select, and multi-select cases, markdown-to-HTML conversion, empty question/answers.parseLegacyTextEntry: free-response and numeric cases, only-correct-answers included for numeric.single_selectionitem inQTIEditor, verify the choice editor renders pre-filled, make a change, verify the emitted item hastype: 'QTI'and correctraw_data.References
frontend/channelEdit/utils.js→floatOrIntRegex,updateAnswersToQuestionTypepreprocessMarkdown:frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.jsfrontend/shared/utils/validation.js→getAssessmentItemErrorsAssessmentItemTypesconstants:frontend/shared/constants.jsQTI Editor — Frontend Architecture Description(Notion)AI usage
I used Claude (Claude Code) to draft this issue from design decisions and the QTI editor architecture.