Add configurable instruction field across key exercise types (Free, Click, Multichoice, Lückentext, Satzmix)

This commit is contained in:
Erza Bytyci
2026-08-23 04:27:18 +00:00
parent f971f626f2
commit acf75eb8c9
38 changed files with 242 additions and 84 deletions
@@ -0,0 +1,2 @@
ALTER TABLE public.free_form_exercises
ADD COLUMN IF NOT EXISTS instruction_text text;
@@ -0,0 +1,2 @@
ALTER TABLE public.multichoice_exercises
ADD COLUMN IF NOT EXISTS instruction_text text;
@@ -0,0 +1,2 @@
ALTER TABLE public.fill_gap_exercises
ADD COLUMN IF NOT EXISTS instruction_text text;
@@ -0,0 +1,2 @@
ALTER TABLE public.jumbled_sentence_exercises
ADD COLUMN IF NOT EXISTS instruction_text text;
+8 -4
View File
@@ -421,7 +421,8 @@ CREATE TABLE public.fill_gap_exercises (
exercise_id bigint UNIQUE NOT NULL,
title text NOT NULL,
source_text text NOT NULL,
source_html text NOT NULL DEFAULT ''::text
source_html text NOT NULL DEFAULT ''::text,
instruction_text text
);
CREATE INDEX fill_gap_exercises_search_fts_idx ON public.fill_gap_exercises USING gin (to_tsvector('simple'::regconfig, ((((COALESCE(title, ''::text) || ' '::text) || COALESCE(source_text, ''::text)) || ' '::text) || COALESCE(source_html, ''::text))));
@@ -476,7 +477,8 @@ CREATE TABLE public.jumbled_sentence_exercises (
updated_at timestamp with time zone NOT NULL DEFAULT now(),
exercise_id bigint UNIQUE NOT NULL,
created_by bigint NOT NULL,
title text NOT NULL
title text NOT NULL,
instruction_text text
);
CREATE INDEX jumbled_sentence_exercises_search_fts_idx ON public.jumbled_sentence_exercises USING gin (to_tsvector('simple'::regconfig, COALESCE(title, ''::text)));
@@ -540,7 +542,8 @@ CREATE TABLE public.free_form_exercises (
updated_at timestamp with time zone NOT NULL DEFAULT now(),
exercise_id bigint NOT NULL,
question text,
title text NOT NULL
title text NOT NULL,
instruction_text text
);
CREATE INDEX free_form_exercises_search_fts_idx ON public.free_form_exercises USING gin (to_tsvector('simple'::regconfig, COALESCE(title, ''::text)));
@@ -752,7 +755,8 @@ CREATE TABLE public.multichoice_exercises (
title text NOT NULL,
exercise_description text,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
updated_at timestamp with time zone NOT NULL DEFAULT now(),
instruction_text text
);
CREATE INDEX multichoice_exercises_search_fts_idx ON public.multichoice_exercises USING gin (to_tsvector('simple'::regconfig, ((COALESCE(title, ''::text) || ' '::text) || COALESCE(exercise_description, ''::text))));
@@ -120,7 +120,7 @@ export default function CreateExercise() {
const [error, setError] = useState(null);
const [titleError, setTitleError] = useState(false);
const [title, setTitle] = useState("");
const [targetCategory, setTargetCategory] = useState("");
const [instructionText, setInstructionText] = useState("");
const [allWordsHtml, setAllWordsHtml] = useState("");
const [allWordsText, setAllWordsText] = useState("");
const [selectedWords, setSelectedWords] = useState([]);
@@ -159,7 +159,7 @@ export default function CreateExercise() {
: "";
setTitle(exerciseData.title || "");
setTargetCategory(exerciseData.category || "");
setInstructionText(exerciseData.category || "");
setAllWordsHtml(sourceHtml);
setAllWordsText(text);
setSelectedWords(
@@ -369,7 +369,7 @@ export default function CreateExercise() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
targetCategory,
instructionText,
targetWords: selectedWords,
allWords,
sourceHtml: allWordsHtml,
@@ -393,7 +393,7 @@ export default function CreateExercise() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
targetCategory,
instructionText,
targetWords: selectedWords,
allWords,
sourceHtml: allWordsHtml,
@@ -498,13 +498,13 @@ export default function CreateExercise() {
</Container>
<Container className="exercise-click click-block">
<label>Zielkategorie</label>
<label>Anweisung</label>
<input
className="click-input"
type="text"
value={targetCategory}
onChange={(e) => setTargetCategory(e.target.value)}
placeholder="Z. B. Verben, Substantive, Adjektive, etc."
value={instructionText}
onChange={(e) => setInstructionText(e.target.value)}
placeholder="Z. B. Markiere nur attributive Adjektive."
required
/>
</Container>
@@ -526,10 +526,6 @@ export default function CreateExercise() {
</form>
) : (
<div>
<p>
Klicken Sie auf die Wörter, um die richtigen auszuwählen (
{targetCategory}).
</p>
<p>
Wenn mehrere benachbarte ausgewählte Wörter als ein Element
behandelt werden sollen, klicken Sie auf "Als Element übernehmen".
@@ -540,7 +536,8 @@ export default function CreateExercise() {
<Container>
<WordSelectionExercise
title={title}
targetCategory={targetCategory}
targetCategory={instructionText}
instructionText={instructionText}
targetWords={selectedWords}
allWords={allWords}
sourceHtml={allWordsHtml}
@@ -576,7 +573,7 @@ export default function CreateExercise() {
<Container className="click-block">
<WordSelectionExercise
title={title}
targetCategory={targetCategory}
targetCategory={instructionText}
targetWords={[]}
allWords={allWords}
sourceHtml={allWordsHtml}
@@ -121,6 +121,7 @@ export default function CreateFillInTheGapExercisePage() {
const isEditMode = Boolean(exercise_id);
const [title, setTitle] = useState("");
const [instructionText, setInstructionText] = useState("");
const [textHtml, setTextHtml] = useState("");
const [text, setText] = useState("");
const [selectedIndices, setSelectedIndices] = useState([]);
@@ -174,6 +175,7 @@ export default function CreateFillInTheGapExercisePage() {
}
setTitle(exerciseData.title || "");
setInstructionText(exerciseData.instruction_text || "");
const sourceText = exerciseData.source_text || "";
setText(sourceText);
setTextHtml(
@@ -333,6 +335,7 @@ export default function CreateFillInTheGapExercisePage() {
try {
const payload = {
title,
instructionText,
text,
textHtml,
gaps,
@@ -400,6 +403,17 @@ export default function CreateFillInTheGapExercisePage() {
/>
</Container>
<Container className="fitg-block">
<label htmlFor="fitg-instruction">Anweisung</label>
<input
id="fitg-instruction"
className="fitg-input"
value={instructionText}
onChange={(event) => setInstructionText(event.target.value)}
placeholder="Optional: Hinweise für die Lernenden"
/>
</Container>
<Container className="fitg-block">
<label>Übungstext</label>
<Editor
@@ -14,6 +14,7 @@ export default function EditFreeFormExercisePage() {
const { exercise_id } = useParams();
const [title, setTitle] = useState("");
const [instructionText, setInstructionText] = useState("");
const [questions, setQuestions] = useState([]);
const [generalError, setGeneralError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -27,6 +28,7 @@ export default function EditFreeFormExercisePage() {
if (data.title) {
setTitle(data.title);
}
setInstructionText(data.instruction_text || "");
if (data.questions) {
console.log("Fetched questions:", data.questions);
console.log("Loaded full data for edit:", data);
@@ -138,7 +140,7 @@ export default function EditFreeFormExercisePage() {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title, questions }),
body: JSON.stringify({ title, instructionText, questions }),
}
);
@@ -194,6 +196,16 @@ export default function EditFreeFormExercisePage() {
/>
</div>
<div className="form-group">
<label>Anweisung</label>
<input
className="form-input"
value={instructionText}
onChange={(e) => setInstructionText(e.target.value)}
placeholder="Optional: Hinweise für die Lernenden"
/>
</div>
{questions.map((q, questionIndex) => (
<Column
key={questionIndex}
@@ -9,11 +9,13 @@ import "../multichoice/multichoice.css";
const defaultFormErrors = {
title: "",
instructionText: "",
questions: [],
};
export default function CreateFreeFormExercise() {
const [title, setTitle] = useState("");
const [instructionText, setInstructionText] = useState("");
const [questions, setQuestions] = useState([
{
question: "",
@@ -118,7 +120,7 @@ export default function CreateFreeFormExercise() {
const res = await makeRequest("/admin/exercises/free-form", {
method: "POST",
body: { title, questions },
body: { title, instructionText, questions },
});
if (res.status === 200) {
@@ -155,6 +157,7 @@ export default function CreateFreeFormExercise() {
const handleSuccessOk = () => {
setShowSuccessDialog(false);
setTitle("");
setInstructionText("");
setQuestions([
{
question: "",
@@ -205,6 +208,18 @@ export default function CreateFreeFormExercise() {
<span className="field-error-hint">{formErrors.title}</span>
)}
</div>
<div className="form-group">
<label>Anweisung</label>
<input
className={`form-input${formErrors.instructionText ? " input-error" : ""}`}
value={instructionText}
onChange={(e) => setInstructionText(e.target.value)}
placeholder="Optional: Hinweise für die Lernenden"
/>
{formErrors.instructionText && (
<span className="field-error-hint">{formErrors.instructionText}</span>
)}
</div>
{questions.map((q, questionIndex) => (
<Column
key={questionIndex}
@@ -32,6 +32,7 @@ export default function CreateJumbledSentenceExercise() {
const { exercise_id } = useParams();
const isEditMode = Boolean(exercise_id);
const [title, setTitle] = useState("");
const [instructionText, setInstructionText] = useState("");
const [sentences, setSentences] = useState([
{
sentence: "",
@@ -60,6 +61,7 @@ export default function CreateJumbledSentenceExercise() {
const loadedExercise = data.exercise;
setTitle(loadedExercise.title || "");
setInstructionText(loadedExercise.instruction_text || "");
setSentences(
(loadedExercise.sentences || []).length
? loadedExercise.sentences.map((item) => ({
@@ -271,6 +273,7 @@ export default function CreateJumbledSentenceExercise() {
const parsed = jumbledSentenceExerciseSchema.safeParse({
title,
instructionText,
sentences: normalizedSentences,
});
if (!parsed.success) {
@@ -289,7 +292,11 @@ export default function CreateJumbledSentenceExercise() {
const res = await fetch(endpoint, {
method: isEditMode ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, sentences: normalizedSentences }),
body: JSON.stringify({
title,
instructionText,
sentences: normalizedSentences,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error || "Fehler beim Speichern.");
@@ -334,6 +341,15 @@ export default function CreateJumbledSentenceExercise() {
required
/>
</label>
<label className={styles.fieldLabel}>
Anweisung:
<input
value={instructionText}
onChange={(e) => setInstructionText(e.target.value)}
className={styles.fieldInput}
placeholder="Optional: Hinweise für die Lernenden"
/>
</label>
<p className={styles.exerciseDescription}>
Geben Sie den Satz in der richtigen Reihenfolge und optional in
alternativen Reihenfolgen <br></br> mit denselben Elementen ein. Die
@@ -13,6 +13,7 @@ export default function CreateMultichoicePage() {
const isEditMode = Boolean(exercise_id);
const [title, setTitle] = useState("");
const [instructionText, setInstructionText] = useState("");
const [sections, setSections] = useState([
{ id: "1", type: "text", value: "Ich" },
@@ -48,6 +49,9 @@ export default function CreateMultichoicePage() {
}
setTitle(exerciseData.title || "");
setInstructionText(
exerciseData.instruction_text || exerciseData.exercise_description || ""
);
const mappedSections = (exerciseData.content || []).map((item) => {
if (item.content_type === "multichoice") {
@@ -183,6 +187,7 @@ export default function CreateMultichoicePage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
instructionText,
// --- FIX 2: REMOVED description from here ---
content: contentToSave,
}),
@@ -248,6 +253,15 @@ export default function CreateMultichoicePage() {
</span>
)}
</div>
<div className="form-group">
<label>Anweisung</label>
<input
className="form-input"
value={instructionText}
onChange={(e) => setInstructionText(e.target.value)}
placeholder="Optional: Hinweise für die Lernenden"
/>
</div>
<hr className="divider" />
{/* --- The Sentence Builder (Inline Flow) --- */}
@@ -11,8 +11,8 @@ function sanitizeHtml(html) {
return purify.sanitize(String(html || ""), { ADD_ATTR: ["target"] });
}
function validatePayload({ title, targetCategory, targetWords, allWords }) {
if (!title || !targetCategory || !targetWords || !allWords) {
function validatePayload({ title, instructionText, targetWords, allWords }) {
if (!title || !instructionText || !targetWords || !allWords) {
return { error: "Alle Felder sind erforderlich.", status: 400 };
}
@@ -23,9 +23,9 @@ function validatePayload({ title, targetCategory, targetWords, allWords }) {
};
}
if (targetCategory.length < 3 || targetCategory.length > 30) {
if (instructionText.length < 5 || instructionText.length > 200) {
return {
error: "Die Kategorie muss zwischen 3 und 30 Zeichen lang sein.",
error: "Die Anweisung muss zwischen 5 und 200 Zeichen lang sein.",
status: 422,
};
}
@@ -110,7 +110,17 @@ export const PUT = withAuth(
try {
const { click_id } = await params;
const body = await request.json();
const { title, targetCategory, targetWords, allWords, sourceHtml } = body;
const {
title,
instructionText,
targetCategory,
targetWords,
allWords,
sourceHtml,
} = body;
const exerciseInstruction = String(
instructionText ?? targetCategory ?? ""
).trim();
const sanitizedSourceHtml = sanitizeHtml(sourceHtml || "");
const falseWordFeedbacks = Array.isArray(body.falseWordFeedbacks)
? body.falseWordFeedbacks
@@ -124,7 +134,7 @@ export const PUT = withAuth(
const validationError = validatePayload({
title,
targetCategory,
instructionText: exerciseInstruction,
targetWords,
allWords,
});
@@ -176,7 +186,7 @@ export const PUT = withAuth(
WHERE id = $6`,
[
title,
targetCategory,
exerciseInstruction,
targetWords,
allWords,
sanitizedSourceHtml,
@@ -53,17 +53,21 @@ export const POST = withAuth(
const json = await request.json();
const {
title,
instructionText,
targetCategory,
targetWords,
allWords,
sourceHtml,
falseWordFeedbacks,
} = json;
const exerciseInstruction = String(
instructionText ?? targetCategory ?? ""
).trim();
const sanitizedSourceHtml = sanitizeHtml(sourceHtml || "");
const normalizedFalseWordFeedbacks =
normalizeFalseWordFeedbacks(falseWordFeedbacks);
if (!title || !targetCategory || !targetWords || !allWords) {
if (!title || !exerciseInstruction || !targetWords || !allWords) {
return Response.json(
{ error: "Alle Felder sind erforderlich." },
{ status: 400 }
@@ -75,9 +79,11 @@ export const POST = withAuth(
{ status: 422 }
);
}
if (targetCategory.length < 3 || targetCategory.length > 30) {
if (exerciseInstruction.length < 5 || exerciseInstruction.length > 200) {
return Response.json(
{ error: "Die Kategorie muss zwischen 3 und 30 Zeichen lang sein." },
{
error: "Die Anweisung muss zwischen 5 und 200 Zeichen lang sein.",
},
{ status: 422 }
);
}
@@ -122,7 +128,7 @@ export const POST = withAuth(
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]
[title, exerciseInstruction, targetWords, allWords, sanitizedSourceHtml]
);
const clickExerciseId = insertResult.rows[0].id;
@@ -111,7 +111,7 @@ describe("create click_exercises API", () => {
"Der Titel muss zwischen 3 und 50 Zeichen lang sein."
);
});
it("should return 422 if targetCategory is too short", async () => {
it("should return 422 if instruction text is too short", async () => {
const admin = await TestFactory.user({ is_admin: true });
const { mockPost } = useTestRequest(admin);
@@ -128,17 +128,17 @@ describe("create click_exercises API", () => {
expect(response.status).toBe(422);
const json = await response.json();
expect(json.error).toBe(
"Die Kategorie muss zwischen 3 und 30 Zeichen lang sein."
"Die Anweisung muss zwischen 5 und 200 Zeichen lang sein."
);
});
it("should return 422 if targetCategory is too long", async () => {
it("should return 422 if instruction text is too long", async () => {
const admin = await TestFactory.user({ is_admin: true });
const { mockPost } = useTestRequest(admin);
const response = await POST(
mockPost("api/exercises/create/click", {
title: "Verben identifizieren",
targetCategory: "a".repeat(31),
targetCategory: "a".repeat(201),
targetWords: ["laufen", "springen", "schwimmen"],
allWords: ["Die", "Kinder", "laufen", "springen", "schwimmen"],
})
@@ -148,7 +148,7 @@ describe("create click_exercises API", () => {
expect(response.status).toBe(422);
const json = await response.json();
expect(json.error).toBe(
"Die Kategorie muss zwischen 3 und 30 Zeichen lang sein."
"Die Anweisung muss zwischen 5 und 200 Zeichen lang sein."
);
});
it("should return 422 if targetWords is empty", async () => {
@@ -26,6 +26,7 @@ export const GET = withAuth(
fge.id,
fge.exercise_id,
fge.title,
fge.instruction_text,
fge.source_text,
fge.source_html,
e.updated_at AS last_modified_at,
@@ -110,7 +111,8 @@ export const PUT = withAuth(
);
}
const { title, text, textHtml, gaps } = parsed.data;
const { title, instructionText, text, textHtml, gaps } = parsed.data;
const normalizedInstructionText = String(instructionText || "").trim();
const normalizedText = normalizePlainText(
text || htmlToPlainText(textHtml || "")
);
@@ -130,9 +132,19 @@ export const PUT = withAuth(
await tx.query(
`UPDATE fill_gap_exercises
SET title = $1, source_text = $2, source_html = $3, updated_at = NOW()
WHERE id = $4`,
[title, normalizedText, sourceHtml, exercise_id]
SET title = $1,
instruction_text = $2,
source_text = $3,
source_html = $4,
updated_at = NOW()
WHERE id = $5`,
[
title,
normalizedInstructionText || null,
normalizedText,
sourceHtml,
exercise_id,
]
);
await tx.query(
@@ -19,7 +19,8 @@ function sanitizeHtml(html) {
export const POST = withAuth(
withInputValidation(fillGapCreateSchema, async (request) => {
const body = await request.json();
const { title, text, textHtml, gaps } = body;
const { title, instructionText, text, textHtml, gaps } = body;
const normalizedInstructionText = String(instructionText || "").trim();
const normalizedText = normalizePlainText(
text || htmlToPlainText(textHtml || "")
);
@@ -41,10 +42,16 @@ export const POST = withAuth(
const exerciseId = exerciseResult.rows[0].id;
const fillGapExerciseResult = await tx.query(
`INSERT INTO fill_gap_exercises (exercise_id, title, source_text, source_html)
VALUES ($1, $2, $3, $4)
`INSERT INTO fill_gap_exercises (exercise_id, title, instruction_text, source_text, source_html)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
[exerciseId, title, normalizedText, sourceHtml]
[
exerciseId,
title,
normalizedInstructionText || null,
normalizedText,
sourceHtml,
]
);
const fillGapExerciseId = fillGapExerciseResult.rows[0].id;
@@ -14,6 +14,7 @@ describe("POST /api/admin/exercises/fillinthegap", () => {
const payload = {
title: "Artikel im Kontext",
instructionText: "Setze in jede Lücke das passende Wort ein.",
text: "Ich gehe in die Schule und fahre mit dem Bus.",
gaps: [
{
@@ -45,6 +46,7 @@ describe("POST /api/admin/exercises/fillinthegap", () => {
);
expect(exerciseRows.rows.length).toBe(1);
expect(exerciseRows.rows[0].title).toBe(payload.title);
expect(exerciseRows.rows[0].instruction_text).toBe(payload.instructionText);
const gapRows = await DB.pool(
"SELECT * FROM fill_gap_gaps WHERE fill_gap_exercise_id = $1",
@@ -13,6 +13,7 @@ export const GET = withAuth(
SELECT
ffe.id,
ffe.title,
ffe.instruction_text,
ffe.exercise_id,
e.updated_at AS last_modified_at,
COALESCE(NULLIF(u.username, ''), u.email) AS last_modified_by
@@ -23,12 +24,8 @@ export const GET = withAuth(
`,
[exercise_id]
);
const parentExerciseId = rows[0].exercise_id;
const createdBy = rows[0].created_by;
if (exerciseRows.length === 0) {
if (!canDeleteOwnedContent(request.user, createdBy)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return NextResponse.json(
{ error: "Exercise not found" },
{ status: 404 }
@@ -99,7 +96,8 @@ export const PUT = withAuth(
try {
const { exercise_id } = await params;
const body = await request.json();
const { title, questions } = body;
const { title, instructionText, questions } = body;
const normalizedInstructionText = String(instructionText || "").trim();
const hasValidAnswerBalance = questions.every(
(q) =>
@@ -131,10 +129,10 @@ export const PUT = withAuth(
await tx.query(
`
UPDATE free_form_exercises
SET title = $1, updated_at = NOW()
WHERE id = $2
SET title = $1, instruction_text = $2, updated_at = NOW()
WHERE id = $3
`,
[title, exercise_id]
[title, normalizedInstructionText || null, exercise_id]
);
await tx.query(
@@ -7,7 +7,8 @@ import { NextResponse } from "next/server";
export const POST = withAuth(
withInputValidation(freeFormExerciseSchema, async (req) => {
const body = await req.json();
const { title, questions } = body;
const { title, instructionText, questions } = body;
const normalizedInstructionText = String(instructionText || "").trim();
const hasValidAnswerBalance = questions.every(
(q) =>
@@ -41,11 +42,11 @@ export const POST = withAuth(
const exerciseId = exercise.rows[0].id;
const freeFormExercise = await tx.query(
`
INSERT INTO free_form_exercises (exercise_id, title)
VALUES ($1, $2)
INSERT INTO free_form_exercises (exercise_id, title, instruction_text)
VALUES ($1, $2, $3)
RETURNING id
`,
[exerciseId, title]
[exerciseId, title, normalizedInstructionText || null]
);
const freeFormExerciseId = freeFormExercise.rows[0].id;
@@ -14,6 +14,7 @@ describe("POST /api/auth/admin/exercises/free-form", () => {
const validInput = {
title: "European Capitals",
instructionText: "Beantworte jede Frage in einem vollständigen Satz.",
questions: [
{
question: "What is the capital of France?",
@@ -54,6 +55,9 @@ describe("POST /api/auth/admin/exercises/free-form", () => {
);
expect(freeFormExerciseResult.rows.length).toBe(1);
expect(freeFormExerciseResult.rows[0].title).toBe(validInput.title);
expect(freeFormExerciseResult.rows[0].instruction_text).toBe(
validInput.instructionText
);
const questionRows = await DB.pool(
"SELECT * FROM free_form_questions WHERE free_form_exercise_id = $1 ORDER BY question_order ASC",
@@ -11,6 +11,7 @@ export const GET = withAuth(async (request, { params }) => {
`SELECT
jse.id,
jse.title,
jse.instruction_text,
e.updated_at AS last_modified_at,
COALESCE(NULLIF(u.username, ''), u.email) AS last_modified_by
FROM jumbled_sentence_exercises jse
@@ -57,12 +58,13 @@ export const PUT = withAuth(
{ status: 400 }
);
}
const { title, sentences } = parseResult.data;
const { title, instructionText, sentences } = parseResult.data;
const normalizedInstructionText = String(instructionText || "").trim();
try {
await DB.transaction(async (client) => {
await client.query(
`UPDATE jumbled_sentence_exercises SET title = $1, updated_at = NOW() WHERE id = $2`,
[title, id]
`UPDATE jumbled_sentence_exercises SET title = $1, instruction_text = $2, updated_at = NOW() WHERE id = $3`,
[title, normalizedInstructionText || null, id]
);
await client.query(
`UPDATE exercises
@@ -18,7 +18,8 @@ export const POST = withAuth(
{ status: 400 }
);
}
const { title, sentences } = parseResult.data;
const { title, instructionText, sentences } = parseResult.data;
const normalizedInstructionText = String(instructionText || "").trim();
const created_by = request.user.id;
try {
const result = await DB.transaction(async (client) => {
@@ -28,8 +29,8 @@ export const POST = withAuth(
);
const exercise_id = exerciseRes.rows[0].id;
const jumbledRes = await client.query(
`INSERT INTO jumbled_sentence_exercises (created_at, updated_at, exercise_id, created_by, title) VALUES (NOW(), NOW(), $1, $2, $3) RETURNING id`,
[exercise_id, created_by, title]
`INSERT INTO jumbled_sentence_exercises (created_at, updated_at, exercise_id, created_by, title, instruction_text) VALUES (NOW(), NOW(), $1, $2, $3, $4) RETURNING id`,
[exercise_id, created_by, title, normalizedInstructionText || null]
);
const jumbled_id = jumbledRes.rows[0].id;
for (const s of sentences) {
@@ -60,7 +61,7 @@ export const GET = withAuth(
async () => {
try {
const res = await DB.pool(
`SELECT id, title FROM jumbled_sentence_exercises ORDER BY id DESC`
`SELECT id, title, instruction_text FROM jumbled_sentence_exercises ORDER BY id DESC`
);
return NextResponse.json({ exercises: res.rows });
} catch (err) {
@@ -14,6 +14,7 @@ describe("admin jumbled sentence exercises API", () => {
const payload = {
title: "Satzreihenfolge A1",
instructionText: "Ordnen Sie die Elemente zu einem korrekten Satz.",
sentences: [
{
sentence: "Ich\nlerne jeden Tag\nDeutsch",
@@ -36,11 +37,14 @@ describe("admin jumbled sentence exercises API", () => {
expect(json.id).toBeDefined();
const createdExercise = await DB.pool(
"SELECT id, exercise_id, title, created_by FROM jumbled_sentence_exercises WHERE id = $1",
"SELECT id, exercise_id, title, instruction_text, created_by FROM jumbled_sentence_exercises WHERE id = $1",
[json.id]
);
expect(createdExercise.rows.length).toBe(1);
expect(createdExercise.rows[0].title).toBe(payload.title);
expect(createdExercise.rows[0].instruction_text).toBe(
payload.instructionText
);
expect(createdExercise.rows[0].created_by).toBe(admin.id);
const linkedExercise = await DB.pool(
@@ -105,14 +109,16 @@ describe("admin jumbled sentence exercises API", () => {
);
await DB.pool(
"INSERT INTO jumbled_sentence_exercises (created_at, updated_at, exercise_id, created_by, title) VALUES (NOW(), NOW(), $1, $2, $3), (NOW(), NOW(), $4, $5, $6)",
"INSERT INTO jumbled_sentence_exercises (created_at, updated_at, exercise_id, created_by, title, instruction_text) VALUES (NOW(), NOW(), $1, $2, $3, $4), (NOW(), NOW(), $5, $6, $7, $8)",
[
baseExerciseA.rows[0].id,
admin.id,
"Titel Eins",
"Anweisung Eins",
baseExerciseB.rows[0].id,
admin.id,
"Titel Zwei",
"Anweisung Zwei",
]
);
@@ -129,5 +135,9 @@ describe("admin jumbled sentence exercises API", () => {
const titles = json.exercises.map((item) => item.title);
expect(titles).toContain("Titel Eins");
expect(titles).toContain("Titel Zwei");
const instructions = json.exercises.map((item) => item.instruction_text);
expect(instructions).toContain("Anweisung Eins");
expect(instructions).toContain("Anweisung Zwei");
});
});
@@ -151,7 +151,8 @@ export const PUT = withAuth(
try {
const { exercise_id } = await params;
const body = await request.json();
const { title, content } = body;
const { title, instructionText, content } = body;
const normalizedInstructionText = String(instructionText || "").trim();
if (!title) {
return NextResponse.json(
@@ -188,9 +189,11 @@ export const PUT = withAuth(
await tx.query(
`UPDATE multichoice_exercises
SET title = $1, updated_at = NOW()
WHERE id = $2`,
[title, exercise_id]
SET title = $1,
instruction_text = $2,
updated_at = NOW()
WHERE id = $3`,
[title, normalizedInstructionText || null, exercise_id]
);
await tx.query(
@@ -6,7 +6,8 @@ export const POST = withAuth(
try {
const json = await request.json();
// We extract description but ignore it for the DB save
const { title, content } = json;
const { title, instructionText, content } = json;
const normalizedInstructionText = String(instructionText || "").trim();
// Validation
if (!title || !Array.isArray(content) || content.length === 0) {
@@ -47,10 +48,10 @@ export const POST = withAuth(
// 2. Insert into multichoice_exercises
// FIX: Removed 'description' here too. Only saving exercise_id and title.
const mcRes = await DB.pool(
`INSERT INTO multichoice_exercises (exercise_id, title)
VALUES ($1, $2)
`INSERT INTO multichoice_exercises (exercise_id, title, instruction_text)
VALUES ($1, $2, $3)
RETURNING id`,
[exercise_id, title]
[exercise_id, title, normalizedInstructionText || null]
);
const multichoice_id = mcRes.rows[0].id;
@@ -14,6 +14,7 @@ describe("POST /api/admin/exercises/multichoice", () => {
const validInput = {
title: "Where is Paris located?",
instructionText: "Wähle für jede Lücke die passende Option aus.",
description: "Geography question",
content: [
{
@@ -48,6 +49,9 @@ describe("POST /api/admin/exercises/multichoice", () => {
[exerciseId]
);
expect(multichoiceExerciseResult.rows.length).toBe(1);
expect(multichoiceExerciseResult.rows[0].instruction_text).toBe(
validInput.instructionText
);
const content = await DB.pool(
"SELECT * FROM multichoice_content WHERE multichoice_exercise_id = $1",
@@ -7,7 +7,7 @@ export const GET = withAuth(async (request, { params }) => {
const { exercise_id } = await params;
const { rows: exerciseRows } = await DB.pool(
`SELECT id, title, source_text, source_html
`SELECT id, title, instruction_text, source_text, source_html
FROM fill_gap_exercises
WHERE id = $1`,
[exercise_id]
@@ -11,6 +11,7 @@ export const GET = withAuth(async (request, { params }) => {
SELECT
id,
title,
instruction_text,
exercise_id
FROM free_form_exercises
WHERE id = $1
@@ -7,7 +7,7 @@ export const GET = withAuth(async (request, { params }) => {
const { id } = await params;
try {
const exRes = await DB.pool(
`SELECT id, title FROM jumbled_sentence_exercises WHERE id = $1`,
`SELECT id, title, instruction_text FROM jumbled_sentence_exercises WHERE id = $1`,
[id]
);
if (!exRes.rows.length) {
@@ -77,6 +77,7 @@ export default function StudentExercisePage() {
<WordSelectionExercise
title={exercise.title}
targetCategory={exercise.category}
instructionText={exercise.category}
targetWords={exercise.target_words}
allWords={exercise.all_words}
sourceHtml={exercise.source_html}
@@ -205,10 +205,11 @@ export default function FillInTheGapExercisePage() {
return (
<Container maxW="900px" m="0 auto" p="md">
<Column gap="md">
<h2>{exercise.title}</h2>
<AdminVisibleLastModified
endpoint={`/admin/exercises/fillinthegap/${exercise_id}`}
/>
<h2>{exercise.title}</h2>
{exercise.instruction_text && <p>{exercise.instruction_text}</p>}
{!!exercise.source_html && (
<Container
@@ -210,10 +210,11 @@ export default function FreeFormExercisePage() {
return (
<Container maxW="800px" m="0 auto" p="md">
<Container mb="lg">
<h2>{exercise.title}</h2>
<AdminVisibleLastModified
endpoint={`/admin/exercises/free-form/${exercise_id}`}
/>
<h2>{exercise.title}</h2>
{exercise.instruction_text && <p>{exercise.instruction_text}</p>}
</Container>
<Container mb="xl" p="md" bg="var(--bg2)" br="md">
@@ -286,10 +286,11 @@ export default function JumbledSentenceExercisePage() {
return (
<Column gap="lg">
<h2>{exercise.title}</h2>
<AdminVisibleLastModified
endpoint={`/admin/exercises/jumbled-sentence/${id}`}
/>
<h2>{exercise.title}</h2>
{exercise.instruction_text && <p>{exercise.instruction_text}</p>}
{exercise.sentences.map((_, sentenceIdx) => (
<Column
+1 -1
View File
@@ -642,7 +642,7 @@ const WordSelectionExercise = ({
let feedbackMessage = "";
if (score === 100) {
feedbackMessage =
"Perfekt! Du hast alle " + targetCategory + " korrekt identifiziert!";
"Perfekt! Du hast alle richtigen Wörter korrekt identifiziert!";
} else if (score >= 90) {
feedbackMessage = "Fast Perfekt! Punktzahl: " + score + "%";
} else if (score >= 50) {
@@ -158,13 +158,15 @@ export default function MultichoicePage({ exerciseId }) {
// Check if all answers are correct
const allCorrect = isSubmitted && checkedAnswers.every(Boolean);
const instructionText =
shuffledExerciseData.instruction_text ||
shuffledExerciseData.exercise_description ||
"";
return (
<div className="exercise-container">
<h2 className="task-title">{shuffledExerciseData.title}</h2>
<p className="task-description">
{shuffledExerciseData.exercise_description}
</p>
{instructionText && <p className="task-description">{instructionText}</p>}
<RenderText
exerciseData={shuffledExerciseData.content}
@@ -47,6 +47,11 @@ export const fillGapCreateSchema = z.object({
.min(3, { message: "Title must be at least 3 characters" })
.max(120, { message: "Title must be at most 120 characters" })
),
instructionText: trimmed(
z
.string()
.max(500, { message: "Instruction must be at most 500 characters" })
).optional(),
text: normalized(z.string().min(1, { message: "Exercise text is required" })),
textHtml: z.string().optional(),
gaps: z
@@ -18,6 +18,10 @@ export const questionSchema = z.object({
export const freeFormExerciseSchema = z.object({
title: z.string().min(1, { message: "Ein Titel ist erforderlich." }),
instructionText: z
.string()
.max(500, { message: "Die Anweisung ist zu lang." })
.optional(),
questions: z
.array(questionSchema)
.min(1, { message: "Mindestens eine Frage ist erforderlich." }),
@@ -117,6 +117,7 @@ export const jumbledSentenceSchema = z
export const jumbledSentenceExerciseSchema = z.object({
title: trimmed(z.string().min(3, { message: "Titel ist erforderlich." })),
instructionText: trimmed(z.string().max(500)).optional(),
sentences: z
.array(jumbledSentenceSchema)
.min(1, { message: "Mindestens ein Satz ist erforderlich." }),