Skip to content

Commit

Permalink
feat: prevent same choice and duplicated keys (#152)
Browse files Browse the repository at this point in the history
fix: update en lang failures messages keys

feat: use transparent background

* fix: generate unique keys to avoid issues in animation

feat: check that each choice is unique

* feat(test): add test for duplicated answers

* fix: use index instead of random for react keys
  • Loading branch information
ReidyT authored May 1, 2024
1 parent 913d640 commit 3fefa3c
Show file tree
Hide file tree
Showing 9 changed files with 96 additions and 19 deletions.
26 changes: 26 additions & 0 deletions cypress/e2e/Admin/create/multipleChoices.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
20 changes: 20 additions & 0 deletions src/components/common/animations/ReorderAnimation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -24,6 +26,22 @@ type Props<T extends DataElementType> = {
) => JSX.Element;
};

const duplicatedKeys = <T extends DataElementType>(
elements: TransitionData<T>[]
) =>
Object.entries(countKeysApparition(elements, 'key'))
.filter(([key, count]) => key && count > 1)
.map(([key, _]) => key);

const printDuplicatedKeysWarning = <T extends DataElementType>(
elements: TransitionData<T>[]
) =>
duplicatedKeys(elements).forEach((k) =>
console.warn(
`Be carefull, the key "${k}" is not unique ! This can cause issues with the animation.`
)
);

export const ReorderAnimation = <T extends DataElementType>({
isAnimating,
elements,
Expand Down Expand Up @@ -88,6 +106,8 @@ export const ReorderAnimation = <T extends DataElementType>({
);
};

printDuplicatedKeysWarning(elements);

return (
<div
style={{
Expand Down
4 changes: 4 additions & 0 deletions src/components/context/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
FAILURE_MESSAGES,
QuestionType,
} from '../../config/constants';
import { getDuplicatedKeys, hasDuplicatedKeys } from '../../utils/array';
import { ANSWER_REGEXP } from '../../utils/fillInTheBlanks';
import {
AppDataWithDataId,
Expand Down Expand Up @@ -250,6 +251,9 @@ export const validateQuestionData = (data: QuestionData) => {
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:
Expand Down
18 changes: 8 additions & 10 deletions src/components/play/multipleChoices/PlayMultipleChoices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,13 @@ const choiceToAnswer = (
idx: number,
marginBottom: number
): TransitionData<AnswerDataType> => ({
key: choice.value,
key: `answer-${choice.value}-${idx}`,
marginBottom,
data: { idx, choice, elementType: ElementType.Answer },
});

const choiceToTitle = (title: string): TransitionData<TitleDataType> => ({
key: title,
key: `title-${title}`,
marginBottom: DEFAULT_MARGIN,
data: {
title,
Expand All @@ -105,7 +105,7 @@ const choiceToHint = (
choiceIdx: number,
hint: string
): TransitionData<HintDataType> => ({
key: hint,
key: `hint-${hint}-${choiceIdx}`,
marginBottom: HINT_MARGIN,
data: {
hint,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
19 changes: 10 additions & 9 deletions src/langs/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/langs/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down
3 changes: 3 additions & 0 deletions src/layout/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ const graaspTheme = createTheme({
primary: {
main: '#555BD9',
},
background: {
default: 'transparent',
},
},
});

Expand Down
23 changes: 23 additions & 0 deletions src/utils/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,26 @@ export const getFirstOrUndefined = <T>(

return undefined;
};

export const countKeysApparition = <T, K extends keyof T>(
elements: T[],
key: K
) =>
elements.reduce<Record<string, number>>((countMap, c) => {
const loweredKey = (c[key] as string).toLowerCase();
countMap[loweredKey] = (countMap[loweredKey] || 0) + 1;
return countMap;
}, {});

export const getDuplicatedKeys = <T, K extends keyof T>(
elements: T[],
key: K
) =>
new Map(
Object.entries(countKeysApparition(elements, key)).filter(
([_k, c]) => c > 1
)
);

export const hasDuplicatedKeys = (map: Map<string, number>) =>
Array.from(map.values()).some((count) => count > 1);

0 comments on commit 3fefa3c

Please sign in to comment.