diff --git a/package.json b/package.json index 8c2e47a02..bc6872fdc 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "luxon": "^3.7.2", + "markdown-to-jsx": "^9.3.3", "react": "^19.2.0", "react-dom": "^19.2.0", "react-grid-layout": "^1.5.2", @@ -90,8 +91,8 @@ "start": "vite", "build": "vite build", "serve": "vite preview", - "test": "LANG=de_de jest", - "test:coverage": "LANG=de_de jest --coverage --collectCoverageFrom='!src/pages/**/*.tsx'", + "test": "LANG=de_de jest --testTimeout=15000", + "test:coverage": "LANG=de_de jest --coverage --collectCoverageFrom='!src/pages/**/*.tsx' --testTimeout=15000", "i18n": "i18next", "lint": "eslint src", "lint:quiet": "eslint --quiet src", @@ -113,7 +114,8 @@ "moduleNameMapper": { "^axios$": "/node_modules/axios/dist/node/axios.cjs", "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", - "\\.(css|less)$": "/__mocks__/styleMock.js" + "\\.(css|less)$": "/__mocks__/styleMock.js", + "^react-markdown$": "/__mocks__/react-markdown.js" }, "preset": "ts-jest", "testEnvironment": "jest-environment-jsdom", @@ -125,4 +127,4 @@ ] }, "packageManager": "npm@10.5.0" -} +} \ No newline at end of file diff --git a/src/components/Common/forms/MarkdownEditor.test.tsx b/src/components/Common/forms/MarkdownEditor.test.tsx new file mode 100644 index 000000000..ae5110e3f --- /dev/null +++ b/src/components/Common/forms/MarkdownEditor.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MarkdownEditor } from './MarkdownEditor'; +import '@testing-library/jest-dom'; + +describe('MarkdownEditor', () => { + const mockChange = jest.fn(); + + it('renders in Write mode by default', () => { + render(); + // Check for the textarea + expect(screen.getByRole('textbox')).toBeInTheDocument(); + // Check content exists inside textbox + expect(screen.getByDisplayValue('Test content')).toBeInTheDocument(); + }); + + it('calls onChange when typing', () => { + render(); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'New text' } }); + expect(mockChange).toHaveBeenCalledWith('New text'); + }); + + it('toggles to preview mode and renders basic formatting', () => { + const markdown = "This is **bold** and *italic*"; + render(); + + // Switch to Preview + fireEvent.click(screen.getByText('Preview')); + + // Check that bold and italic tags are rendered + // Note: markdown-to-jsx might use or , check your overrides. + // We overrode 'strong' to 'strong', so we look for that. + const boldElement = screen.getByText('bold'); + expect(boldElement.tagName).toBe('STRONG'); + + const italicElement = screen.getByText('italic'); + expect(italicElement.tagName).toBe('EM'); + }); + + it('blocks Heading tags (h1-h6) and renders them as plain text/paragraphs', () => { + // We provide a # Heading. + // Expected result: Text "Forbidden Heading" is visible, but NOT inside an

tag. + render(); + + fireEvent.click(screen.getByText('Preview')); + + // 1. The text should still be readable + expect(screen.getByText('Forbidden Heading')).toBeInTheDocument(); + + // 2. But there should be NO heading role in the document + const heading = screen.queryByRole('heading', { level: 1 }); + expect(heading).toBeNull(); + }); + + it('blocks Link tags (a) and renders plain text', () => { + const markdown = "[Malicious Link](http://evil.com)"; + render(); + + fireEvent.click(screen.getByText('Preview')); + + // 1. The text anchor should be visible + expect(screen.getByText('Malicious Link')).toBeInTheDocument(); + + // 2. But it should NOT be a link (no anchor tag) + const link = screen.queryByRole('link'); + expect(link).toBeNull(); + + // 3. Ensure the href is not present in the DOM (security check) + const textElement = screen.getByText('Malicious Link'); + expect(textElement).not.toHaveAttribute('href'); + }); + + it('blocks Images', () => { + // Image syntax: ![alt text](url) + render(); + + fireEvent.click(screen.getByText('Preview')); + + // Ensure the image tag is not rendered + const img = screen.queryByRole('img'); + expect(img).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/components/Common/forms/MarkdownEditor.tsx b/src/components/Common/forms/MarkdownEditor.tsx new file mode 100644 index 000000000..3e69b7bd6 --- /dev/null +++ b/src/components/Common/forms/MarkdownEditor.tsx @@ -0,0 +1,113 @@ +import React, { useState } from 'react'; +import Markdown, { MarkdownToJSX } from 'markdown-to-jsx'; +import { Box, Button, TextField, Typography, Paper, ButtonGroup } from '@mui/material'; + +const StripElement = ({ children }: { children: React.ReactNode }) => {children}; + +const StripBlock = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +const MarkdownOptions: MarkdownToJSX.Options = { + overrides: { + p: { + component: Typography, + props: { variant: 'body1', sx: { mb: 1.5 } } + }, + + // Allowed inline/list tags + strong: { component: 'strong' }, + b: { component: 'b' }, + em: { component: 'em' }, + i: { component: 'i' }, + ul: { component: 'ul', props: { style: { paddingLeft: '20px', margin: '0 0 16px 0' } } }, + ol: { component: 'ol', props: { style: { paddingLeft: '20px', margin: '0 0 16px 0' } } }, li: { component: 'li' }, + + // Block links and headings by mapping them to plan spans. + a: { component: StripElement }, + + // --- BLOCKED TAGS (No headings allowed) --- + h1: { component: StripBlock }, + h2: { component: StripBlock }, + h3: { component: StripBlock }, + h4: { component: StripBlock }, + h5: { component: StripBlock }, + h6: { component: StripBlock }, // Tables, images, etc. are naturally ignored by markdown-to-jsx if not explicitly defined + + img: { component: () => null }, + + table: { component: 'div' }, + }, +}; + +interface MarkdownEditorProps { + value: string; + onChange: (value: string) => void; + label?: string; + error?: boolean; + helperText?: string; +} + +export const MarkdownEditor: React.FC = ({ + value, + onChange, + label = "Description", + error, + helperText +}) => { + const [isPreview, setIsPreview] = useState(false); + + return ( + + + + {label} + + + + + + + + {isPreview ? ( + + {/* The library handles the parsing based on our secure options */} + + {value || '*No content*'} + + + ) : ( + onChange(e.target.value)} + error={error} + helperText={helperText} + placeholder="Use Markdown: *italic*, **bold**, - list" + /> + )} + + ); +}; \ No newline at end of file diff --git a/src/components/Exercises/Add/Step3Description.tsx b/src/components/Exercises/Add/Step3Description.tsx index bbcab4451..46761aec0 100644 --- a/src/components/Exercises/Add/Step3Description.tsx +++ b/src/components/Exercises/Add/Step3Description.tsx @@ -3,11 +3,10 @@ import Grid from '@mui/material/Grid'; import { useLanguageCheckQuery } from "components/Core/queries"; import { StepProps } from "components/Exercises/Add/AddExerciseStepper"; import { PaddingBox } from "components/Exercises/Detail/ExerciseDetails"; -import { ExerciseDescription } from "components/Exercises/forms/ExerciseDescription"; +import { MarkdownEditor } from "components/Common/forms/MarkdownEditor"; import { ExerciseNotes } from "components/Exercises/forms/ExerciseNotes"; import { descriptionValidator, noteValidator } from "components/Exercises/forms/yupValidators"; import { Form, Formik } from "formik"; -import React from "react"; import { useTranslation } from "react-i18next"; import { useExerciseSubmissionStateValue } from "state"; import { setDescriptionEn, setNotesEn } from "state/exerciseSubmissionReducer"; @@ -59,38 +58,46 @@ export const Step3Description = ({ onContinue, onBack }: StepProps) => { }} > -
- - + {({ values, errors, touched, setFieldValue }) => ( + + + setFieldValue('description', val)} + error={touched.description && Boolean(errors.description)} + helperText={touched.description ? errors.description : undefined} + /> - + - + - - - -
- - -
-
+ + + +
+ + +
+
+
-
-
- +
+ + )} ) ); -}; +}; \ No newline at end of file diff --git a/src/components/Exercises/Detail/ExerciseDetailEdit.test.tsx b/src/components/Exercises/Detail/ExerciseDetailEdit.test.tsx index 829c5ed34..9f6d74d9c 100644 --- a/src/components/Exercises/Detail/ExerciseDetailEdit.test.tsx +++ b/src/components/Exercises/Detail/ExerciseDetailEdit.test.tsx @@ -149,7 +149,8 @@ describe("Exercise translation edit tests", () => { id: 9, languageId: 1, author: "", - description: "Die Kniebeuge ist eine Übung zur Kräftigung der Oberschenkelmuskulatur", + description: "", + descriptionSource: "Die Kniebeuge ist eine Übung zur Kräftigung der Oberschenkelmuskulatur", name: "Mangalitza" }); }); diff --git a/src/components/Exercises/Detail/ExerciseDetailEdit.tsx b/src/components/Exercises/Detail/ExerciseDetailEdit.tsx index 4d91ad2c7..4be7d5572 100644 --- a/src/components/Exercises/Detail/ExerciseDetailEdit.tsx +++ b/src/components/Exercises/Detail/ExerciseDetailEdit.tsx @@ -6,7 +6,7 @@ import { PaddingBox } from "components/Exercises/Detail/ExerciseDetails"; import { EditExerciseCategory } from "components/Exercises/forms/Category"; import { EditExerciseEquipment } from "components/Exercises/forms/Equipment"; import { ExerciseAliases } from "components/Exercises/forms/ExerciseAliases"; -import { ExerciseDescription } from "components/Exercises/forms/ExerciseDescription"; +import { MarkdownEditor } from "components/Common/forms/MarkdownEditor"; import { ExerciseName } from "components/Exercises/forms/ExerciseName"; import { AddImageCard, ImageEditCard } from "components/Exercises/forms/ImageCard"; import { EditExerciseMuscle } from "components/Exercises/forms/Muscle"; @@ -85,7 +85,7 @@ export const ExerciseDetailEdit = ({ exerciseId, language }: ViewProps) => { const isNewTranslation = language.id !== translationFromBase?.language; const exerciseTranslation = isNewTranslation - ? new Translation(null, null, '', '', language.id) + ? new Translation(null, null, '', '', language.id, [], [], [], undefined) : translationFromBase; const exerciseEnglish = exercise.getTranslation(); @@ -100,29 +100,26 @@ export const ExerciseDetailEdit = ({ exerciseId, language }: ViewProps) => { initialValues={{ name: exerciseTranslation.name, alternativeNames: exerciseTranslation.aliases.map(a => ({ id: a.id, alias: a.alias })), - description: exerciseTranslation.description, + // Fallback: Use Source if available, else HTML (legacy), else empty + description: exerciseTranslation.descriptionSource || exerciseTranslation.description || '', }} enableReinitialize validationSchema={validationSchema} onSubmit={async values => { // Exercise translation + const payload = { + exerciseId: exercise.id!, + languageId: language.id, + name: values.name, + description: '', + descriptionSource: values.description, + author: '' + }; + const translation = exerciseTranslation.id - ? await editTranslationQuery.mutateAsync({ - id: exerciseTranslation.id, - exerciseId: exercise.id!, - languageId: language.id, - name: values.name, - description: values.description, - author: '' - }) - : await addTranslationQuery.mutateAsync({ - exerciseId: exercise.id!, - languageId: language.id, - name: values.name, - description: values.description, - author: profileQuery.data!.username - }); + ? await editTranslationQuery.mutateAsync({ ...payload, id: exerciseTranslation.id }) + : await addTranslationQuery.mutateAsync({ ...payload, author: profileQuery.data!.username }); // Alias handling const aliasOrig = (exerciseTranslation.aliases).map(a => ({ id: a.id, alias: a.alias })); @@ -147,127 +144,114 @@ export const ExerciseDetailEdit = ({ exerciseId, language }: ViewProps) => { setAlertIsVisible(true); }} > -
- - {alertIsVisible && + {({ values, touched, errors, setFieldValue }) => ( + + + {alertIsVisible && + + { + setAlertIsVisible(false); + }} + > + + + } + > + {t('exercises.successfullyUpdated')} + + + } + + + {t('translation')} + + + {t('English')} + + + + {language.nameLong} ({language.nameShort}) + + - { - setAlertIsVisible(false); - }} - > - - - } - > - {t('exercises.successfullyUpdated')} - - } - - - {t('translation')} - - - {t('English')} - - - - {language.nameLong} ({language.nameShort}) - - - - - {t('name')} - - - {exerciseEnglish.name} -
    - {exerciseEnglish.aliases.map((alias) => ( -
  • {alias.alias}
  • - ))} -
-
- - - - - - - - - - - - - {t('exercises.description')} - - -
- - - - - - {editExercisePermissionQuery.data && <> + {t('name')} + + + {exerciseEnglish.name} +
    + {exerciseEnglish.aliases.map((alias) => ( +
  • {alias.alias}
  • + ))} +
+
+ + + + + + + + - {t('nutrition.others')} + {t('exercises.description')} - + {/* English/Base Description (Read Only) */} + English Description (Reference) +
- e.id)} /> + {/* Markdown Editor */} + setFieldValue('description', val)} + error={touched.description && Boolean(errors.description)} + // FIXED: Use ternary to ensure we return undefined instead of false + helperText={touched.description ? errors.description : undefined} + /> - } - - {/* - - - - - {t('exercises.notes')} - - -
    - {exerciseEnglish.notes.map((note: Note) => ( -
  • {note.note}
  • - ))} -
-
- -
    - {exerciseTranslation.notes.map((note: Note) => ( -
  • {note.note}
  • - ))} -
-
- */} + {editExercisePermissionQuery.data && <> + + + + + {t('nutrition.others')} + + + + + + e.id)} /> + + } - - - + + + + - - + + )} {/* Images */} @@ -302,7 +286,7 @@ export const ExerciseDetailEdit = ({ exerciseId, language }: ViewProps) => { {exercise.videos.map(video => ( + canDelete={deleteVideoPermissionQuery.data!} /> ))} @@ -353,4 +337,4 @@ export const ExerciseDetailEdit = ({ exerciseId, language }: ViewProps) => { } ; -}; +}; \ No newline at end of file diff --git a/src/components/Exercises/models/translation.test.ts b/src/components/Exercises/models/translation.test.ts index f44540831..f79f6d062 100644 --- a/src/components/Exercises/models/translation.test.ts +++ b/src/components/Exercises/models/translation.test.ts @@ -33,6 +33,7 @@ describe("Exercise translation model tests", () => { language: 1, notes: [], aliases: [], + description_source: undefined, // eslint-disable-next-line camelcase author_history: ['author1', 'author2', 'author3'] })).toStrictEqual(e1); @@ -48,6 +49,7 @@ describe("Exercise translation model tests", () => { name: "a very long name that should be truncated", description: "description", language: 1, + description_source: undefined, }); }); diff --git a/src/components/Exercises/models/translation.ts b/src/components/Exercises/models/translation.ts index cf1b28994..52737cd4d 100644 --- a/src/components/Exercises/models/translation.ts +++ b/src/components/Exercises/models/translation.ts @@ -12,13 +12,14 @@ export class Translation { authors: string[] = []; constructor(public id: number | null, - public uuid: string | null, - public name: string, - public description: string, - public language: number, - notes?: Note[], - aliases?: Alias[], - authors?: string[] + public uuid: string | null, + public name: string, + public description: string, + public language: number, + notes?: Note[], + aliases?: Alias[], + authors?: string[], + public descriptionSource?: string ) { if (notes) { this.notes = notes; @@ -62,7 +63,8 @@ export class TranslationAdapter implements Adapter { item.notes?.map((e: any) => (new NoteAdapter().fromJson(e))), // eslint-disable-next-line @typescript-eslint/no-explicit-any item.aliases?.map((e: any) => (new AliasAdapter().fromJson(e))), - item.author_history + item.author_history, + item.description_source ); } @@ -78,6 +80,7 @@ export class TranslationAdapter implements Adapter { name: item.name, description: item.description, language: item.language, + description_source: item.descriptionSource, }; } } \ No newline at end of file diff --git a/src/services/exerciseTranslation.ts b/src/services/exerciseTranslation.ts index d90ee41a7..c9a4e99ee 100644 --- a/src/services/exerciseTranslation.ts +++ b/src/services/exerciseTranslation.ts @@ -63,10 +63,11 @@ export interface AddTranslationParams { name: string; description: string; author: string; + descriptionSource?: string; } export const addTranslation = async (params: AddTranslationParams): Promise => { - const { exerciseId, languageId, name, description, author } = params; + const { exerciseId, languageId, name, description, author, descriptionSource } = params; const url = makeUrl(EXERCISE_TRANSLATION_PATH); const baseData = { @@ -74,6 +75,7 @@ export const addTranslation = async (params: AddTranslationParams): Promise => { - const { id, exerciseId, languageId, name, description } = data; + const { id, exerciseId, languageId, name, description, descriptionSource } = data; const url = makeUrl(EXERCISE_TRANSLATION_PATH, { id: id }); const baseData = { @@ -101,6 +103,7 @@ export const editTranslation = async (data: EditTranslationParams): Promise