diff --git a/cypress/e2e/Admin/create/multipleChoices.cy.ts b/cypress/e2e/Admin/create/multipleChoices.cy.ts index c2dab422..e1f3f479 100644 --- a/cypress/e2e/Admin/create/multipleChoices.cy.ts +++ b/cypress/e2e/Admin/create/multipleChoices.cy.ts @@ -237,6 +237,32 @@ describe('Multiple Choices', () => { }); }); + it('Duplicated answers are not allowed', () => { + cy.setUpApi({ + database: { + appSettings: [], + }, + appContext: { + permission: PermissionLevel.Admin, + context: Context.Builder, + }, + }); + cy.visit('/'); + + const new1 = { + ...newMultipleChoiceData, + choices: [ + ...newMultipleChoiceData.choices, + { value: 'choice1', isCorrect: true, explanation: '' }, + { value: 'choice1', isCorrect: true, explanation: '' }, + ], + }; + fillMultipleChoiceQuestion(new1, { shouldSave: false }); + cy.checkErrorMessage({ + errorMessage: t(FAILURE_MESSAGES.MULTIPLE_CHOICES_DUPLICATED_CHOICE), + }); + }); + describe('Display saved settings', () => { beforeEach(() => { cy.setUpApi({ diff --git a/src/components/common/animations/ReorderAnimation.tsx b/src/components/common/animations/ReorderAnimation.tsx index 094ee46a..fa21304a 100644 --- a/src/components/common/animations/ReorderAnimation.tsx +++ b/src/components/common/animations/ReorderAnimation.tsx @@ -3,6 +3,8 @@ import { useTransition } from '@react-spring/web'; import { useState } from 'react'; import { animated } from 'react-spring'; +import { countKeysApparition } from '../../../utils/array'; + const ANIMATION_DURATION_MS = 350; type DataElementType = { elementType: number }; @@ -24,6 +26,22 @@ type Props = { ) => JSX.Element; }; +const duplicatedKeys = ( + elements: TransitionData[] +) => + Object.entries(countKeysApparition(elements, 'key')) + .filter(([key, count]) => key && count > 1) + .map(([key, _]) => key); + +const printDuplicatedKeysWarning = ( + elements: TransitionData[] +) => + duplicatedKeys(elements).forEach((k) => + console.warn( + `Be carefull, the key "${k}" is not unique ! This can cause issues with the animation.` + ) + ); + export const ReorderAnimation = ({ isAnimating, elements, @@ -88,6 +106,8 @@ export const ReorderAnimation = ({ ); }; + printDuplicatedKeysWarning(elements); + return (
{ if (data?.choices?.some(({ value }) => !value)) { throw FAILURE_MESSAGES.MULTIPLE_CHOICES_EMPTY_CHOICE; } + if (hasDuplicatedKeys(getDuplicatedKeys(data?.choices, 'value'))) { + throw FAILURE_MESSAGES.MULTIPLE_CHOICES_DUPLICATED_CHOICE; + } break; case QuestionType.FILL_BLANKS: diff --git a/src/components/play/multipleChoices/PlayMultipleChoices.tsx b/src/components/play/multipleChoices/PlayMultipleChoices.tsx index 3e793092..ce5910d6 100644 --- a/src/components/play/multipleChoices/PlayMultipleChoices.tsx +++ b/src/components/play/multipleChoices/PlayMultipleChoices.tsx @@ -87,13 +87,13 @@ const choiceToAnswer = ( idx: number, marginBottom: number ): TransitionData => ({ - key: choice.value, + key: `answer-${choice.value}-${idx}`, marginBottom, data: { idx, choice, elementType: ElementType.Answer }, }); const choiceToTitle = (title: string): TransitionData => ({ - key: title, + key: `title-${title}`, marginBottom: DEFAULT_MARGIN, data: { title, @@ -105,7 +105,7 @@ const choiceToHint = ( choiceIdx: number, hint: string ): TransitionData => ({ - key: hint, + key: `hint-${hint}-${choiceIdx}`, marginBottom: HINT_MARGIN, data: { hint, @@ -161,11 +161,12 @@ const PlayMultipleChoices = ({ !showCorrection; useEffect(() => { + const answers = choices.map((c, idx) => + choiceToAnswer(c, idx, DEFAULT_MARGIN) + ); // set the "gaming" view if (!showCorrection && !showCorrectness) { - setElements( - choices.map((c, idx) => choiceToAnswer(c, idx, DEFAULT_MARGIN)) - ); + setElements(answers); } else { // set the "correctness" or "correction" view setElements( @@ -178,13 +179,10 @@ const PlayMultipleChoices = ({ return []; } - const answers = choices - .map((c, idx) => choiceToAnswer(c, idx, DEFAULT_MARGIN)) - .filter((_, idx) => sectionTitle.state === choiceStates[idx]); - return [ choiceToTitle(t(sectionTitles[i].title)), ...answers + .filter((_, idx) => sectionTitle.state === choiceStates[idx]) .map((answer) => { const hint = answer.data.choice.explanation; const displayHint = showHint( diff --git a/src/config/constants.ts b/src/config/constants.ts index c021542e..b04731c2 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -111,6 +111,7 @@ export const FAILURE_MESSAGES = { MULTIPLE_CHOICES_ANSWER_COUNT: 'MULTIPLE_CHOICES_ANSWER_COUNT', MULTIPLE_CHOICES_CORRECT_ANSWER: 'MULTIPLE_CHOICES_CORRECT_ANSWER', MULTIPLE_CHOICES_EMPTY_CHOICE: 'MULTIPLE_CHOICES_EMPTY_CHOICE', + MULTIPLE_CHOICES_DUPLICATED_CHOICE: 'MULTIPLE_CHOICES_DUPLICATED_CHOICE', TEXT_INPUT_NOT_EMPTY: 'TEXT_INPUT_NOT_EMPTY', FILL_BLANKS_EMPTY_TEXT: 'FILL_BLANKS_EMPTY_TEXT', FILL_BLANKS_UNMATCHING_TAGS: 'FILL_BLANKS_UNMATCHING_TAGS', diff --git a/src/langs/en.json b/src/langs/en.json index 671b4b8f..edcfb000 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -22,15 +22,16 @@ "Minimum": "Minimum", "Slide the cursor to the correct value": "Slide the cursor to the correct value", "Add a new question": "Add a new question", - "FAILURE_MESSAGES.EMPTY_QUESTION": "Question title cannot be empty", - "FAILURE_MESSAGES.SLIDER_MIN_SMALLER_THAN_MAX": "The minimum value should be less than the maximum value", - "FAILURE_MESSAGES.SLIDER_UNDEFINED_MIN_MAX": "Minimum and maximum values should be defined", - "FAILURE_MESSAGES.MULTIPLE_CHOICES_ANSWER_COUNT": "You must provide at least 2 possible answers", - "FAILURE_MESSAGES.MULTIPLE_CHOICES_CORRECT_ANSWER": "You must set at least one correct answer", - "FAILURE_MESSAGES.MULTIPLE_CHOICES_EMPTY_CHOICE": "An answer cannot be empty", - "FAILURE_MESSAGES.TEXT_INPUT_NOT_EMPTY": "Answer cannot be empty", - "FAILURE_MESSAGES.FILL_BLANKS_EMPTY_TEXT": "The text cannot be empty", - "FAILURE_MESSAGES.FILL_BLANKS_UNMATCHING_TAGS": "The text has unmatching '<' and '>'", + "EMPTY_QUESTION": "Question title cannot be empty", + "SLIDER_MIN_SMALLER_THAN_MAX": "The minimum value should be less than the maximum value", + "SLIDER_UNDEFINED_MIN_MAX": "Minimum and maximum values should be defined", + "MULTIPLE_CHOICES_ANSWER_COUNT": "You must provide at least 2 possible answers", + "MULTIPLE_CHOICES_CORRECT_ANSWER": "You must set at least one correct answer", + "MULTIPLE_CHOICES_EMPTY_CHOICE": "An answer cannot be empty", + "MULTIPLE_CHOICES_DUPLICATED_CHOICE": "Each answer should be unique", + "TEXT_INPUT_NOT_EMPTY": "Answer cannot be empty", + "FILL_BLANKS_EMPTY_TEXT": "The text cannot be empty", + "FILL_BLANKS_UNMATCHING_TAGS": "The text has unmatching '<' and '>'", "Create Quiz": "Create Quiz", "Results": "Results", "User": "User", diff --git a/src/langs/fr.json b/src/langs/fr.json index d5393026..acfa1e10 100644 --- a/src/langs/fr.json +++ b/src/langs/fr.json @@ -27,6 +27,7 @@ "MULTIPLE_CHOICES_ANSWER_COUNT": "Au moins 2 réponses doivent être proposées", "MULTIPLE_CHOICES_CORRECT_ANSWER": "Au moins une réponse doit être correcte", "MULTIPLE_CHOICES_EMPTY_CHOICE": "Une réponse ne peut pas être vide", + "MULTIPLE_CHOICES_DUPLICATED_CHOICE": "Chaque réponse doit être unique", "TEXT_INPUT_NOT_EMPTY": "La réponse ne peut pas être vide", "FILL_BLANKS_EMPTY_TEXT": "", "FILL_BLANKS_UNMATCHING_TAGS": "", diff --git a/src/layout/theme.ts b/src/layout/theme.ts index f137faf7..35a18eaf 100644 --- a/src/layout/theme.ts +++ b/src/layout/theme.ts @@ -5,6 +5,9 @@ const graaspTheme = createTheme({ primary: { main: '#555BD9', }, + background: { + default: 'transparent', + }, }, }); diff --git a/src/utils/array.ts b/src/utils/array.ts index 352e90bd..d430d2cb 100644 --- a/src/utils/array.ts +++ b/src/utils/array.ts @@ -10,3 +10,26 @@ export const getFirstOrUndefined = ( return undefined; }; + +export const countKeysApparition = ( + elements: T[], + key: K +) => + elements.reduce>((countMap, c) => { + const loweredKey = (c[key] as string).toLowerCase(); + countMap[loweredKey] = (countMap[loweredKey] || 0) + 1; + return countMap; + }, {}); + +export const getDuplicatedKeys = ( + elements: T[], + key: K +) => + new Map( + Object.entries(countKeysApparition(elements, key)).filter( + ([_k, c]) => c > 1 + ) + ); + +export const hasDuplicatedKeys = (map: Map) => + Array.from(map.values()).some((count) => count > 1);