From f971f626f294c2b08c1bb07c46a4154d178dcadb Mon Sep 17 00:00:00 2001 From: Erza Bytyci Date: Thu, 30 Jul 2026 14:28:58 +0000 Subject: [PATCH] Add false-word feedback support to click exercises --- .../00060_add_click_false_word_feedbacks.sql | 15 ++ gradesa/data/schema.sql | 18 ++ gradesa/schema.sql | 13 + .../app/admin/create-exercise/click/click.css | 41 +++ .../app/admin/create-exercise/click/page.js | 240 +++++++++++++++++- .../admin/exercises/click/[click_id]/route.js | 95 +++++-- .../app/api/admin/exercises/click/route.js | 106 ++++++-- .../api/admin/exercises/click/route.test.js | 21 ++ .../api/exercises/click/[click_id]/route.js | 10 + .../exercises/click/[click_id]/route.test.js | 15 ++ gradesa/src/app/api/exercises/click/route.js | 25 +- .../src/app/api/exercises/click/route.test.js | 16 +- .../exercises/click/[click_id]/page.css | 38 +++ .../exercises/click/[click_id]/page.js | 43 +++- gradesa/src/backend/test/testfactory.js | 16 ++ gradesa/src/components/ui/click/click.js | 73 +++++- 16 files changed, 725 insertions(+), 60 deletions(-) create mode 100644 gradesa/data/migrations/00060_add_click_false_word_feedbacks.sql create mode 100644 gradesa/src/app/grammar/exercises/click/[click_id]/page.css diff --git a/gradesa/data/migrations/00060_add_click_false_word_feedbacks.sql b/gradesa/data/migrations/00060_add_click_false_word_feedbacks.sql new file mode 100644 index 0000000..b4fade2 --- /dev/null +++ b/gradesa/data/migrations/00060_add_click_false_word_feedbacks.sql @@ -0,0 +1,15 @@ +CREATE TABLE public.click_false_word_feedbacks ( + id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + click_exercise_id bigint NOT NULL REFERENCES click_exercises(id) ON DELETE CASCADE, + slot_key text NOT NULL, + word_text text NOT NULL, + feedback text NOT NULL, + CONSTRAINT click_false_word_feedbacks_unique UNIQUE (click_exercise_id, slot_key) +); + +CREATE TRIGGER updated_at +BEFORE UPDATE ON public.click_false_word_feedbacks +FOR EACH ROW +EXECUTE FUNCTION updated_at(); \ No newline at end of file diff --git a/gradesa/data/schema.sql b/gradesa/data/schema.sql index b3688b2..54ceb22 100644 --- a/gradesa/data/schema.sql +++ b/gradesa/data/schema.sql @@ -125,6 +125,24 @@ ALTER TABLE public.click_answers ADD CONSTRAINT click_answers_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +CREATE TABLE public.click_false_word_feedbacks ( + id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + click_exercise_id bigint NOT NULL, + slot_key text NOT NULL, + word_text text NOT NULL, + feedback text NOT NULL +); + +ALTER TABLE public.click_false_word_feedbacks +ADD CONSTRAINT click_false_word_feedbacks_unique +UNIQUE (click_exercise_id, slot_key); + +ALTER TABLE public.click_false_word_feedbacks +ADD CONSTRAINT click_false_word_feedbacks_click_exercise_id_fkey +FOREIGN KEY (click_exercise_id) REFERENCES click_exercises(id) ON DELETE CASCADE; + CREATE TABLE public.click_to_exercises ( id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY, created_at timestamp with time zone NOT NULL DEFAULT now(), diff --git a/gradesa/schema.sql b/gradesa/schema.sql index eac0efb..b4b0df1 100644 --- a/gradesa/schema.sql +++ b/gradesa/schema.sql @@ -54,6 +54,19 @@ CREATE TABLE public.click_exercises ( source_html text ); +CREATE TABLE public.click_false_word_feedbacks ( + id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + click_exercise_id bigint NOT NULL REFERENCES click_exercises(id) ON DELETE CASCADE, + slot_key text NOT NULL, + word_text text NOT NULL, + feedback text NOT NULL, + CONSTRAINT click_false_word_feedbacks_unique UNIQUE (click_exercise_id, slot_key) +); + +CREATE TRIGGER updated_at BEFORE UPDATE ON public.click_false_word_feedbacks FOR EACH ROW EXECUTE FUNCTION updated_at(); + ALTER TABLE public.click_exercises ADD CONSTRAINT click_exercises_all_words_target_words_key UNIQUE (all_words, target_words); diff --git a/gradesa/src/app/admin/create-exercise/click/click.css b/gradesa/src/app/admin/create-exercise/click/click.css index fdcc5a7..18b7e49 100644 --- a/gradesa/src/app/admin/create-exercise/click/click.css +++ b/gradesa/src/app/admin/create-exercise/click/click.css @@ -47,6 +47,11 @@ justify-content: flex-start; } +.correct-word-actions { + display: flex; + justify-content: flex-end; +} + .error-message { font-size: 0.8em; margin-top: 1em; @@ -78,4 +83,40 @@ display: inline-block; max-width: 100%; word-wrap: break-word; +} + +.false-word-feedback-list { + display: grid; + gap: var(--u-sm); + margin-top: var(--u-sm); +} + +.false-word-feedback-row { + display: grid; + grid-template-columns: minmax(120px, 180px) minmax(320px, 1fr); + gap: var(--u-sm); + align-items: start; +} + +.false-word-feedback-word { + display: inline-block; + padding-top: 0.45rem; + word-break: break-word; +} + +.false-word-feedback-input { + width: 100%; + min-height: 2.8rem; + resize: vertical; + font: inherit; +} + +@media (max-width: 900px) { + .false-word-feedback-row { + grid-template-columns: 1fr; + } + + .false-word-feedback-word { + padding-top: 0; + } } \ No newline at end of file diff --git a/gradesa/src/app/admin/create-exercise/click/page.js b/gradesa/src/app/admin/create-exercise/click/page.js index 3be45bd..c54f744 100644 --- a/gradesa/src/app/admin/create-exercise/click/page.js +++ b/gradesa/src/app/admin/create-exercise/click/page.js @@ -1,5 +1,5 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { Container } from "@/components/ui/layout/container"; import { Column } from "@/components/ui/layout/container"; @@ -12,6 +12,8 @@ import { htmlToPlainText } from "@/shared/utils/normalizeEditorText"; import { withBasePath } from "@/shared/utils/basePath"; import AdminLastModified from "@/components/ui/admin-last-modified"; +const WORD_REGEX = /\p{L}+(?:['’-]\p{L}+)*/gu; + function escapeHtml(value) { return String(value || "") .replace(/&/g, "&") @@ -90,6 +92,25 @@ function splitIntoContiguousChunks(slotKeys) { return chunks; } +function buildWordSlotLookup(tokens) { + const lookup = new Map(); + let wordIndex = 0; + + (Array.isArray(tokens) ? tokens : []).forEach((token) => { + if (token === "\n" || /^[^\S\n]+$/u.test(token)) { + return; + } + + const matches = [...String(token).matchAll(WORD_REGEX)]; + matches.forEach((match) => { + lookup.set(`w-${wordIndex}`, match[0]); + wordIndex += 1; + }); + }); + + return lookup; +} + export default function CreateExercise() { const router = useRouter(); const { click_id } = useParams(); @@ -103,6 +124,9 @@ export default function CreateExercise() { const [allWordsHtml, setAllWordsHtml] = useState(""); const [allWordsText, setAllWordsText] = useState(""); const [selectedWords, setSelectedWords] = useState([]); + const [showFalseWordSelection, setShowFalseWordSelection] = useState(false); + const [falseWordSelectionKeys, setFalseWordSelectionKeys] = useState([]); + const [falseWordFeedbackByKey, setFalseWordFeedbackByKey] = useState({}); const [previewMode, setPreviewMode] = useState(false); const { @@ -141,6 +165,24 @@ export default function CreateExercise() { setSelectedWords( Array.isArray(exerciseData.target_words) ? exerciseData.target_words : [] ); + + const loadedFalseWordFeedbacks = Array.isArray( + exerciseData.false_word_feedbacks + ) + ? exerciseData.false_word_feedbacks + : []; + + setShowFalseWordSelection(loadedFalseWordFeedbacks.length > 0); + setFalseWordSelectionKeys( + loadedFalseWordFeedbacks.map((entry) => entry.slot_key).filter(Boolean) + ); + setFalseWordFeedbackByKey( + Object.fromEntries( + loadedFalseWordFeedbacks + .filter((entry) => entry.slot_key) + .map((entry) => [entry.slot_key, entry.feedback || ""]) + ) + ); }, [exerciseData, isEditMode]); const handleEditorContentChange = (html) => { @@ -152,6 +194,48 @@ export default function CreateExercise() { const allWords = allWordsText.replace(/\r\n/g, "\n").match(/[^\S\n]+|\n|[^\s]+/g) ?? []; + const falseWordSlotLookup = useMemo( + () => buildWordSlotLookup(allWords), + [allWords] + ); + + const falseWordFeedbackEntries = useMemo( + () => + falseWordSelectionKeys.map((slotKey) => ({ + slotKey, + wordText: falseWordSlotLookup.get(slotKey) || slotKey, + feedback: falseWordFeedbackByKey[slotKey] || "", + })), + [falseWordSelectionKeys, falseWordFeedbackByKey, falseWordSlotLookup] + ); + + const disallowedFalseWordSlotKeys = useMemo(() => { + const entries = (Array.isArray(selectedWords) ? selectedWords : []) + .map(parseTargetEntry) + .filter(Boolean); + + const disallowed = new Set(); + const legacyWordTargets = new Set(); + + entries.forEach((entry) => { + if (/^w-\d+$/u.test(entry.slotKey)) { + disallowed.add(entry.slotKey); + } else if (entry.slotKey) { + legacyWordTargets.add(entry.slotKey); + } + }); + + if (legacyWordTargets.size > 0) { + falseWordSlotLookup.forEach((word, slotKey) => { + if (legacyWordTargets.has(word)) { + disallowed.add(slotKey); + } + }); + } + + return disallowed; + }, [selectedWords, falseWordSlotLookup]); + const handlePreview = (e) => { e.preventDefault(); setPreviewMode(true); @@ -169,8 +253,16 @@ export default function CreateExercise() { return; } - const uniqueSlotKeys = [...new Set(entries.map((entry) => entry.slotKey))]; - const chunks = splitIntoContiguousChunks(uniqueSlotKeys); + const existingGroupedEntries = entries.filter((entry) => entry.groupId); + const existingGroupedSlotKeys = new Set( + existingGroupedEntries.map((entry) => entry.slotKey) + ); + const ungroupedSlotKeys = entries + .filter((entry) => !existingGroupedSlotKeys.has(entry.slotKey)) + .map((entry) => entry.slotKey); + + const uniqueUngroupedSlotKeys = [...new Set(ungroupedSlotKeys)]; + const chunks = splitIntoContiguousChunks(uniqueUngroupedSlotKeys); const hasElementChunk = chunks.some((chunk) => chunk.length > 1); if (!hasElementChunk) { @@ -180,16 +272,64 @@ export default function CreateExercise() { return; } - const nextTargets = chunks.flatMap((chunk, chunkIndex) => { + const groupedExistingRaw = existingGroupedEntries.map((entry) => entry.raw); + const nextTargetsFromUngrouped = chunks.flatMap((chunk, chunkIndex) => { if (chunk.length <= 1) { return chunk; } - const groupId = `group-${Date.now()}-${chunkIndex}`; + const groupId = + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? `group-${crypto.randomUUID()}` + : `group-${Date.now()}-${Math.random().toString(36).slice(2)}-${chunkIndex}`; return chunk.map((slotKey) => `${groupId}::${slotKey}`); }); - setSelectedWords(nextTargets); + setSelectedWords([...groupedExistingRaw, ...nextTargetsFromUngrouped]); + }; + + const handleFalseWordSelectionChange = (updatedSelectedKeys) => { + const blockedKeys = updatedSelectedKeys.filter((slotKey) => + disallowedFalseWordSlotKeys.has(slotKey) + ); + + if (blockedKeys.length > 0) { + const warningMessage = + "Du hast dieses Wort bereits als korrektes Wort ausgewählt."; + if (typeof window !== "undefined") { + window.alert(warningMessage); + } + } + + const allowedKeys = updatedSelectedKeys.filter( + (slotKey) => !disallowedFalseWordSlotKeys.has(slotKey) + ); + + setError(null); + setFalseWordSelectionKeys(allowedKeys); + }; + + const handleFalseWordFeedbackChange = (slotKey, value) => { + setFalseWordFeedbackByKey((current) => ({ + ...current, + [slotKey]: value, + })); + }; + + const validateFalseWordFeedbacks = () => { + if (falseWordSelectionKeys.length === 0) { + return null; + } + + const missingFeedback = falseWordSelectionKeys.find( + (slotKey) => !(falseWordFeedbackByKey[slotKey] || "").trim() + ); + + if (missingFeedback) { + return "Bitte gib für jedes markierte falsche Wort ein Feedback ein."; + } + + return null; }; const handleSaveExercise = async () => { @@ -197,6 +337,30 @@ export default function CreateExercise() { setError(null); setTitleError(false); + const validationError = validateFalseWordFeedbacks(); + if (validationError) { + setError(validationError); + return; + } + + const conflictingFalseWords = falseWordSelectionKeys.filter((slotKey) => + disallowedFalseWordSlotKeys.has(slotKey) + ); + if (conflictingFalseWords.length > 0) { + setError( + "Einige falsche Wörter sind bereits als korrekte Wörter ausgewählt. Bitte entferne diese zuerst." + ); + return; + } + + const falseWordFeedbacksPayload = falseWordFeedbackEntries.map( + (entry) => ({ + slotKey: entry.slotKey, + wordText: entry.wordText, + feedback: entry.feedback, + }) + ); + if (isEditMode) { const response = await fetch( withBasePath(`/api/admin/exercises/click/${click_id}`), @@ -209,6 +373,7 @@ export default function CreateExercise() { targetWords: selectedWords, allWords, sourceHtml: allWordsHtml, + falseWordFeedbacks: falseWordFeedbacksPayload, }), } ); @@ -232,6 +397,7 @@ export default function CreateExercise() { targetWords: selectedWords, allWords, sourceHtml: allWordsHtml, + falseWordFeedbacks: falseWordFeedbacksPayload, }), } ); @@ -385,7 +551,7 @@ export default function CreateExercise() { } /> - + + + {showFalseWordSelection && ( + + +
+ {falseWordFeedbackEntries.length === 0 ? ( +

+ Markiere ein falsches Wort, um dafür ein Feedback zu + hinterlegen. +

+ ) : ( + falseWordFeedbackEntries.map((entry) => ( +
+ + {entry.wordText} + +