diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue
index e2511272c0..752fd664c9 100644
--- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue
+++ b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue
@@ -19,6 +19,7 @@
@@ -42,14 +43,21 @@
setup(props) {
const { nodeId } = toRefs(props);
const { windowIsSmall } = useKResponsiveWindow();
- const { assessmentItems, invalidItemsCount, allowFreeResponse, applyUpdate } =
+ const { assessmentItems, invalidItemsCount, allowFreeResponse, language, applyUpdate } =
useAssessmentItems(nodeId);
const bannerStyle = computed(() =>
windowIsSmall.value ? {} : { maxWidth: '1200px', margin: '0 auto' },
);
- return { assessmentItems, invalidItemsCount, allowFreeResponse, applyUpdate, bannerStyle };
+ return {
+ assessmentItems,
+ invalidItemsCount,
+ allowFreeResponse,
+ language,
+ applyUpdate,
+ bannerStyle,
+ };
},
props: {
nodeId: {
diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue
index 0750653f47..e00cfb7744 100644
--- a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue
+++ b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue
@@ -146,6 +146,7 @@
:total="assessmentItems.length"
mode="view"
:allowFreeResponse="allowFreeResponse"
+ :language="language"
:showAnswers="showAnswers"
class="question-preview"
/>
@@ -499,6 +500,7 @@
AccessibilityCategories,
ContentModalities,
} from '../../shared/constants';
+ import { exerciseLanguage } from '../composables/useAssessmentItems';
import ContentNodeValidator from './ContentNodeValidator';
import {
@@ -570,6 +572,7 @@
]),
...mapGetters('file', ['getContentNodeFiles', 'contentNodesTotalSize']),
...mapGetters('assessmentItem', ['getAssessmentItems', 'getInvalidAssessmentItemsCount']),
+ ...mapGetters('channel', ['getChannel']),
node() {
return this.getContentNode(this.nodeId);
},
@@ -620,6 +623,10 @@
allowFreeResponse() {
return this.node?.extra_fields?.options?.modality === ContentModalities.SURVEY;
},
+ language() {
+ // The Clipboard previews nodes from other channels.
+ return exerciseLanguage(this.node, this.getChannel(this.node?.channel_id));
+ },
fileSize() {
return this.contentNodesTotalSize([this.nodeId]);
},
diff --git a/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js b/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js
index 66dced531c..c20ab3c8c8 100644
--- a/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js
+++ b/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js
@@ -3,6 +3,16 @@ import VueRouter from 'vue-router';
import { render } from '@testing-library/vue';
import useAssessmentItems from '../useAssessmentItems';
import { AssessmentItemTypes, ContentModalities } from 'shared/constants';
+import { i18nSetup } from 'shared/i18n';
+
+// Not Jest's default `en`, so a fallback hard-coded to `en` would fail.
+beforeAll(async () => {
+ window.languageCode = 'es';
+ await i18nSetup(true);
+});
+afterAll(() => {
+ delete window.languageCode;
+});
const NODE_ID = 'node-1';
@@ -18,7 +28,7 @@ const item = (assessment_id, order, raw_data = `${assessment_id}`) =>
* Renders a component that does nothing but run the composable, and returns it alongside
* the actions the composable dispatched, in the order it dispatched them.
*/
-function setup(storedItems, { modality = null } = {}) {
+function setup(storedItems, { modality = null, nodeLanguage = null, channelLanguage = null } = {}) {
const dispatched = [];
const record = name => (context, payload) => dispatched.push([name, payload]);
@@ -27,7 +37,16 @@ function setup(storedItems, { modality = null } = {}) {
contentNode: {
namespaced: true,
getters: {
- getContentNode: () => () => ({ extra_fields: { options: { modality } } }),
+ getContentNode: () => () => ({
+ language: nodeLanguage,
+ extra_fields: { options: { modality } },
+ }),
+ },
+ },
+ currentChannel: {
+ namespaced: true,
+ getters: {
+ currentChannel: () => ({ language: channelLanguage }),
},
},
assessmentItem: {
@@ -75,6 +94,39 @@ describe('useAssessmentItems', () => {
});
});
+ describe('language', () => {
+ it("is the exercise's own language when it has one", () => {
+ const { composable } = setup([], { nodeLanguage: 'fr', channelLanguage: 'hi' });
+
+ expect(composable.language.value).toBe('fr');
+ });
+
+ it("falls back to the channel's language", () => {
+ const { composable } = setup([], { channelLanguage: 'hi' });
+
+ expect(composable.language.value).toBe('hi');
+ });
+
+ it("falls back to the author's interface language when neither has one", () => {
+ const { composable } = setup([]);
+
+ expect(composable.language.value).toBe('es');
+ });
+
+ // `qaa` and `qab` are reserved for private use, so no browser has conventions for them.
+ it('skips a language with no number conventions in this browser', () => {
+ const { composable } = setup([], { nodeLanguage: 'qaa', channelLanguage: 'hi' });
+
+ expect(composable.language.value).toBe('hi');
+ });
+
+ it('falls back to the interface language when the others have no number conventions', () => {
+ const { composable } = setup([], { nodeLanguage: 'qaa', channelLanguage: 'qab' });
+
+ expect(composable.language.value).toBe('es');
+ });
+ });
+
it('dispatches nothing when the list is unchanged', async () => {
const items = [item('a', 0), item('b', 1)];
const { composable, dispatched } = setup(items);
diff --git a/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js b/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js
index 08c418439c..afecd16aab 100644
--- a/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js
+++ b/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js
@@ -1,6 +1,7 @@
import { computed, unref } from 'vue';
import useStore from 'shared/composables/useStore';
import { ContentModalities } from 'shared/constants';
+import { currentLanguage } from 'shared/i18n';
/**
* Work out what changed between the list Studio holds and the list the editor produced.
@@ -43,6 +44,20 @@ function diffAssessmentItems(prevItems, nextItems) {
return { orders, added, updated, deleted };
}
+/**
+ * The first of the exercise's language, the channel's and the author's UI language that this
+ * browser has number conventions for. Empty when it has none, as `Intl` would use the
+ * browser's own.
+ *
+ * @param {object} [node]
+ * @param {object} [channel]
+ * @returns {string}
+ */
+export function exerciseLanguage(node, channel) {
+ const candidates = [node?.language, channel?.language, currentLanguage].filter(Boolean);
+ return candidates.find(l => Intl.NumberFormat.supportedLocalesOf([l]).length) ?? '';
+}
+
/**
* Everything the questions tab needs about one content node's assessment items: the
* ordered list to render, how many of them are incomplete, and a way to save an edited
@@ -57,13 +72,17 @@ export default function useAssessmentItems(nodeId) {
store.getters['assessmentItem/getAssessmentItems'](unref(nodeId)),
);
+ const node = computed(() => store.getters['contentNode/getContentNode'](unref(nodeId)));
+
/**
* Currently free responses are only allowed in surveys
*/
const allowFreeResponse = computed(
- () =>
- store.getters['contentNode/getContentNode'](unref(nodeId))?.extra_fields?.options
- ?.modality === ContentModalities.SURVEY,
+ () => node.value?.extra_fields?.options?.modality === ContentModalities.SURVEY,
+ );
+
+ const language = computed(() =>
+ exerciseLanguage(node.value, store.getters['currentChannel/currentChannel']),
);
const invalidItemsCount = computed(() =>
@@ -107,5 +126,5 @@ export default function useAssessmentItems(nodeId) {
}
}
- return { assessmentItems, invalidItemsCount, allowFreeResponse, applyUpdate };
+ return { assessmentItems, invalidItemsCount, allowFreeResponse, language, applyUpdate };
}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
index f1d60ffca2..62277a4e23 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
@@ -214,6 +214,74 @@ describe('validateQtiItem', () => {
});
});
+describe('validateQtiItem on numeric answers typed in a language', () => {
+ function build(language, value) {
+ const state = {
+ prompt: '
How much?
',
+ answers: [{ id: 'a1', value }],
+ expectedLength: 50,
+ };
+ const { bodyXml, responseDeclarations } = textEntryInteractionDescriptor.buildXML(
+ state,
+ QuestionType.NUMERIC,
+ { language },
+ );
+ const rawData = assembleItemXml({
+ identifier: 'item',
+ title: 'Question',
+ language,
+ bodyXml,
+ responseDeclarations,
+ });
+ return { state, rawData };
+ }
+
+ it.each([
+ ['fr', '1,5'],
+ ['fr', '1,234'],
+ ['fr', '1 234,5'],
+ ['fr', '1\u202F234,5'],
+ ['en', '1,234'],
+ ['en', '1,234.5'],
+ ['hi', '12,34,567'],
+ ['hi', '३०'],
+ ['ar', '٣٠'],
+ ['fr', '1.5'],
+ ['de', '1.5'],
+ ['ar-EG', '3.5'],
+ ])('reports no error for an answer typed in %s as %s, as the editor does', (language, value) => {
+ const { state, rawData } = build(language, value);
+ expect(validateQtiItem(rawData)).toEqual([]);
+ expect(
+ textEntryInteractionDescriptor.validate(state, QuestionType.NUMERIC, { language }),
+ ).toEqual([]);
+ });
+
+ it.each([
+ ['fr', '1,5', '1.5'],
+ ['fr', '1.5', '1.5'],
+ ['de', '1.5', '1.5'],
+ ['de', '0.5', '0.5'],
+ ['es', '-1.5', '-1.5'],
+ ['de', '1.234', '1234'],
+ ['de', '1.234,5', '1234.5'],
+ ])('stores an answer typed in %s as %s as %s', (language, value, stored) => {
+ expect(build(language, value).rawData).toContain(`${stored}`);
+ });
+
+ it.each([
+ ['fr', '1,2,3'],
+ ['fr', '1 234.5'],
+ ])('reports an answer typed in %s as %s invalid, as the editor does', (language, value) => {
+ const { state, rawData } = build(language, value);
+ const invalid = ValidationError.INVALID_NUMERIC_VALUE;
+ expect(
+ codesOf(textEntryInteractionDescriptor.validate(state, QuestionType.NUMERIC, { language })),
+ ).toContain(invalid);
+ expect(codesOf(validateQtiItem(rawData))).toContain(invalid);
+ });
+});
+
// What the editor asks about an item it is already showing, which is everything an
// interaction cannot answer for itself.
describe('validateItemShape', () => {
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue
index 0762cf6780..81a97ccf4a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue
@@ -24,6 +24,7 @@
:mode="mode"
:showAnswers="showAnswers"
:teleportTargetId="settingsTargetId"
+ :language="language"
@update:interaction="onUpdateInteraction"
@update:errors="errors => $emit('update:errors', errors)"
/>
@@ -125,6 +126,14 @@
type: Boolean,
default: true,
},
+ /**
+ * The exercise's language, which numeric answers are read and shown in. Empty means
+ * they are read and shown as stored.
+ */
+ language: {
+ type: String,
+ default: '',
+ },
},
emits: ['update:questionType', 'update:interaction', 'update:errors'],
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
index 661e0d7712..c30596329b 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
@@ -12,6 +12,7 @@ import {
CHOICE_ITEM_DOCUMENT_WITH_HINTS_AND_STIMULUS,
CHOICE_ITEM_DOCUMENT_WITH_STIMULUS,
FREE_RESPONSE_ITEM_DOCUMENT,
+ NUMERIC_ITEM_DOCUMENT,
NO_INTERACTION_ITEM_DOCUMENT,
CHOICE_ITEM_DOCUMENT_WITH_HINTS,
VALID_ASSOCIATE_ITEM_DOCUMENT,
@@ -46,6 +47,7 @@ const {
questionNumberAndTypeLabel$,
unknownTypeLabel$,
responsePoolLabel$,
+ answerValuePlaceholder$,
} = qtiEditorStrings;
const defaultProps = {
@@ -224,6 +226,69 @@ describe('QTIItemEditor', () => {
});
});
+ test('shows a numeric answer in the language it is given', async () => {
+ renderComponent({
+ item: { ...defaultProps.item, raw_data: NUMERIC_ITEM_DOCUMENT },
+ showAnswers: true,
+ language: 'fr',
+ });
+ expect(await screen.findByRole('textbox', { name: answerValuePlaceholder$() })).toHaveValue(
+ '1234,5',
+ );
+ });
+
+ test('reads a numeric answer in the language it is given after a change', async () => {
+ const { emitted, updateProps } = renderComponent({
+ item: { ...defaultProps.item, raw_data: NUMERIC_ITEM_DOCUMENT },
+ mode: 'edit',
+ language: 'fr',
+ });
+ const answerInput = () => screen.findByRole('textbox', { name: answerValuePlaceholder$() });
+ expect(await answerInput()).toHaveValue('1234,5');
+ await updateProps({ language: 'en' });
+ const input = await answerInput();
+ expect(input).toHaveValue('1234.5');
+ await fireEvent.input(input, { target: { value: '1,234' } });
+
+ expect(emitted()['update:rawData'].at(-1)[0]).toContain('1234');
+ });
+
+ test('keeps the open question in place when the language changes', async () => {
+ const { updateProps } = renderComponent({
+ item: { ...defaultProps.item, raw_data: NUMERIC_ITEM_DOCUMENT },
+ mode: 'edit',
+ language: 'fr',
+ });
+ const input = await screen.findByRole('textbox', { name: answerValuePlaceholder$() });
+ input.focus();
+ await updateProps({ language: 'en' });
+
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveFocus();
+ expect(input).toHaveValue('1234.5');
+ });
+
+ test.each([
+ ['fr', '1234.5'],
+ ['en', '1.50'],
+ ['fr', '1E3'],
+ ['de', '0012'],
+ ['ar-EG', '1.50'],
+ ['en', '12345678901234567890'],
+ ])(
+ 'does not save a numeric answer stored as xsd:double on opening in %s (%s)',
+ async (language, stored) => {
+ const { emitted, updateProps } = renderComponent({
+ item: { ...defaultProps.item, raw_data: NUMERIC_ITEM_DOCUMENT.replace('1234.5', stored) },
+ language,
+ });
+ await updateProps({ mode: 'edit' });
+ await nextTick();
+
+ expect(emitted()['update:rawData']).toBeUndefined();
+ },
+ );
+
describe('items this editor cannot edit', () => {
test('shows a read-only message for an item authored elsewhere', () => {
renderComponent({
@@ -325,6 +390,32 @@ describe('QTIItemEditor', () => {
expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument();
});
+ // Publishing reads the stored XML, where these are not xsd:double.
+ test.each([
+ ['fr', '1,5', '1.5'],
+ ['fr', '1 234,5', '1234.5'],
+ ['de', '1.234,5', '1234.5'],
+ ['en', '1,234', '1234'],
+ ])(
+ 'is shown in %s for a stored numeric answer %s until opening stores %s',
+ async (language, stored, canonical) => {
+ const item = {
+ ...defaultProps.item,
+ raw_data: NUMERIC_ITEM_DOCUMENT.replace('1234.5', stored),
+ };
+ const { emitted, updateProps } = renderComponent({ item, language });
+ await nextTick();
+ expect(screen.getByTestId('incompleteIndicator')).toBeInTheDocument();
+
+ await updateProps({ mode: 'edit' });
+ const [rawData] = emitted()['update:rawData'].at(-1);
+ expect(rawData).toContain(`${canonical}`);
+
+ await updateProps({ item: { ...item, raw_data: rawData } });
+ expect(screen.queryByTestId('incompleteIndicator')).not.toBeInTheDocument();
+ },
+ );
+
test('is shown for an unsupported item with no interaction, which blocks publishing', async () => {
await renderAndValidate(NO_INTERACTION_ITEM_DOCUMENT);
expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument();
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
index 167df94589..621928f9e6 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
@@ -67,6 +67,7 @@
:mode="mode"
:showAnswers="showAnswers"
:allowFreeResponse="allowFreeResponse"
+ :language="language"
@update:questionType="type => (currentQuestionType = type)"
@update:interaction="onUpdateInteraction"
@update:errors="onUpdateErrors"
@@ -115,10 +116,11 @@
import { qtiEditorStrings } from '../../qtiEditorStrings';
import { AssessmentItemTypes, QuestionType } from '../../constants';
import useQtiItem from '../../composables/useQtiItem';
- import { validateItemShape, validateQtiItem } from '../../validateItem';
+ import { validateItemShape } from '../../validateItem';
import { isSupportedItem } from '../../interactions/resolveDescriptor';
import InteractionSection from '../InteractionSection/index.vue';
import HintsSection from '../HintsSection/index.vue';
+ import { getAssessmentItemErrors } from 'shared/utils/validation';
export default {
name: 'QTIItemEditor',
@@ -318,6 +320,10 @@
errors.value = newErrors;
}
+ const storedErrors = computed(() =>
+ getAssessmentItemErrors(props.item, { allowFreeResponse: props.allowFreeResponse }),
+ );
+
/**
* Whether the question is missing something an author still has to supply.
*/
@@ -325,18 +331,22 @@
if (isUnsupported.value) {
// Unreadable or empty QTI can't be fixed here and blocks publishing; other
// unsupported items are publishable.
- return (
- isQti.value &&
- validateQtiItem(props.item.raw_data, { allowFreeResponse: props.allowFreeResponse })
- .length > 0
- );
+ return storedErrors.value.length > 0;
}
const itemErrors = validateItemShape({
interactions: interactions.value,
questionTypes: [currentQuestionType.value],
allowFreeResponse: props.allowFreeResponse,
});
- return itemErrors.length > 0 || errors.value.length > 0;
+ // Publishing reads the stored XML, which the editor reads Numeric answers from in the
+ // exercise language. Skipped while open: each edit is stored as the editor validates it.
+ return (
+ itemErrors.length > 0 ||
+ errors.value.length > 0 ||
+ (props.mode !== 'edit' &&
+ currentQuestionType.value === QuestionType.NUMERIC &&
+ storedErrors.value.length > 0)
+ );
});
return {
@@ -400,6 +410,14 @@
type: Boolean,
default: true,
},
+ /**
+ * The exercise's language, which numeric answers are read and shown in. Empty means
+ * they are read and shown as stored.
+ */
+ language: {
+ type: String,
+ default: '',
+ },
},
emits: ['open', 'close', 'update:rawData'],
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js
index 4b768951fe..d7834ed7b2 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js
@@ -95,7 +95,7 @@ describe('useInteraction', () => {
questionType.value = 'multiSelect';
runValidation();
- expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'multiSelect');
+ expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'multiSelect', {});
});
it('bodyXml recomputes when state changes', () => {
@@ -135,7 +135,7 @@ describe('useInteraction', () => {
bodyXml.value; // trigger recompute
expect(descriptor.buildXML).toHaveBeenCalledTimes(2);
- expect(descriptor.buildXML).toHaveBeenLastCalledWith(expect.anything(), 'multiSelect');
+ expect(descriptor.buildXML).toHaveBeenLastCalledWith(expect.anything(), 'multiSelect', {});
});
it('automatically runs validation when state changes', async () => {
@@ -156,7 +156,7 @@ describe('useInteraction', () => {
state.value = { prompt: 'updated' };
await nextTick();
- expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'singleSelect');
+ expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'singleSelect', {});
expect(errors.value).toEqual([{ code: 'UPDATED_ERROR' }]);
});
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js
index 316ffe0b9f..f38bbb8fd2 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js
@@ -7,13 +7,14 @@ import { ref, computed, watch } from 'vue';
* interaction plugin must go through. Individual interaction composables
* (e.g. useChoiceInteraction) call this and add mutation methods on top.
*
- * Validation runs on every state or questionType change, so errors always describe the
+ * Validation runs on every state, questionType or options change, so errors always describe the
* state the editor is showing. runValidation is exposed for explicit triggers, such as
* closing a panel.
*
* @param {import('../interactions/InteractionDescriptor').InteractionDescriptor} descriptor
* @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock
* @param {import('vue').Ref} questionType
+ * @param {object} [options] - Passed to the descriptor's buildXML and validate
* @returns {{
* state: import('vue').Ref