Skip to content

[QTI] Implement text entry interaction editor #5979

@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

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):

<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:

  • cardinality is "single" when there is exactly one answer, "multiple" when there are two or more.
  • base-type is "float" for numeric, "string" for free response.
  • Free response has no <qti-correct-response> element at all.
  • response-identifier on the interaction and identifier on 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) with parseXML.
  • Locate the <qti-text-entry-interaction> element.
  • Read expected-length from the element's attributes; default to 0.
  • Collect everything in the body but the element's containing <p> into prompt as serialized HTML.
  • Parse the response declaration string(s) via the QTIDeclaration helper (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: [].

Export buildXML(state, questionType){ bodyXml: string, declarations: string[] }:

  • Serialize state.prompt HTML 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> 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] }.

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 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.

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?: string[]) => string | null} getQuestionType
 */

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.
  • Existing lint and test suites pass.

References

  • Architecture proposal: QTI Editor — Frontend Architecture Description (Notion)
  • Existing numeric validation regex: frontend/channelEdit/utils.jsfloatOrIntRegex
  • Existing assessment editor numeric answer UI: frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue
  • Existing validation logic: frontend/shared/utils/validation.jsgetAssessmentItemErrors
  • 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>: attributes expected-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.

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