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, * bodyXml: import('vue').ComputedRef, @@ -22,7 +23,7 @@ import { ref, computed, watch } from 'vue'; * runValidation: () => void, * }} */ -export function useInteraction(descriptor, interactionBlock, questionType) { +export function useInteraction(descriptor, interactionBlock, questionType, options = {}) { const initialState = descriptor.parse( interactionBlock.bodyXml, interactionBlock.responseDeclarations, @@ -33,7 +34,7 @@ export function useInteraction(descriptor, interactionBlock, questionType) { // Rebuild XML whenever state or questionType changes. const interaction = computed(() => { if (!questionType.value) return { bodyXml: '', responseDeclarations: [] }; - return descriptor.buildXML(state.value, questionType.value); + return descriptor.buildXML(state.value, questionType.value, options); }); const bodyXml = computed(() => interaction.value.bodyXml); @@ -43,10 +44,13 @@ export function useInteraction(descriptor, interactionBlock, questionType) { /** Validates and updates errors. Exposed for explicit triggers (e.g. close). */ function runValidation() { - errors.value = descriptor.validate(state.value, questionType.value); + errors.value = descriptor.validate(state.value, questionType.value, options); } - watch([state, questionType], runValidation, { deep: true, immediate: true }); + watch([state, questionType, () => options], runValidation, { + deep: true, + immediate: true, + }); return { state, bodyXml, responseDeclarations, errors, runValidation }; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js index 43d9546312..c269c5ceb2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js @@ -1,4 +1,6 @@ -import { readonly } from 'vue'; +import { reactive, readonly, watch } from 'vue'; +import { QuestionType } from '../constants'; +import { formatLocaleNumber, readLocaleNumber } from '../utils/localeNumbers'; import { generateRandomSlug } from '../utils/generateRandomSlug'; import { textEntryInteractionDescriptor } from '../interactions/textEntry/Descriptor'; import { useInteraction } from './useInteraction'; @@ -10,13 +12,89 @@ import { useInteraction } from './useInteraction'; * There is no moveAnswerUp/Down — answer order is not meaningful for either * numeric acceptable-answer lists or textEntry correct-answer lists. * + * Numeric answers are shown as the value they are stored as, written in `language`: on + * opening, on switching to Numeric, on a language change, and when the author leaves an answer. + * * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock * @param {import('vue').Ref} questionType + * @param {{ language?: import('vue').Ref|string }} [options] - The language numeric + * answers are written in */ -export function useTextEntryInteraction(interactionBlock, questionType) { - const base = useInteraction(textEntryInteractionDescriptor, interactionBlock, questionType); +export function useTextEntryInteraction(interactionBlock, questionType, { language } = {}) { + const options = reactive({ language }); + const base = useInteraction( + textEntryInteractionDescriptor, + interactionBlock, + questionType, + options, + ); const { state } = base; + // Only Numeric answers are reformatted; others are stored as typed. + const isNumeric = () => questionType.value === QuestionType.NUMERIC; + + state.value = { + ...state.value, + answers: state.value.answers.map(a => ({ + ...a, + value: isNumeric() ? formatLocaleNumber(a.value, options.language) : a.value, + stored: a.value, + })), + }; + + // Answers the editor hasn't reshown and the author hasn't typed in since parsing. + const untouchedIds = new Set(state.value.answers.map(a => a.id)); + + function replaceAnswerValues(show, id) { + const answers = state.value.answers.map(a => + id === undefined || a.id === id ? { ...a, value: show(a) } : a, + ); + // Unchanged state would still rebuild and emit the XML, saving an answer nobody edited. + if (answers.every((a, i) => a.value === state.value.answers[i].value)) return; + state.value = { ...state.value, answers }; + } + + function mapAnswerValues(show, id) { + if (isNumeric()) replaceAnswerValues(show, id); + } + + // An answer the old language rejects may be what the author meant in the new one. + const showStored = (value, from) => + formatLocaleNumber( + readLocaleNumber(value, from) ?? readLocaleNumber(value, options.language) ?? value, + options.language, + ); + + watch( + questionType, + (type, oldType) => { + if (type === QuestionType.NUMERIC) { + showStoredAnswerValue(); + } else if (oldType === QuestionType.NUMERIC) { + // A learner can't type the bidi marks and U+2212 minus a formatted number carries. + replaceAnswerValues(({ value }) => + readLocaleNumber(value, options.language) === null + ? value + : value.replace(/\p{Cf}/gu, '').replace(/−/g, '-'), + ); + } + }, + { flush: 'sync' }, + ); + + watch( + () => options.language, + (_, oldLanguage) => { + // Answers shown or typed since parsing are written in the old language; the rest as stored. + mapAnswerValues(({ id, value, stored }) => + untouchedIds.has(id) + ? formatLocaleNumber(stored, options.language) + : showStored(value, oldLanguage), + ); + }, + { flush: 'sync' }, + ); + function setPrompt(html) { state.value = { ...state.value, prompt: html }; } @@ -55,12 +133,28 @@ export function useTextEntryInteraction(interactionBlock, questionType) { * @param {string} value */ function updateAnswerValue(id, value) { + untouchedIds.delete(id); state.value = { ...state.value, answers: state.value.answers.map(a => (a.id === id ? { ...a, value } : a)), }; } + /** + * Show a numeric answer, or all of them, as the value it is stored as. Other answers are + * stored as typed. + * + * @param {string} [id] + */ + function showStoredAnswerValue(id) { + if (id === undefined) { + untouchedIds.clear(); + } else { + untouchedIds.delete(id); + } + mapAnswerValues(({ value }) => showStored(value, options.language), id); + } + /** * Toggle the caseSensitive flag for a textEntry answer row. * @@ -82,6 +176,7 @@ export function useTextEntryInteraction(interactionBlock, questionType) { addAnswer, removeAnswer, updateAnswerValue, + showStoredAnswerValue, toggleCaseSensitive, }; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue index 8ab224eb09..3751ed03bb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue @@ -27,6 +27,7 @@ :total="items.length" :mode="activeId === item.assessment_id ? 'edit' : 'view'" :allowFreeResponse="allowFreeResponse" + :language="language" :showAnswers="showAnswers" data-testid="item" @open="openItem(item.assessment_id)" @@ -224,6 +225,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'], diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js index 3cce104b50..46872c8594 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js @@ -16,6 +16,9 @@ import { isContentNode } from '../serialization/xml'; /** * Methods a subclass has to implement. `matches` and `getTypeOptions` are not listed * because this class provides usable defaults for them. + * + * `buildXML` and `validate` take a trailing `{ language }`: the exercise language + * answers are written in, for descriptors whose answers depend on it. */ const REQUIRED_METHODS = [ 'getQuestionType', diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js index 6e789cae97..1529e211e8 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js @@ -106,23 +106,26 @@ class TextEntryInteractionDescriptor extends InteractionDescriptor { /** * @param {TextEntryState} state * @param {string} questionType + * @param {{ language?: string }} [options] * @returns {{ bodyXml: string, responseDeclarations: string[] }} */ - buildXML(state, questionType) { + buildXML(state, questionType, options) { return buildTextEntryInteractionXML( state, questionType, this.getResponseDeclarationSchema(questionType), + options, ); } /** * @param {TextEntryState} state * @param {string} questionType + * @param {{ language?: string }} [options] * @returns {Array<{ code: string, id?: string }>} */ - validate(state, questionType) { - return validateTextEntryInteraction(state, questionType); + validate(state, questionType, options) { + return validateTextEntryInteraction(state, questionType, options); } } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue index b75b4d8da4..d3468a1ff7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue @@ -100,6 +100,7 @@ @focus="focusedAnswerId = answer.id" @blur=" focusedAnswerId = null; + showStoredAnswerValue(answer.id); runValidation(); " > @@ -150,7 +151,7 @@ v-if="isNumeric && answerHasError(answer.id, ValidationError.INVALID_NUMERIC_VALUE)" class="answer-validation-message" > - {{ errorInvalidNumericValue$() }} + {{ errorInvalidNumericValue$(numberExamples) }} - import { computed, ref, watch, nextTick } from 'vue'; + import { computed, ref, toRef, watch, nextTick } from 'vue'; + import isEqual from 'lodash/isEqual'; import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; import { qtiEditorStrings } from '../../qtiEditorStrings'; import { QuestionType, ValidationError } from '../../constants'; import { useTextEntryInteraction } from '../../composables/useTextEntryInteraction'; + import { formatLocaleNumber } from '../../utils/localeNumbers'; + import { textEntryInteractionDescriptor } from './Descriptor'; import ValidationMessage from 'shared/views/QTIEditor/components/ValidationMessage'; import AddListItemButton from 'shared/views/QTIEditor/components/AddListItemButton'; import ClickableRegion from 'shared/views/QTIEditor/components/ClickableRegion'; @@ -234,11 +238,20 @@ addAnswer, removeAnswer, updateAnswerValue, + showStoredAnswerValue, toggleCaseSensitive, - } = useTextEntryInteraction(props.interaction, questionTypeRef); + } = useTextEntryInteraction(props.interaction, questionTypeRef, { + language: toRef(props, 'language'), + }); const isNumeric = computed(() => props.questionType === QuestionType.NUMERIC); + const numberExamples = computed(() => ({ + integer: formatLocaleNumber('12', props.language), + decimal: formatLocaleNumber('0.5', props.language), + negative: formatLocaleNumber('-3.14', props.language), + })); + const showAnswerSection = computed( () => props.questionType === QuestionType.NUMERIC || @@ -263,19 +276,6 @@ } } - watch( - () => props.mode, - newMode => { - if (newMode === 'edit') { - // Open the question, the first thing in the card, so the card opens at its start. - openPrompt(); - } else { - isPromptOpen.value = false; - } - }, - { immediate: true }, - ); - // Error sets const questionHasError = computed(() => errors.value.some(e => e.code === ValidationError.PROMPT_REQUIRED), @@ -353,6 +353,31 @@ { immediate: true }, ); + const answerValues = ({ bodyXml, responseDeclarations }) => + textEntryInteractionDescriptor + .parse(bodyXml, responseDeclarations) + .answers.map(a => a.value); + + watch( + () => props.mode, + (newMode, oldMode) => { + if (newMode !== 'edit') { + isPromptOpen.value = false; + return; + } + // Open the question, the first thing in the card, so the card opens at its start. + openPrompt(); + showStoredAnswerValue(); + // Opening a card does not remount it, so report answers stored in another form here. + if (oldMode === undefined || !workingInteraction.value.bodyXml) return; + if (isEqual(answerValues(workingInteraction.value), answerValues(props.interaction))) { + return; + } + emit('update:interaction', workingInteraction.value); + }, + { immediate: true }, + ); + // Errors are reported the same way, for the card to show that the question needs work. watch(errors, newVal => emit('update:errors', newVal), { immediate: true }); @@ -360,6 +385,7 @@ state, windowIsSmall, isNumeric, + numberExamples, showAnswerSection, isPromptOpen, questionHasError, @@ -370,6 +396,7 @@ closePrompt, setPrompt, onAnswerInput, + showStoredAnswerValue, onAddAnswer, onRemoveAnswer, onToggleCaseSensitive, @@ -416,6 +443,11 @@ type: Boolean, default: false, }, + /** The exercise's language, which numeric answers are read and shown in */ + language: { + type: String, + default: '', + }, }, emits: ['update:interaction', 'update:errors'], diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js index 3a1bf857fb..e450463e8a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js @@ -32,10 +32,10 @@ const renderEditor = (props = {}) => // The mock TipTapEditor renders a