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
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
<div>
<div style="padding: 16px 24px 0">
<div
style="
padding: 16px;
color: #2196f3;
background-color: transparent;
border: 1px solid #2196f3;
border-radius: 4px;
"
:style="{
padding: '16px',
color: $themePalette.blue.v_600,
backgroundColor: 'transparent',
border: `1px solid ${$themePalette.blue.v_600}`,
borderRadius: '4px',
}"
>
<strong>QTI Editor — Dev Demo</strong>
&nbsp;Hardcoded items. Changes are local only and not persisted.
Expand All @@ -28,28 +28,34 @@
<script>

import { ref, defineComponent } from 'vue';
import { CHOICE_ITEM_XML, MULTI_CHOICE_ITEM_XML } from './qtiDemoData';
import QTIEditor from 'shared/views/QTIEditor/index';
import { QtiInteraction } from 'shared/views/QTIEditor/constants';
import { AssessmentItemTypes } from 'shared/views/QTIEditor/constants';

/**
* Hardcoded items covering three interaction types so the closed-card
* type label can be visually verified.
* Hardcoded items covering different states:
* - item-1: has raw_data (real QTI XML) → exercises the full load path
* - item-2: no raw_data → shows placeholder (blank new item state)
* - item-3: no raw_data → shows placeholder
*/
const INITIAL_ASSESSMENTS = [
{
id: 'demo-item-1',
type: QtiInteraction.CHOICE,
title: 'Which planet is closest to the Sun?',
assessment_id: 'demo-item-1',
type: AssessmentItemTypes.QTI,
raw_data: CHOICE_ITEM_XML,
},
{
id: 'demo-item-2',
type: QtiInteraction.EXTENDED_TEXT,
title: 'Describe the water cycle in your own words.',
assessment_id: 'demo-item-2',
type: AssessmentItemTypes.QTI,
raw_data: MULTI_CHOICE_ITEM_XML,
},
{
id: 'demo-item-3',
type: QtiInteraction.ORDER,
title: 'Arrange these events in chronological order.',
assessment_id: 'demo-item-3',
type: AssessmentItemTypes.QTI,
},
{
assessment_id: 'demo-item-4',
type: AssessmentItemTypes.QTI,
},
Comment on lines +36 to 59

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, apologies, I didn't catch this in the first PR. The assessments array here will have the same structure we get from the AssessmentItem Django model, so, it'd be better to have the same structure. So, a couple of changes:

  • Instead of id let's use assessment_id.
  • Let's remove the title field, as it's not defined in our assessment item model.
  • For type, in this array, let's copy this AssessmentItemTypes to our constants. And let's add a QTI: 'qti' type; that will be the type of these ⬆️ questions.

We will need to be careful about the difference between "question/assessment item types" and interaction types, because assessment types are what we will save on the backend, and interaction types are the ones we will infer from the XML. We will potentially use the assessment item types for the type selector to not create a new constant.

];

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { AssessmentItemTypes } from 'shared/views/QTIEditor/constants';

/**
* Demo item 1: a real choice interaction XML so the full load path can
* be verified end-to-end (parseItem → useQtiItem → InteractionSection →
* ChoiceInteractionEditor).
*/
export const CHOICE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
identifier="item-1"
title="Which planet is closest to the Sun?"
adaptive="false"
time-dependent="false"
xml:lang="en"
>
<qti-response-declaration
identifier="RESPONSE"
cardinality="single"
base-type="identifier"
>
<qti-correct-response>
<qti-value>mercury</qti-value>
</qti-correct-response>
</qti-response-declaration>

<qti-item-body>
<qti-choice-interaction
response-identifier="RESPONSE"
max-choices="1"
>
<qti-prompt>Which planet is closest to the Sun?</qti-prompt>
<qti-simple-choice identifier="mercury">Mercury</qti-simple-choice>
<qti-simple-choice identifier="venus">Venus</qti-simple-choice>
<qti-simple-choice identifier="earth">Earth</qti-simple-choice>
<qti-simple-choice identifier="mars">Mars</qti-simple-choice>
</qti-choice-interaction>
</qti-item-body>
</qti-assessment-item>`;

/**
* Demo item 2: a multi-select choice interaction XML (max-choices > 1).
*/
export const MULTI_CHOICE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
identifier="item-2"
title="Select all the prime numbers."
adaptive="false"
time-dependent="false"
xml:lang="en"
>
<qti-response-declaration
identifier="RESPONSE"
cardinality="multiple"
base-type="identifier"
>
<qti-correct-response>
<qti-value>two</qti-value>
<qti-value>three</qti-value>
<qti-value>five</qti-value>
</qti-correct-response>
</qti-response-declaration>

<qti-item-body>
<qti-choice-interaction
response-identifier="RESPONSE"
max-choices="4"
>
<qti-prompt>Select all the prime numbers.</qti-prompt>
<qti-simple-choice identifier="one">1</qti-simple-choice>
<qti-simple-choice identifier="two">2</qti-simple-choice>
<qti-simple-choice identifier="three">3</qti-simple-choice>
<qti-simple-choice identifier="four">4</qti-simple-choice>
<qti-simple-choice identifier="five">5</qti-simple-choice>
</qti-choice-interaction>
</qti-item-body>
</qti-assessment-item>`;

/**
* Hardcoded items covering different states:
* - item-1: has raw_data (real QTI XML) → exercises the full load path
* - item-2: no raw_data → shows placeholder (blank new item state)
* - item-3: no raw_data → shows placeholder
*/
export const INITIAL_ASSESSMENTS = [
{
assessment_id: 'demo-item-1',
type: AssessmentItemTypes.QTI,
raw_data: CHOICE_ITEM_XML,
},
{
assessment_id: 'demo-item-2',
type: AssessmentItemTypes.QTI,
raw_data: MULTI_CHOICE_ITEM_XML,
},
{
assessment_id: 'demo-item-3',
type: AssessmentItemTypes.QTI,
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/vue';
import VueRouter from 'vue-router';
import InteractionSection from '../index.vue';

import {
CHOICE_SINGLE_SELECT_XML,
UNKNOWN_INTERACTION_XML,
mockInteractionBlock as interactionBlock,
} from '../../../utils/testingFixtures';

const renderSection = (props = {}) =>
render(InteractionSection, {
props: { mode: 'edit', ...props },
routes: new VueRouter(),
});

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('InteractionSection', () => {
describe('choice interaction', () => {
it('renders the prompt from the XML via ChoiceInteractionEditor', () => {
renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) });
expect(screen.getByText('Which planet is closest to the Sun?')).toBeInTheDocument();
});

it('renders radio buttons for a single-select choice interaction', () => {
renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) });
const radios = screen.getAllByRole('radio');
expect(radios).toHaveLength(3);
});

it('renders the choice labels', () => {
renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) });
expect(screen.getByText('Mercury')).toBeInTheDocument();
expect(screen.getByText('Venus')).toBeInTheDocument();
});
});

describe('parse error handling', () => {
it('shows a parse error message and no interaction when XML is malformed', () => {
renderSection({ interaction: interactionBlock('not-xml<{{') });
expect(screen.queryByRole('radio')).not.toBeInTheDocument();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: The radio assertion on this line is a duplicate — it also appears on line 43, just before the comment. Remove one.

// At minimum no interactive elements render
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
});
});

describe('unknown interaction type', () => {
it('falls back silently when the interaction tag is unrecognized', () => {
// Should not throw — just renders the fallback component
expect(() =>
renderSection({ interaction: interactionBlock(UNKNOWN_INTERACTION_XML) }),
).not.toThrow();
});
});
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Along with the mode, we should also pass the "show answers" boolean ref as a prop to the interaction section and the interaction editor.

Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<template>

<div>
<p
v-if="parseError"
:style="{ color: $themePalette.red.v_700, margin: 0 }"
>
{{ parseError }}
</p>
<component
:is="descriptor.editorComponent"
v-else
:key="descriptor.type"
:questionType="questionType"
:interaction="interaction"
:mode="mode"
:showAnswers="showAnswers"
/>
</div>

</template>


<script>

import { computed, watch } from 'vue';
import useInteractionDescriptor from '../../composables/useInteractionDescriptor';

export default {
name: 'InteractionSection',

setup(props, { emit }) {
const bodyXmlRef = computed(() => props.interaction?.bodyXml);
const { descriptor, questionType, parseError } = useInteractionDescriptor(bodyXmlRef);

watch(
questionType,
newType => {
if (newType) emit('update:questionType', newType);
},
{ immediate: true },
);

return { descriptor, questionType, parseError };
},

props: {
/** The raw XML block representing an interaction and its response declarations */
interaction: {
type: Object,
required: true,
},
Comment thread
AlexVelezLl marked this conversation as resolved.
/** View or edit mode */
mode: {
type: String,
default: 'view',
},
Comment on lines +48 to +57

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add some validators here? Both for interaction and mode. Or just mention the expected object structure/string values.

/** Whether to display correct answers (used in view mode previews) */
showAnswers: {
type: Boolean,
default: false,
},
},

emits: ['update:questionType'],
};

</script>
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const defaultProps = {
index: 0,
total: 5,
mode: 'view',
displayAnswersPreview: false,
showAnswers: false,
};

const renderComponent = (props = {}, slots = {}) => {
Expand All @@ -28,9 +28,9 @@ const renderComponent = (props = {}, slots = {}) => {

describe('QTIItemEditor', () => {
describe('view mode', () => {
test('does not show the card body', () => {
test('shows the card body (placeholder) even in view mode', () => {
renderComponent({ mode: 'view' });
expect(screen.queryByText(questionContentPlaceholder$())).not.toBeInTheDocument();
expect(screen.getByText(questionContentPlaceholder$())).toBeInTheDocument();
});

test('does not show the close button', () => {
Expand All @@ -57,14 +57,14 @@ describe('QTIItemEditor', () => {
});
});

describe('displayAnswersPreview', () => {
test('shows the card body in view mode when displayAnswersPreview is true', () => {
renderComponent({ mode: 'view', displayAnswersPreview: true });
describe('showAnswers', () => {
test('shows the card body in view mode when showAnswers is true', () => {
renderComponent({ mode: 'view', showAnswers: true });
expect(screen.getByText(questionContentPlaceholder$())).toBeInTheDocument();
});

test('does not show the close button even when displayAnswersPreview is true', () => {
renderComponent({ mode: 'view', displayAnswersPreview: true });
test('does not show the close button even when showAnswers is true', () => {
renderComponent({ mode: 'view', showAnswers: true });
expect(screen.queryByRole('button', { name: closeBtnLabel$() })).not.toBeInTheDocument();
});
});
Expand Down
Loading
Loading