Repository navigation
[QTI] Implement text entry interaction editor #5979
Description
❌ 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 textEntry interaction plugin end-to-end: XML parsing, XML assembly, validation, a useTextEntryInteraction composable built on useInteraction, and a working TextEntryEditor.vue that handles both numeric and free-response question types.
This task depends on the choice interaction plugin being in place (for useInteraction, generateRandomSlug, and defineInteraction).
Complexity: Medium
Target branch: unstable
Depends on: [QTI] Implement choice interaction (#5978)
Context
The text entry interaction maps to <qti-text-entry-interaction> and covers two question types:
numeric— one or more acceptable float answers; rendered as a numeric text input with a list of acceptable answer rows below the promptfreeResponse— no correct answer; the student types freely; no answer list is shown
Both are served by a single textEntry descriptor.
<qti-text-entry-interaction> is a QTI inline element — it is embedded within the body's paragraph flow rather than standing alone as a block element. However, for Studio's authoring model, this interaction occupies the entire item body (one text-entry per item), so parseItem returns the whole <qti-item-body> as a single interaction block. Multi-text-entry items (multiple inline interactions in one body) are explicitly out of scope.
State shape
parse produces (and buildXML consumes) a single flat state object:
/**
* TextEntryState
* @property {string} prompt — HTML content of the prompt (everything before the interaction element); default ""
* @property {TextEntryAnswer[]} answers — acceptable answers; always [] for freeResponse
* @property {number} expectedLength — value of expected-length attribute; always 50 for freeResponse; 0 (absent) for numeric
*/
/**
* TextEntryAnswer
* @property {string} id — client-side key only, not serialized to XML; e.g. "answer_xlqTuVoq"
* @property {string} value — the numeric string, e.g. "12" or "0.5" or "-3.14e2"
*/QTI XML reference
Body XML — shared structure (inline interaction embedded in body):
<qti-item-body>
<div>
<p>What is 3 × 4?</p>
<p><qti-text-entry-interaction response-identifier="RESPONSE" /></p>
</div>
</qti-item-body>For free response, add expected-length="50" on the interaction element:
<p><qti-text-entry-interaction response-identifier="RESPONSE" expected-length="50" /></p>Response declaration — numeric, single acceptable answer:
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="float">
<qti-correct-response>
<qti-value>12</qti-value>
</qti-correct-response>
</qti-response-declaration>Response declaration — numeric, multiple acceptable answers:
<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="float">
<qti-correct-response>
<qti-value>0.5</qti-value>
<qti-value>1/2</qti-value>
</qti-correct-response>
</qti-response-declaration>Response declaration — free response (no correct response):
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string" />Rules:
cardinalityis"single"when there is exactly one answer,"multiple"when there are two or more.base-typeis"float"for numeric,"string"for free response.- Free response has no
<qti-correct-response>element at all. response-identifieron the interaction andidentifieron the declaration both use the constant"RESPONSE".
The Change
1. interactions/textEntry/parse.js
Export parse(bodyXml, responseDeclarations) → TextEntryState:
- Parse
bodyXml(the entire<qti-item-body>inner HTML) withparseXML. - Locate the
<qti-text-entry-interaction>element. - Read
expected-lengthfrom the element's attributes; default to0. - Collect everything in the body but the element's containing
<p>intopromptas serialized HTML. - Parse the response declaration string(s) via the
QTIDeclarationhelper (from [QTI] Build the QTI declaration model with XML parsing and serialization #5965):- If
base-type="float"and a<qti-correct-response>exists → collect each<qti-value>text content as an answer. - Otherwise →
answers: [].
- If
Export buildXML(state, questionType) → { bodyXml: string, declarations: string[] }:
- Serialize
state.promptHTML as the block content before the interaction. - Append
<p><qti-text-entry-interaction response-identifier="RESPONSE"with:expected-length="expectedLenghtRef".
- For
numeric: build a<qti-response-declaration>withbase-type="float",cardinality="single"(1 answer) or"multiple"(2+ answers), and a<qti-correct-response>listing eachstate.answers[i].valueas a<qti-value>. - For
freeResponse: build a self-closing<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string" />. - Wrap the body in a
<div>and return{ bodyXml, declarations: [declarationString] }.
2. interactions/textEntry/validate.js
Export validate(state, questionType) → ValidationError[]:
| Rule | Condition |
|---|---|
| Prompt required | state.prompt is empty or whitespace-only |
| No correct answer | questionType === 'numeric' and state.answers.length === 0 |
| Invalid numeric value | questionType === 'numeric' and any answer.value does not match floatOrIntRegex |
Copy floatOrIntRegex from channelEdit/utils.js to the QTIEditor folder.
3. InteractionDescriptor — extend getQuestionType signature
Update the typedef comment in interactions/defineInteraction.js to note that getQuestionType may optionally receive a second argument:
/**
* @property {(bodyEl: Element, declarations?: Element[]) => string | null} getQuestionType
*/4. interactions/textEntry/index.js
Define the descriptor.
5. interactions/index.js — register the descriptor
Add the textEntry descriptor to the registry aggregator alongside choice.
6. composables/useTextEntryInteraction.js
Builds on useInteraction and exposes state-mutation methods.
Note: There is no moveAnswerUp/moveAnswerDown — answer order is not meaningful for numeric acceptable-answer lists.
7. interactions/textEntry/TextEntryEditor.vue
A Vue SFC that wires the composable to the UI.
UI Code can be reused/be similar from the current assessment editor. UI specs is similar to the current assessment editor.
Out of Scope
- Multiple
<qti-text-entry-interaction>elements in a single item body. - Hints (
HintsSection). - Partial-credit scoring via
<qti-mapping>.
Acceptance Criteria
parse.js
-
parse(bodyXml, responseDeclarations)returns aTextEntryStatewith correctprompt,answers, andexpectedLengthvalues. - For a
numericitem, each entry inanswershas{ id, value }wherevaluematches a<qti-value>from the correct response. - For a
freeResponseitem,answersis[]andexpectedLengthis50. -
buildXML(state, questionType)produces XML that, when re-parsed, yields an equivalent state (round-trip stable). -
buildXMLwithquestionType === 'freeResponse'emits a self-closing declaration with no<qti-correct-response>. -
buildXMLsetscardinality="single"for 1 answer,cardinality="multiple"for 2+ answers. -
buildXMLaddsexpected-length="50" placeholder-text="Enter your answer here"only forfreeResponse.
validate.js
- Returns
PROMPT_REQUIREDwhenstate.promptis empty or whitespace-only. - Returns
NO_CORRECT_ANSWERfornumericwhenstate.answers.length === 0. - Returns
INVALID_NUMERIC_VALUEfor each answer whosevaluedoes not matchfloatOrIntRegex. - Returns an empty array when state is valid.
Descriptor + registry
-
textEntry/index.jsexports a valid descriptor accepted bydefineInteraction. -
getQuestionType(bodyEl, declarations)returns'numeric'for float declarations and'freeResponse'otherwise. - The descriptor is included in the registry aggregator.
useTextEntryInteraction
-
addAnswer()appends a new answer with agenerateRandomSlug('answer')id and empty value. -
removeAnswer(id)is a no-op when only one answer remains. -
updateAnswerValue(id, value)mutates the correct answer entry. -
errorsstarts empty;runValidation()populates it by callingvalidate(state, questionType).
TextEntryEditor.vue
- For
freeResponse: renders only the prompt RTE; no answer rows or "Add" button. - For
numeric: renders the prompt RTE and the acceptable-answer list with delete buttons and an "Add acceptable answer" button. - Calls
runValidation()on prompt blur and on each answer input blur. - Calls
runValidation()immediately afteraddAnswerandremoveAnswer. - Displays
INVALID_NUMERIC_VALUEerrors inline next to the offending answer input. - Works end-to-end on the demo page: loading a
<qti-text-entry-interaction>XML renders the editor pre-filled; editing answers updates the live XML.
Testing
- Unit tests for
parse: round-trip for both question types,expectedLengthdefaults, missing declaration defaults tofreeResponse. - Unit tests for
buildXML: cardinality derived from answer count, free response has no<qti-correct-response>. - Unit tests for
validate: each error condition covered, valid state returns[]. - Unit tests for
useTextEntryInteraction: each mutation produces the expected state change. - Existing lint and test suites pass.
References
- Architecture proposal:
QTI Editor — Frontend Architecture Description(Notion) - Existing numeric validation regex:
frontend/channelEdit/utils.js→floatOrIntRegex - Existing assessment editor numeric answer UI:
frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue - Existing validation logic:
frontend/shared/utils/validation.js→getAssessmentItemErrors - Backend QTI serialization reference:
contentcuration/utils/assessment/qti/archive.py→_create_text_entry_interaction_and_response - QTI 3.0 spec for
<qti-text-entry-interaction>: attributesexpected-length,placeholder-text,pattern-mask; inline interaction element
AI usage
I used Claude (Claude Code) to draft this issue from design decisions and the QTI editor architecture.

Activity