Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/frontend-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
lfs: true
- name: Use Node.js
uses: actions/setup-node@v6
with:
Expand Down
100 changes: 48 additions & 52 deletions kolibri/plugins/qti_viewer/frontend/components/AssessmentItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,47 +15,30 @@
import { computed, inject, provide, watch } from 'vue';
import cloneDeep from 'lodash/cloneDeep';
import { createSafeHTML } from 'kolibri-common/components/SafeHTML';
import { QTIVariable } from '../utils/qti/declarations';
import { useQTIContext } from '../composables/useQTIContext';
import ChoiceInteraction from './interactions/ChoiceInteraction.vue';
import Prompt from './Prompt.vue';
import SimpleChoice from './interactions/SimpleChoice.vue';
import TextEntryInteraction from './interactions/TextEntryInteraction.vue';

/**
* Extract QTI declarations of a specific type from an XML document
* @param {Document} xmlDocument - The QTI XML document
* @param {string} declarationType - 'response', 'outcome', or 'context'
* @param {Function} interactionHandler - a function that is called when a variable value is set
* @param {Ref{Object}} injectedAnswerState - a computed ref that contains any injected answers
* @returns {Object} Map of identifier -> QTIVariable
*/
function getQTIDeclarations(xmlDocument, declarationType, interactionHander) {
const declarations = {};

const selector = `qti-${declarationType}-declaration`;

const nodes = xmlDocument.querySelectorAll(selector);

for (const node of nodes) {
const variable = new QTIVariable(node, interactionHander);
declarations[variable.identifier] = variable;
}
return declarations;
}

function clearObject(obj) {
for (const key in obj) {
delete obj[key];
}
}

const SafeHTML = createSafeHTML({
[ChoiceInteraction.tag]: ChoiceInteraction,
[Prompt.tag]: Prompt,
[SimpleChoice.tag]: SimpleChoice,
[TextEntryInteraction.tag]: TextEntryInteraction,
});

/**
* @typedef {Object} CheckAnswerResult
* @property {Object<string, *>} outcomes
* Snapshot of current outcome variable values keyed by identifier, e.g.
* `{ SCORE: 1 }`. Written by response processing during `checkAnswer`.
* @property {Object<string, *>} answerState
* Snapshot of response variable values plus a `QTI_CONTEXT` entry
* containing the active QTI context record. Shape suitable for persisting
* and re-injecting via the `answerState` prop on a later mount.
*/

export default {
name: 'AssessmentItem',
components: {
Expand All @@ -72,51 +55,64 @@
});

const { interaction, registerCheckAnswer } = inject('handlers');

const QTI_CONTEXT = inject('QTI_CONTEXT');

const injectedAnswerState = inject('answerState');

const responses = {};
// Use the QTI context composable for declaration management and response processing.
// The interaction callback is called when any response variable value changes,
// notifying the parent (QTIViewer) that the user has interacted.
const qtiContext = useQTIContext(props, {
onValueChange: interaction,
});

const { responses, processResponses } = qtiContext;

function setFromAnswerState() {
for (const key in responses) {
if (injectedAnswerState.value[key]) {
responses[key].value = injectedAnswerState.value[key];
for (const [id, variable] of Object.entries(responses.value)) {
if (id in injectedAnswerState.value && injectedAnswerState.value[id] != null) {
variable.value = injectedAnswerState.value[id];
} else {
responses[key].reset();
variable.reset();
}
}
}

// Currently this only handles response variable declarations,
// as that is all we need for survey functionality.
// Extract response declarations
function setResponseDeclarations() {
clearObject(responses);
Object.assign(responses, getQTIDeclarations(props.xmlDoc, 'response', interaction));
setFromAnswerState();
}

registerCheckAnswer(() => {
// Run response processing to compute outcome values (e.g., SCORE)
processResponses();

const answerState = {};
for (const key in responses) {
answerState[key] = cloneDeep(responses[key].value);
for (const [id, variable] of Object.entries(responses.value)) {
answerState[id] = cloneDeep(variable.value);
}
// Eventually this will come more generally from processing context declarations
// but for now store this as the only context that we handle
// Eventually this will come more generally from processing context
// declarations, but for now QTI_CONTEXT is the only context we store.
answerState['QTI_CONTEXT'] = cloneDeep(QTI_CONTEXT.value);

// Extract outcome values for the caller
const outcomes = {};
for (const [id, variable] of Object.entries(qtiContext.outcomes.value)) {
outcomes[id] = variable.value;
}

return {
correct: 1,
outcomes,
answerState,
};
});

provide('responses', responses);

watch(() => props.xmlDoc, setResponseDeclarations);
watch(
() => props.xmlDoc,
() => {
// responses computed ref auto-updates when xmlDoc changes;
// sync from answer state after rebuild
setFromAnswerState();
},
);
watch(() => injectedAnswerState.value, setFromAnswerState);
setResponseDeclarations();
setFromAnswerState();

return {
itemBody,
Expand Down
159 changes: 116 additions & 43 deletions kolibri/plugins/qti_viewer/frontend/components/QTISandboxPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,44 +25,28 @@

<div class="answer-state-editor">
<h3>Answer State</h3>
<div class="answer-state-controls">
<KButton
size="small"
:disabled="!newKeyName"
@click="addAnswerStateKey"
>
Add Key
</KButton>
<input
v-model="newKeyName"
placeholder="Key name"
class="key-input"
@keyup.enter="addAnswerStateKey"
>
</div>
<div class="answer-state-items">
<div
v-for="(value, key) in answerState"
v-for="(value, key) in currentAnswerState"
:key="key"
class="answer-state-item"
>
<span class="key-name">{{ key }}:</span>
<input
:value="value"
:value="formatAnswerValue(value)"
class="value-input"
@input="updateAnswerStateValue(key, $event.target.value)"
>
<KIconButton
icon="close"
size="small"
@click="removeAnswerStateKey(key)"
/>
</div>
<div
v-if="Object.keys(answerState).length === 0"
v-if="Object.keys(currentAnswerState).length === 0"
class="empty-state-small"
>
No answer state keys
{{
selectedXml
? 'This item has no response declarations.'
: 'Select an item to populate the answer state.'
}}
</div>
</div>
</div>
Expand All @@ -73,10 +57,13 @@
<div class="qti-preview-container">
<ContentViewer
v-if="selectedXml"
ref="contentViewer"
:itemData="selectedXml"
:interactive="interactive"
:answerState="answerState"
:answerState="userAnswerState"
preset="qti"
@startTracking="refreshOutcomes"
@interaction="refreshOutcomes"
/>
<div
v-else
Expand All @@ -85,6 +72,25 @@
Select a QTI item to see the preview
</div>
</div>

<div class="outcomes-panel">
<h3>Outcomes</h3>
<div
v-if="Object.keys(outcomes).length === 0"
class="empty-state-small"
>
This item has no outcome declarations.
</div>
<div
v-for="(value, key) in outcomes"
v-else
:key="key"
class="outcome-item"
>
<span class="key-name">{{ key }}:</span>
<code class="outcome-value">{{ formatOutcome(value) }}</code>
</div>
</div>
</div>
</div>

Expand Down Expand Up @@ -163,12 +169,20 @@

data() {
return {
answerState: {},
// What the user types into the answer-state inputs. Passed as the
// :answerState prop to seed responses. Never written from
// checkAnswer output.
userAnswerState: {},
// Snapshot of the live response values from the most recent
// checkAnswer call. Drives the answer-state display and key list.
currentAnswerState: {},
// Snapshot of the live outcome values from the most recent
// checkAnswer call.
outcomes: {},
interactive: true,
showSidePanel: false,
inputtedXml: '',
structure,
newKeyName: '',
};
},

Expand All @@ -189,6 +203,16 @@
},
},

watch: {
async selectedXml() {
this.outcomes = {};
this.currentAnswerState = {};
this.userAnswerState = {};
await this.$nextTick();
this.refreshOutcomes();
},
},

methods: {
selectItem(item) {
if (item && items[item.identifier] && item.identifier !== this.itemId) {
Expand All @@ -197,26 +221,33 @@
this.showSidePanel = false;
}
},
addAnswerStateKey() {
if (this.newKeyName && !this.answerState[this.newKeyName]) {
this.answerState = {
...this.answerState,
[this.newKeyName]: '',
};
this.newKeyName = '';
}
},
removeAnswerStateKey(key) {
const newState = { ...this.answerState };
delete newState[key];
this.answerState = newState;
},
updateAnswerStateValue(key, value) {
this.answerState = {
...this.answerState,
this.userAnswerState = {
...this.userAnswerState,
[key]: value,
};
},
refreshOutcomes() {
const result = this.$refs.contentViewer?.checkAnswer?.();
if (!result) return;
this.outcomes = result.outcomes ?? {};
// Strip QTI_CONTEXT — it rides in on the answerState payload but
// isn't a response variable and shouldn't appear in the display.
const responses = { ...(result.answerState ?? {}) };
delete responses.QTI_CONTEXT;
this.currentAnswerState = responses;
},
formatOutcome(value) {
if (value === null || value === undefined) return 'null';
if (typeof value === 'string') return JSON.stringify(value);
if (Array.isArray(value) || typeof value === 'object') return JSON.stringify(value);
return String(value);
},
formatAnswerValue(value) {
if (value === null || value === undefined) return '';
if (Array.isArray(value) || typeof value === 'object') return JSON.stringify(value);
return String(value);
},
},
};

Expand Down Expand Up @@ -347,6 +378,48 @@
border-radius: 4px;
}

.outcomes-panel {
padding: 0.75rem;
margin-top: 1rem;
background-color: #fafafa;
border: 1px solid #dddddd;
border-radius: 4px;

h3 {
margin: 0 0 0.5rem;
font-size: 0.9rem;
color: #555555;
}
}

.outcome-item {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.375rem;
margin-bottom: 0.375rem;
background-color: white;
border: 1px solid #e0e0e0;
border-radius: 3px;

.key-name {
min-width: 80px;
font-size: 0.8rem;
font-weight: 500;
color: #666666;
}

.outcome-value {
flex: 1;
padding: 0.25rem 0.375rem;
font-family: monospace;
font-size: 0.8rem;
color: #333333;
background-color: #f5f5f5;
border-radius: 2px;
}
}

.empty-state {
display: flex;
align-items: center;
Expand Down
Loading
Loading