Add false-word feedback support to click exercises

This commit is contained in:
Erza Bytyci
2026-07-30 14:28:58 +00:00
parent 796dd873a5
commit f971f626f2
16 changed files with 725 additions and 60 deletions
@@ -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();
+18
View File
@@ -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(),
+13
View File
@@ -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);
@@ -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;
}
}
@@ -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() {
}
/>
</Container>
<Container>
<Container className="correct-word-actions">
<Button
size="sm"
variant="secondary"
@@ -395,6 +561,66 @@ export default function CreateExercise() {
Als Element übernehmen
</Button>
</Container>
<Container className="click-block">
<Button
size="sm"
variant="secondary"
onClick={() => setShowFalseWordSelection((current) => !current)}
>
{showFalseWordSelection
? "Falsche Wörter ausblenden"
: "Falsche Wörter auswählen"}
</Button>
</Container>
{showFalseWordSelection && (
<Container className="click-block">
<WordSelectionExercise
title={title}
targetCategory={targetCategory}
targetWords={[]}
allWords={allWords}
sourceHtml={allWordsHtml}
isPreviewMode={true}
previewGroupAdjacentSelection={false}
previewAutoSelectTargets={false}
previewHighlightTargets={false}
previewSelectionKeys={falseWordSelectionKeys}
onPreviewSelectionChange={handleFalseWordSelectionChange}
instructionText="Wähle die falschen Wörter aus, für die du Feedback speichern möchtest."
/>
<div className="false-word-feedback-list">
{falseWordFeedbackEntries.length === 0 ? (
<p>
Markiere ein falsches Wort, um dafür ein Feedback zu
hinterlegen.
</p>
) : (
falseWordFeedbackEntries.map((entry) => (
<div
key={entry.slotKey}
className="false-word-feedback-row"
>
<strong className="false-word-feedback-word">
{entry.wordText}
</strong>
<textarea
className="false-word-feedback-input"
rows={2}
value={entry.feedback}
onChange={(e) =>
handleFalseWordFeedbackChange(
entry.slotKey,
e.target.value
)
}
placeholder="Feedback für dieses falsche Wort"
/>
</div>
))
)}
</div>
</Container>
)}
<Container>
<Button size="sm" variant="secondary" onClick={handleEditAgain}>
Übung bearbeiten
@@ -72,6 +72,14 @@ export const GET = withAuth(
[click_id]
);
const feedbackResult = await DB.pool(
`SELECT slot_key, word_text, feedback
FROM click_false_word_feedbacks
WHERE click_exercise_id = $1
ORDER BY id ASC`,
[click_id]
);
if (result.rows.length === 0) {
return NextResponse.json(
{ error: "Übung nicht gefunden." },
@@ -79,7 +87,10 @@ export const GET = withAuth(
);
}
return NextResponse.json(result.rows[0]);
return NextResponse.json({
...result.rows[0],
false_word_feedbacks: feedbackResult.rows,
});
} catch (error) {
console.error("Error fetching click exercise:", error);
return NextResponse.json(
@@ -101,6 +112,15 @@ export const PUT = withAuth(
const body = await request.json();
const { title, targetCategory, targetWords, allWords, sourceHtml } = body;
const sanitizedSourceHtml = sanitizeHtml(sourceHtml || "");
const falseWordFeedbacks = Array.isArray(body.falseWordFeedbacks)
? body.falseWordFeedbacks
: [];
const normalizedFalseWordFeedbacks = falseWordFeedbacks.map((entry) => ({
slotKey: String(entry?.slotKey || entry?.slot_key || "").trim(),
wordText: String(entry?.wordText || entry?.word_text || "").trim(),
feedback: String(entry?.feedback || "").trim(),
}));
const validationError = validatePayload({
title,
@@ -144,33 +164,54 @@ export const PUT = withAuth(
);
}
await DB.pool(
`UPDATE click_exercises
SET title = $1,
category = $2,
target_words = $3,
all_words = $4,
source_html = $5,
updated_at = NOW()
WHERE id = $6`,
[
title,
targetCategory,
targetWords,
allWords,
sanitizedSourceHtml,
click_id,
]
);
await DB.transaction(async (tx) => {
await tx.query(
`UPDATE click_exercises
SET title = $1,
category = $2,
target_words = $3,
all_words = $4,
source_html = $5,
updated_at = NOW()
WHERE id = $6`,
[
title,
targetCategory,
targetWords,
allWords,
sanitizedSourceHtml,
click_id,
]
);
await DB.pool(
`UPDATE exercises e
SET updated_by = $1
FROM click_to_exercises cte
WHERE cte.exercise_id = e.id
AND cte.click_id = $2`,
[request.user?.id ?? null, click_id]
);
await tx.query(
`DELETE FROM click_false_word_feedbacks
WHERE click_exercise_id = $1`,
[click_id]
);
for (const entry of normalizedFalseWordFeedbacks) {
if (!entry.slotKey || !entry.wordText || !entry.feedback) {
continue;
}
await tx.query(
`INSERT INTO click_false_word_feedbacks
(click_exercise_id, slot_key, word_text, feedback)
VALUES ($1, $2, $3, $4)`,
[click_id, entry.slotKey, entry.wordText, entry.feedback]
);
}
await tx.query(
`UPDATE exercises e
SET updated_by = $1
FROM click_to_exercises cte
WHERE cte.exercise_id = e.id
AND cte.click_id = $2`,
[request.user?.id ?? null, click_id]
);
});
return NextResponse.json({ success: true });
} catch (error) {
@@ -9,11 +9,59 @@ function sanitizeHtml(html) {
return purify.sanitize(String(html || ""), { ADD_ATTR: ["target"] });
}
function normalizeFalseWordFeedbacks(falseWordFeedbacks) {
if (!Array.isArray(falseWordFeedbacks)) {
return [];
}
return falseWordFeedbacks.map((entry) => ({
slotKey: String(entry?.slotKey || entry?.slot_key || "").trim(),
wordText: String(entry?.wordText || entry?.word_text || "").trim(),
feedback: String(entry?.feedback || "").trim(),
}));
}
function validateFalseWordFeedbacks(falseWordFeedbacks) {
if (falseWordFeedbacks.length === 0) {
return null;
}
if (falseWordFeedbacks.length > 1000) {
return {
error:
"Es dürfen maximal 1000 falsche Wörter mit Feedback gespeichert werden.",
status: 422,
};
}
if (
falseWordFeedbacks.some(
(entry) => !entry.slotKey || !entry.wordText || !entry.feedback
)
) {
return {
error: "Für jedes markierte falsche Wort ist ein Feedback erforderlich.",
status: 422,
};
}
return null;
}
export const POST = withAuth(
async (request) => {
const json = await request.json();
const { title, targetCategory, targetWords, allWords, sourceHtml } = json;
const {
title,
targetCategory,
targetWords,
allWords,
sourceHtml,
falseWordFeedbacks,
} = json;
const sanitizedSourceHtml = sanitizeHtml(sourceHtml || "");
const normalizedFalseWordFeedbacks =
normalizeFalseWordFeedbacks(falseWordFeedbacks);
if (!title || !targetCategory || !targetWords || !allWords) {
return Response.json(
@@ -45,6 +93,17 @@ export const POST = withAuth(
{ status: 422 }
);
}
const falseWordValidationError = validateFalseWordFeedbacks(
normalizedFalseWordFeedbacks
);
if (falseWordValidationError) {
return Response.json(
{ error: falseWordValidationError.error },
{ status: falseWordValidationError.status }
);
}
const existingExercise = await DB.pool(
`SELECT ce.id
FROM click_exercises ce
@@ -59,23 +118,38 @@ export const POST = withAuth(
{ status: 409 }
);
}
const id = await DB.pool(
`INSERT INTO click_exercises (title, category, target_words, all_words, source_html)
VALUES ($1, $2, $3, $4, $5) returning id`,
[title, targetCategory, targetWords, allWords, sanitizedSourceHtml]
);
const id = await DB.transaction(async (tx) => {
const insertResult = await tx.query(
`INSERT INTO click_exercises (title, category, target_words, all_words, source_html)
VALUES ($1, $2, $3, $4, $5) returning id`,
[title, targetCategory, targetWords, allWords, sanitizedSourceHtml]
);
await DB.pool(
`UPDATE exercises e
SET created_by = $1,
updated_by = $1
FROM click_to_exercises cte
WHERE cte.exercise_id = e.id
AND cte.click_id = $2`,
[request.user?.id ?? null, id.rows[0].id]
);
const clickExerciseId = insertResult.rows[0].id;
return Response.json({ id: id.rows[0].id }, { status: 201 });
for (const entry of normalizedFalseWordFeedbacks) {
await tx.query(
`INSERT INTO click_false_word_feedbacks
(click_exercise_id, slot_key, word_text, feedback)
VALUES ($1, $2, $3, $4)`,
[clickExerciseId, entry.slotKey, entry.wordText, entry.feedback]
);
}
await tx.query(
`UPDATE exercises e
SET created_by = $1,
updated_by = $1
FROM click_to_exercises cte
WHERE cte.exercise_id = e.id
AND cte.click_id = $2`,
[request.user?.id ?? null, clickExerciseId]
);
return clickExerciseId;
});
return Response.json({ id }, { status: 201 });
},
{
requireAdmin: true,
@@ -18,6 +18,13 @@ describe("create click_exercises API", () => {
targetCategory: "Verben",
targetWords: ["laufen", "springen", "schwimmen"],
allWords: ["Die", "Kinder", "laufen", "springen", "schwimmen"],
falseWordFeedbacks: [
{
slotKey: "w-0",
wordText: "Die",
feedback: "Das ist hier kein Verb.",
},
],
})
);
@@ -32,6 +39,20 @@ describe("create click_exercises API", () => {
[json.id]
);
expect(exercise.rows.length).toBe(1);
const falseWordFeedbacks = await DB.pool(
`SELECT slot_key, word_text, feedback
FROM click_false_word_feedbacks
WHERE click_exercise_id = $1`,
[json.id]
);
expect(falseWordFeedbacks.rows).toEqual([
{
slot_key: "w-0",
word_text: "Die",
feedback: "Das ist hier kein Verb.",
},
]);
});
it("should return 400 if required fields are missing", async () => {
const admin = await TestFactory.user({ is_admin: true });
@@ -18,6 +18,14 @@ export const GET = withAuth(async (request, { params }) => {
[click_id]
);
const falseWordFeedbacks = await DB.pool(
`SELECT slot_key, word_text, feedback
FROM click_false_word_feedbacks
WHERE click_exercise_id = $1
ORDER BY id ASC`,
[click_id]
);
if (exercise.rows.length === 0) {
return Response.json({ message: "Keine Übung gefunden." }, { status: 404 });
}
@@ -33,6 +41,7 @@ export const GET = withAuth(async (request, { params }) => {
return Response.json(
{
exercise: exercise.rows[0],
falseWordFeedbacks: falseWordFeedbacks.rows,
userAnswers: answer.rows[0],
},
{ status: 200 }
@@ -43,6 +52,7 @@ export const GET = withAuth(async (request, { params }) => {
return Response.json(
{
exercise: exercise.rows[0],
falseWordFeedbacks: falseWordFeedbacks.rows,
userAnswers: null,
},
{ status: 200 }
@@ -19,6 +19,13 @@ describe("click_exercises API", () => {
all_words: ["Die", "Kinder", "laufen", "springen", "schwimmen"],
});
await TestFactory.clickFalseWordFeedback({
click_exercise_id: exercise.id,
slot_key: "w-0",
word_text: "Die",
feedback: "Das ist hier kein Verb.",
});
// Insert a user answer for the exercise
await DB.pool(
`INSERT INTO click_answers (user_id, click_exercise_id, answer, target_words)
@@ -54,6 +61,13 @@ describe("click_exercises API", () => {
"springen",
"schwimmen",
]);
expect(json.falseWordFeedbacks).toEqual([
{
slot_key: "w-0",
word_text: "Die",
feedback: "Das ist hier kein Verb.",
},
]);
expect(json.userAnswers).toBeDefined();
expect(json.userAnswers.answer).toEqual(["laufen", "springen"]);
});
@@ -94,6 +108,7 @@ describe("click_exercises API", () => {
"und",
"langsam",
]);
expect(json.falseWordFeedbacks).toEqual([]);
expect(json.userAnswers).toBeNull(); // No user answers exist
});
+24 -1
View File
@@ -22,7 +22,30 @@ export const GET = withAuth(async () => {
e.created_at DESC
`);
return NextResponse.json(rows);
const feedbackRows = await DB.pool(
`SELECT click_exercise_id, slot_key, word_text, feedback
FROM click_false_word_feedbacks
ORDER BY id ASC`
);
const feedbackByExerciseId = new Map();
feedbackRows.rows.forEach((row) => {
if (!feedbackByExerciseId.has(row.click_exercise_id)) {
feedbackByExerciseId.set(row.click_exercise_id, []);
}
feedbackByExerciseId.get(row.click_exercise_id).push({
slot_key: row.slot_key,
word_text: row.word_text,
feedback: row.feedback,
});
});
return NextResponse.json(
rows.map((row) => ({
...row,
false_word_feedbacks: feedbackByExerciseId.get(row.click_id) || [],
}))
);
} catch (error) {
console.error("Error fetching click exercises:", error);
return NextResponse.json(
@@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
import { GET } from "./route";
import { useTestDatabase } from "@/backend/test/testdb";
import { useTestRequest } from "@/backend/test/mock-request";
import { DB } from "@/backend/db";
import { TestFactory } from "@/backend/test/testfactory";
describe("click_exercises API", () => {
@@ -28,6 +27,13 @@ describe("click_exercises API", () => {
all_words: ["Der", "Hund", "ist", "schnell", "und", "langsam"],
});
await TestFactory.clickFalseWordFeedback({
click_exercise_id: click_exercise2.id,
slot_key: "w-0",
word_text: "Der",
feedback: "Das ist kein Adjektiv.",
});
const response = await GET(mockGet("/api/exercises/click"));
expect(response).toBeDefined();
@@ -48,6 +54,13 @@ describe("click_exercises API", () => {
"und",
"langsam",
]);
expect(json[0].false_word_feedbacks).toEqual([
{
slot_key: "w-0",
word_text: "Der",
feedback: "Das ist kein Adjektiv.",
},
]);
expect(json[1].title).toBe("Verben identifizieren");
expect(json[1].category).toBe("Verben");
@@ -59,6 +72,7 @@ describe("click_exercises API", () => {
"springen",
"schwimmen",
]);
expect(json[1].false_word_feedbacks).toEqual([]);
});
it("should return an empty array if no click exercises exist", async () => {
@@ -0,0 +1,38 @@
.false-word-feedback-panel {
margin-top: var(--u-md);
padding: var(--u-sm);
border: 1px solid var(--bg3);
border-radius: var(--u-sm);
background: var(--bg2);
}
.false-word-feedback-heading {
margin: 0 0 var(--u-sm) 0;
}
.false-word-feedback-grid {
display: grid;
gap: var(--u-sm);
}
.false-word-feedback-item {
display: grid;
grid-template-columns: minmax(120px, 180px) minmax(320px, 1fr);
gap: var(--u-sm);
align-items: start;
}
.false-word-feedback-item-word {
font-weight: 700;
word-break: break-word;
}
.false-word-feedback-item-text {
line-height: 1.4;
}
@media (max-width: 900px) {
.false-word-feedback-item {
grid-template-columns: 1fr;
}
}
@@ -1,11 +1,12 @@
"use client";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import WordSelectionExercise from "@/components/ui/click/click.js";
import { Button } from "@/components/ui/button";
import AdminVisibleLastModified from "@/components/ui/admin-visible-last-modified";
import useQuery from "@/shared/hooks/useQuery";
import { useRequest } from "@/shared/hooks/useRequest";
import "./page.css";
export default function StudentExercisePage() {
const params = useParams();
@@ -21,6 +22,14 @@ export default function StudentExercisePage() {
const [isSubmitted, setIsSubmitted] = useState(false);
const [feedback, setFeedback] = useState("");
const [falseWordFeedbacks, setFalseWordFeedbacks] = useState([]);
useEffect(() => {
if (!isSubmitted) {
setFeedback("");
setFalseWordFeedbacks([]);
}
}, [isSubmitted]);
const exercise = data?.exercise || null;
@@ -34,8 +43,14 @@ export default function StudentExercisePage() {
}
};
const handleSubmit = async (selectedWords, score, feedbackMessage) => {
const handleSubmit = async (
selectedWords,
score,
feedbackMessage,
selectedFalseWordFeedbacks = []
) => {
setFeedback(feedbackMessage);
setFalseWordFeedbacks(selectedFalseWordFeedbacks);
setIsSubmitted(true);
// Save answers to the database
@@ -65,6 +80,7 @@ export default function StudentExercisePage() {
targetWords={exercise.target_words}
allWords={exercise.all_words}
sourceHtml={exercise.source_html}
falseWordFeedbacks={data.falseWordFeedbacks || []}
previousAnswers={data.userAnswers?.answer || []}
isPreviewMode={false}
onSubmit={handleSubmit} // Pass the submit handler as a prop
@@ -73,6 +89,29 @@ export default function StudentExercisePage() {
feedback={feedback} // Pass feedback as a prop
/>
{falseWordFeedbacks.length > 0 && (
<div className="false-word-feedback-panel">
<h3 className="false-word-feedback-heading">
Feedback zu falschen Wörtern
</h3>
<div className="false-word-feedback-grid">
{falseWordFeedbacks.map((entry) => (
<div
key={`${entry.slotKey}-${entry.feedback}`}
className="false-word-feedback-item"
>
<div className="false-word-feedback-item-word">
{entry.wordText || "Wort"}
</div>
<div className="false-word-feedback-item-text">
{entry.feedback}
</div>
</div>
))}
</div>
</div>
)}
<br />
<div>
<Button
+16
View File
@@ -186,6 +186,21 @@ const clickExercise = modelFactory(
async (_base) => {}
);
const clickFalseWordFeedback = modelFactory(
"click_false_word_feedbacks",
{
slot_key: "w-0",
word_text: faker.lorem.word(),
feedback: faker.lorem.sentence(),
},
async (base) => {
if (!base.click_exercise_id) {
const exercise = await clickExercise();
base.click_exercise_id = exercise.id;
}
}
);
const fillGapExercise = modelFactory(
"fill_gap_exercises",
{
@@ -257,6 +272,7 @@ export const TestFactory = {
multichoiceContent,
multichoiceOption,
clickExercise,
clickFalseWordFeedback,
fillGapExercise,
fillGapGap,
fillGapAnswer,
+67 -6
View File
@@ -295,11 +295,15 @@ const WordSelectionExercise = ({
targetWords,
allWords,
sourceHtml,
falseWordFeedbacks = [],
previousAnswers,
onSelectionChange,
previewSelectionKeys,
onPreviewSelectionChange,
previewGroupAdjacentSelection = true,
previewAutoSelectTargets = true,
previewHighlightTargets = true,
instructionText,
onSubmit,
isPreviewMode = false,
isSubmitted,
@@ -435,6 +439,28 @@ const WordSelectionExercise = ({
[targetSlotKeys]
);
const falseWordFeedbackMap = useMemo(() => {
const map = new Map();
(Array.isArray(falseWordFeedbacks) ? falseWordFeedbacks : []).forEach(
(entry) => {
const slotKey = entry?.slot_key || entry?.slotKey;
const feedbackText = entry?.feedback;
if (!slotKey || !feedbackText) {
return;
}
map.set(slotKey, {
slotKey,
wordText: entry?.word_text || entry?.wordText || "",
feedback: feedbackText,
});
}
);
return map;
}, [falseWordFeedbacks]);
const isControlledPreview =
isPreviewMode &&
Array.isArray(previewSelectionKeys) &&
@@ -445,18 +471,40 @@ const WordSelectionExercise = ({
: selectedSlotKeys;
useEffect(() => {
if (!isControlledPreview && isPreviewMode && targetSlotKeys.length > 0) {
if (
!isControlledPreview &&
isPreviewMode &&
previewAutoSelectTargets &&
targetSlotKeys.length > 0
) {
setSelectedSlotKeys(orderSlotKeys(targetSlotKeys, wordSlots));
}
}, [targetSlotKeys, isPreviewMode, wordSlots, isControlledPreview]);
}, [
targetSlotKeys,
isPreviewMode,
wordSlots,
isControlledPreview,
previewAutoSelectTargets,
]);
const displayedSelectedSet = useMemo(() => {
if (!isPreviewMode) {
return new Set(selectedSlotKeys);
}
return new Set([...targetSlotKeys, ...previewSelection]);
}, [isPreviewMode, selectedSlotKeys, targetSlotKeys, previewSelection]);
const selected = [...previewSelection];
if (previewHighlightTargets) {
selected.push(...targetSlotKeys);
}
return new Set(selected);
}, [
isPreviewMode,
selectedSlotKeys,
targetSlotKeys,
previewSelection,
previewHighlightTargets,
]);
const handleWordClick = (slotKey) => {
if (isSubmitted && !isPreviewMode) return;
@@ -631,7 +679,17 @@ const WordSelectionExercise = ({
: selectedSlotKeys.map((key) => slotToWord[key]).filter(Boolean);
}
onSubmit(selectedPayload, score, feedbackMessage);
const selectedFalseWordFeedbacks = selectedSlotKeys
.filter((slotKey) => !targetSlotKeySet.has(slotKey))
.map((slotKey) => falseWordFeedbackMap.get(slotKey))
.filter(Boolean);
onSubmit(
selectedPayload,
score,
feedbackMessage,
selectedFalseWordFeedbacks
);
};
const resetExercise = () => {
@@ -740,7 +798,10 @@ const WordSelectionExercise = ({
<Column gap="md">
<h1>{title}</h1>
<div className="click-instruction">
<i>{`Wähle alle ${targetCategory} aus dem untenstehenden Text aus.`}</i>
<i>
{instructionText ||
`Wähle alle ${targetCategory} aus dem untenstehenden Text aus.`}
</i>
</div>
{htmlSlotsData.slots.length > 0 ? (
<Container className="word-container click-source-html">