You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
❌ 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 prompt
freeResponse — 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):
For numeric: build a <qti-response-declaration> with base-type="float", cardinality="single" (1 answer) or "multiple" (2+ answers), and a <qti-correct-response> listing each state.answers[i].value as 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] }.
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 shared/utils/.
Extend constants.js with new error codes: PROMPT_REQUIRED (shared with choice if not already declared), NO_CORRECT_ANSWER (shared), INVALID_NUMERIC_VALUE.
The second argument is only passed by InteractionSection when placement === 'block' and the underlying QTI element is inline (i.e. the body element contains the interaction rather than being the interaction). Choice and other block interactions ignore it. This extension is backward compatible because declarations is optional with a default of [].
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:
Props:
bodyXml: String,responseDeclarations: Array,// string[]questionType: String,// 'numeric' | 'freeResponse'mode: String,// 'edit' | 'view'displayAnswersPreview: Boolean// Whether "show answers" is on when in view mode
Emits:
'update:bodyXml''update:responseDeclarations'
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 a TextEntryState with correct prompt, answers, and expectedLength values.
For a numeric item, each entry in answers has { id, value } where value matches a <qti-value> from the correct response.
For a freeResponse item, answers is [] and expectedLength is 50.
buildXML(state, questionType) produces XML that, when re-parsed, yields an equivalent state (round-trip stable).
buildXML with questionType === 'freeResponse' emits a self-closing declaration with no <qti-correct-response>.
buildXML sets cardinality="single" for 1 answer, cardinality="multiple" for 2+ answers.
buildXML adds expected-length="50" placeholder-text="Enter your answer here" only for freeResponse.
validate.js
Returns PROMPT_REQUIRED when state.prompt is empty or whitespace-only.
Returns NO_CORRECT_ANSWER for numeric when state.answers.length === 0.
Returns INVALID_NUMERIC_VALUE for each answer whose value does not match floatOrIntRegex.
Returns an empty array when state is valid.
Descriptor + registry
textEntry/index.js exports a valid descriptor accepted by defineInteraction.
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 a generateRandomSlug('answer') id and empty value.
removeAnswer(id) is a no-op when only one answer remains.
updateAnswerValue(id, value) mutates the correct answer entry.
errors starts empty; runValidation() populates it by calling validate(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.
Emits update:bodyXml and update:responseDeclarations reactively.
Calls runValidation() on prompt blur and on each answer input blur.
Calls runValidation() immediately after addAnswer and removeAnswer.
Displays INVALID_NUMERIC_VALUE errors 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, expectedLength defaults, missing declaration defaults to freeResponse.
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.
❌ 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
textEntryinteraction plugin end-to-end: XML parsing, XML assembly, validation, auseTextEntryInteractioncomposable built onuseInteraction, and a workingTextEntryEditor.vuethat handles both numeric and free-response question types.This task depends on the
choiceinteraction plugin being in place (foruseInteraction,generateRandomSlug, anddefineInteraction).Complexity: Medium
Target branch:
unstableDepends 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 shownBoth are served by a single
textEntrydescriptor.<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), soparseItemreturns 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
parseproduces (andbuildXMLconsumes) a single flat state object:QTI XML reference
Body XML — shared structure (inline interaction embedded in body):
For free response, add
expected-length="50"on the interaction element:Response declaration — numeric, single acceptable answer:
Response declaration — numeric, multiple acceptable answers:
Response declaration — free response (no correct response):
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.<qti-correct-response>element at all.response-identifieron the interaction andidentifieron the declaration both use the constant"RESPONSE".The Change
1.
interactions/textEntry/parse.jsExport
parse(bodyXml, responseDeclarations)→TextEntryState:bodyXml(the entire<qti-item-body>inner HTML) withparseXML.<qti-text-entry-interaction>element.expected-lengthfrom the element's attributes; default to0.<p>intopromptas serialized HTML.QTIDeclarationhelper (from [QTI] Build the QTI declaration model with XML parsing and serialization #5965):base-type="float"and a<qti-correct-response>exists → collect each<qti-value>text content as an answer.answers: [].Export
buildXML(state, questionType)→{ bodyXml: string, declarations: string[] }:state.promptHTML as the block content before the interaction.<p><qti-text-entry-interaction response-identifier="RESPONSE"with:expected-length="expectedLenghtRef".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>.freeResponse: build a self-closing<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string" />.<div>and return{ bodyXml, declarations: [declarationString] }.2.
interactions/textEntry/validate.jsExport
validate(state, questionType)→ValidationError[]:state.promptis empty or whitespace-onlyquestionType === 'numeric'andstate.answers.length === 0questionType === 'numeric'and anyanswer.valuedoes not matchfloatOrIntRegexCopy
floatOrIntRegexfromchannelEdit/utils.jstoshared/utils/.Extend
constants.jswith new error codes:PROMPT_REQUIRED(shared with choice if not already declared),NO_CORRECT_ANSWER(shared),INVALID_NUMERIC_VALUE.3.
InteractionDescriptor— extendgetQuestionTypesignatureUpdate the typedef comment in
interactions/defineInteraction.jsto note thatgetQuestionTypemay optionally receive a second argument:The second argument is only passed by
InteractionSectionwhenplacement === 'block'and the underlying QTI element is inline (i.e. the body element contains the interaction rather than being the interaction). Choice and other block interactions ignore it. This extension is backward compatible becausedeclarationsis optional with a default of[].4.
interactions/textEntry/index.jsDefine the descriptor.
5.
interactions/index.js— register the descriptorAdd the
textEntrydescriptor to the registry aggregator alongsidechoice.6.
composables/useTextEntryInteraction.jsBuilds on
useInteractionand exposes state-mutation methods.Note: There is no
moveAnswerUp/moveAnswerDown— answer order is not meaningful for numeric acceptable-answer lists.7.
interactions/textEntry/TextEntryEditor.vueA Vue SFC that wires the composable to the UI:
Props:
Emits:
UI Code can be reused/be similar from the current assessment editor. UI specs is similar to the current assessment editor.
Out of Scope
<qti-text-entry-interaction>elements in a single item body.HintsSection).<qti-mapping>.Acceptance Criteria
parse.jsparse(bodyXml, responseDeclarations)returns aTextEntryStatewith correctprompt,answers, andexpectedLengthvalues.numericitem, each entry inanswershas{ id, value }wherevaluematches a<qti-value>from the correct response.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.jsPROMPT_REQUIREDwhenstate.promptis empty or whitespace-only.NO_CORRECT_ANSWERfornumericwhenstate.answers.length === 0.INVALID_NUMERIC_VALUEfor each answer whosevaluedoes not matchfloatOrIntRegex.Descriptor + registry
textEntry/index.jsexports a valid descriptor accepted bydefineInteraction.getQuestionType(bodyEl, declarations)returns'numeric'for float declarations and'freeResponse'otherwise.useTextEntryInteractionaddAnswer()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.vuefreeResponse: renders only the prompt RTE; no answer rows or "Add" button.numeric: renders the prompt RTE and the acceptable-answer list with delete buttons and an "Add acceptable answer" button.update:bodyXmlandupdate:responseDeclarationsreactively.runValidation()on prompt blur and on each answer input blur.runValidation()immediately afteraddAnswerandremoveAnswer.INVALID_NUMERIC_VALUEerrors inline next to the offending answer input.<qti-text-entry-interaction>XML renders the editor pre-filled; editing answers updates the live XML.Testing
parse: round-trip for both question types,expectedLengthdefaults, missing declaration defaults tofreeResponse.buildXML: cardinality derived from answer count, free response has no<qti-correct-response>.validate: each error condition covered, valid state returns[].useTextEntryInteraction: each mutation produces the expected state change.References
QTI Editor — Frontend Architecture Description(Notion)frontend/channelEdit/utils.js→floatOrIntRegexfrontend/channelEdit/components/AnswersEditor/AnswersEditor.vuefrontend/shared/utils/validation.js→getAssessmentItemErrorscontentcuration/utils/assessment/qti/archive.py→_create_text_entry_interaction_and_response<qti-text-entry-interaction>: attributesexpected-length,placeholder-text,pattern-mask; inline interaction elementAI usage
I used Claude (Claude Code) to draft this issue from design decisions and the QTI editor architecture.