Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ddad985
Write no warning when not every response can be scored
AlexVelezLl Oct 7, 2026
d3cb3bf
Fit the prominent insert action in with the toolbar's other tools
AlexVelezLl Oct 7, 2026
171d702
Document that an expanded popup keeps the rich text editor open
AlexVelezLl Oct 7, 2026
c21401f
Move the caret past inline choice chips instead of selecting them
AlexVelezLl Oct 7, 2026
0e25d6c
Read a rich text editor's stored HTML through one helper
AlexVelezLl Oct 7, 2026
acb3414
Add the inline choice interaction editor
AlexVelezLl Oct 7, 2026
68f9852
Make inline choice chips easier to use with a keyboard or screen reader
AlexVelezLl Oct 19, 2026
5f472c7
Show the insert icon on the inline choice Insert action
AlexVelezLl Oct 14, 2026
381a73c
Add the core for converting a question to another type
AlexVelezLl Oct 10, 2026
8dbcc33
Convert answers between the answer-list question types
AlexVelezLl Oct 10, 2026
e43e0d7
Convert pairs between associate and match questions
AlexVelezLl Oct 10, 2026
d30b1be
Carry inline choice passages across question types
AlexVelezLl Oct 10, 2026
5064744
Add a modal to confirm a question type change
AlexVelezLl Oct 10, 2026
ea20a78
Focus a question's editor only when its card opens
AlexVelezLl Oct 13, 2026
ad2057d
Convert answers when the question type changes
AlexVelezLl Oct 10, 2026
154ce11
Check that every interaction exports its content for conversion
AlexVelezLl Oct 10, 2026
6172971
Give numeric answers to other types as the exercise language shows them
AlexVelezLl Oct 14, 2026
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 .stylelintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ module.exports = {
*/
'selector-max-id': null, // This would require a major refactor
'csstree/validator': null, // this triggers issues with unknown at rules too.
// Allows uppercase, as in KDS theme variables like --tokens-fineLine.
'custom-property-pattern': '^([a-z][a-zA-Z0-9]*)(-[a-zA-Z0-9]+)*$',
'selector-pseudo-element-no-unknown': [
true,
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2479,11 +2479,6 @@ video {
[aria-disabled] {
cursor: default;
}
::selection {
color: #000000;
text-shadow: none;
background-color: #b3d4fc;
}
.bottom-sheet-transition-enter,
.bottom-sheet-transition-leave-to {
-webkit-transform: translateY(100%);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Edits an exercise's assessment items stored as [QTI 3](https://www.imsglobal.org
4. The interaction's `Editor.vue` (`interactions/index.js`) edits a plain state object through `composables/useInteraction.js`: `descriptor.parse` → state → `descriptor.buildXML` → `descriptor.validate`.
5. `useQtiItem` rebuilds the full item with `serialization/assembleItem.js`; `QTIItemEditor` emits `update:rawData`. `validateItem.js` `isEditableItem` shows read-only any item holding content outside the body that this rebuild doesn't write or regenerate.

Changing a question's type goes through `conversion/`: the old descriptor exports its state, the new one imports it, and whatever the change would remove or change is listed in `components/ChangeQuestionTypeModal` for the author to confirm first.

`validateItem.js` `validateQtiItem` runs the same parse and validation headless, without the Vue editors.

> [!IMPORTANT]
Expand All @@ -19,15 +21,17 @@ Edits an exercise's assessment items stored as [QTI 3](https://www.imsglobal.org
- `Descriptor.js` — subclass of `InteractionDescriptor`: matches the element, picks the question type, delegates to `parse.js` and `validation.js`.
- `parse.js` — XML ↔ state.
- `validation.js` — state → error codes.
- `convert.js` — state ↔ the neutral facets a question type change carries (answers, pairs, a passage).
- `Editor.vue` — the UI; absent for headless interactions (`HEADLESS_INTERACTIONS`).
- `interactions/descriptors.js` — descriptor registry, free of `.vue` imports. `interactions/index.js` adds the editors.
- `serialization/` — item-level parse/assemble, hints (`qti-catalog-info`), XML helpers (`xml.js`), response declarations (`qti/`).
- `components/` — shared UI: hints, chip lists, toolbars, question type selector.

### Adding an interaction
1. Create `interactions/<type>/` with `Descriptor.js`, `parse.js`, `validation.js` and `Editor.vue`.
1. Create `interactions/<type>/` with `Descriptor.js`, `parse.js`, `validation.js`, `convert.js` and `Editor.vue`.
2. Register the descriptor in `interactions/descriptors.js` and the editor in `interactions/index.js`.
3. Add its question types to `constants.js` and strings to `qtiEditorStrings.js`.
4. Override `getConversionFormat`, `exportContent` and `importContent` with `convert.js`, so its answers carry over when an author changes the question's type, and add a filled question of each type to `conversion/__tests__/questions.js`. Until it does, a type change from it keeps only the question without telling the author, which is why the conversion tests fail without it.

### Rich text fields
Prompts, choices, items and hints are edited in `TipTapEditor` with `format="html"`.
Expand All @@ -36,3 +40,6 @@ Prompts, choices, items and hints are edited in `TipTapEditor` with `format="htm
> In `parse`, read any field TipTap loads as HTML with `getContentHTML` (or `getPromptHTML`) from `serialization/xml.js`, never `innerHTML` or `XMLSerializer`. XML serialization self-closes empty elements (`<span data-latex="…"/>`); the HTML parser doesn't treat `/>` as closing, so everything after it is nested inside and lost.
>
> On build, pass the HTML to `buildXmlNode({ innerHTML })`, which parses it as HTML and re-creates it as XML.

> [!NOTE]
> A node view that shows itself as selected, like the inline choice chip, uses `--brand-secondary-v100`, the background of KDS's global `::selection`, with `--tokens-text` for its text.
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,10 @@ describe('validateQtiItem', () => {
expect(validateQtiItem(xml, { allowFreeResponse: false })).toEqual([]);
});

it('does not apply the editor rules to an interaction with no editor', () => {
expect(validateQtiItem(INLINE_CHOICE_ITEM_DOCUMENT)).toEqual([]);
it('runs the inline choice validator', () => {
expect(validateQtiItem(INLINE_CHOICE_ITEM_DOCUMENT)).toEqual([
{ code: ValidationError.NO_CORRECT_ANSWER, id: 'RESPONSE_2' },
]);
});

it('reports an item with no raw data at all', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { render, screen, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import VueRouter from 'vue-router';
import ChangeQuestionTypeModal from '../index.vue';
import { ConversionLoss } from '../../../constants';
import { qtiEditorStrings as tr } from '../../../qtiEditorStrings';

const renderModal = (props = {}) =>
render(ChangeQuestionTypeModal, {
props: {
questionTypeLabel: 'Numeric input',
losses: [ConversionLoss.INCORRECT_ANSWERS_REMOVED, ConversionLoss.FORMATTING_REMOVED],
...props,
},
routes: new VueRouter(),
});

describe('ChangeQuestionTypeModal', () => {
it('asks to confirm the change, naming the new type', () => {
renderModal();
const dialog = screen.getByRole('dialog');
expect(within(dialog).getByText(tr.$tr('changeQuestionTypeTitle'))).toBeInTheDocument();
expect(
within(dialog).getByText(tr.$tr('changeQuestionTypeIntro', { type: 'Numeric input' })),
).toBeInTheDocument();
});

it('lists what the change would remove or change, one sentence each', () => {
renderModal();
const items = screen.getAllByRole('listitem').map(item => item.textContent.trim());
expect(items).toEqual([tr.$tr('incorrectAnswersRemovedLoss'), tr.$tr('formattingRemovedLoss')]);
});

it('shows each blank of a folded passage as it will appear in the question', () => {
renderModal({ losses: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION] });
expect(screen.getByRole('listitem')).toHaveTextContent(/"_____"/);
});

it.each(Object.values(ConversionLoss))('has a sentence for %s', loss => {
renderModal({ losses: [loss] });
expect(screen.getByRole('listitem').textContent.trim()).not.toBe('');
});

it('confirms with the Change button', async () => {
const user = userEvent.setup();
const { emitted } = renderModal();
await user.click(screen.getByRole('button', { name: tr.$tr('changeQuestionTypeAction') }));
expect(emitted().submit).toHaveLength(1);
expect(emitted().cancel).toBeUndefined();
});

it('cancels with the Cancel button', async () => {
const user = userEvent.setup();
const { emitted } = renderModal();
await user.click(screen.getByRole('button', { name: tr.$tr('cancelAction') }));
expect(emitted().cancel).toHaveLength(1);
expect(emitted().submit).toBeUndefined();
});

it('can be used with the keyboard only', async () => {
const user = userEvent.setup();
const { emitted } = renderModal();
await user.keyboard('{Escape}');
expect(emitted().cancel).toHaveLength(1);

const change = screen.getByRole('button', { name: tr.$tr('changeQuestionTypeAction') });
change.focus();
await user.keyboard('{Enter}');
expect(emitted().submit).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<template>

<KModal
:title="changeQuestionTypeTitle$()"
:submitText="changeQuestionTypeAction$()"
:cancelText="cancelAction$()"
@submit="$emit('submit')"
@cancel="$emit('cancel')"
>
<p>{{ changeQuestionTypeIntro$({ type: questionTypeLabel }) }}</p>
<ul class="losses">
<li
v-for="loss in losses"
:key="loss"
>
{{ lossMessages[loss]({ blank: BLANK_TEXT }) }}
</li>
</ul>
</KModal>

</template>


<script>

import { ConversionLoss } from '../../constants';
import { BLANK_TEXT } from '../../conversion/passage';
import { qtiEditorStrings } from '../../qtiEditorStrings';

/**
* Asks an author to confirm a change of question type that would remove or change some of
* the question, saying what.
*/
export default {
name: 'ChangeQuestionTypeModal',

setup() {
const {
changeQuestionTypeTitle$,
changeQuestionTypeIntro$,
changeQuestionTypeAction$,
cancelAction$,
} = qtiEditorStrings;

const lossMessages = {
[ConversionLoss.ANSWERS_REMOVED]: qtiEditorStrings.answersRemovedLoss$,
[ConversionLoss.INCORRECT_ANSWERS_REMOVED]: qtiEditorStrings.incorrectAnswersRemovedLoss$,
[ConversionLoss.ANY_CORRECT_ANSWER_ACCEPTED]:
qtiEditorStrings.anyCorrectAnswerAcceptedLoss$,
[ConversionLoss.CORRECT_ANSWERS_REDUCED]: qtiEditorStrings.correctAnswersReducedLoss$,
[ConversionLoss.CORRECT_ANSWERS_NOT_KEPT]: qtiEditorStrings.correctAnswersNotKeptLoss$,
[ConversionLoss.ORDER_BECOMES_ANSWER]: qtiEditorStrings.orderBecomesAnswerLoss$,
[ConversionLoss.ORDER_NOT_KEPT]: qtiEditorStrings.orderNotKeptLoss$,
[ConversionLoss.FORMATTING_REMOVED]: qtiEditorStrings.formattingRemovedLoss$,
[ConversionLoss.PASSAGE_ADDED_TO_QUESTION]: qtiEditorStrings.passageAddedToQuestionLoss$,
};

return {
changeQuestionTypeTitle$,
changeQuestionTypeIntro$,
changeQuestionTypeAction$,
cancelAction$,
lossMessages,
BLANK_TEXT,
};
},

props: {
/** The label of the question type the author picked */
questionTypeLabel: {
type: String,
required: true,
},
/** ConversionLoss codes: what the change would remove or change */
losses: {
type: Array,
required: true,
validator: losses => losses.every(loss => Object.values(ConversionLoss).includes(loss)),
},
},

emits: ['submit', 'cancel'],
};

</script>


<style scoped>

.losses {
padding-inline-start: 24px;
margin: 0;
}

.losses li + li {
margin-top: 8px;
}

</style>
Loading
Loading