refactor: PairForm — single type dropdown, 4 variants, carry-over behavior

- answer_type is now a single value (text/yes_no/question/word)
- Dropdown replaces checkboxes, per-type field sections
- Text: only positive statement
- Yes/No: optional question + answer picker
- Question: question* + positive* + optional negative
- Word: optional question + word pickers
- After save: texts/words carry over, type resets for next pair
- Fix: closed missing div in PairsPanel scroll container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 16:11:02 +02:00
parent 99ca1c58c2
commit 0c816d3f2d

View File

@@ -156,16 +156,22 @@ function WordTag({ word, onRemove }) {
} }
// ─── PairForm ───────────────────────────────────────────────────────────────── // ─── PairForm ─────────────────────────────────────────────────────────────────
const PAIR_TYPES = [
{ value: 'text', label: 'Text', hint: 'Nur positives Statement' },
{ value: 'yes_no', label: 'Ja / Nein', hint: 'Frage + Ja/Nein Antwort' },
{ value: 'question', label: 'Frage', hint: 'Frage + Positiv + Negativ' },
{ value: 'word', label: 'Wort', hint: 'Frage + Positive/Negative Wörter' },
];
function PairForm({ objectId, onPairSaved }) { function PairForm({ objectId, onPairSaved }) {
const lang = getUserLang(); const lang = getUserLang();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [savedFlash, setSavedFlash] = useState(false);
// Type checkboxes // Single type dropdown
const [typeText, setTypeText] = useState(false); const [type, setType] = useState('');
const [typeYesNo, setTypeYesNo] = useState(false);
const [typeWord, setTypeWord] = useState(false);
// Text fields // Text fields — carried over between saves
const [question, setQuestion] = useState(''); const [question, setQuestion] = useState('');
const [positive, setPositive] = useState(''); const [positive, setPositive] = useState('');
const [negative, setNegative] = useState(''); const [negative, setNegative] = useState('');
@@ -174,16 +180,17 @@ function PairForm({ objectId, onPairSaved }) {
const [creatingWord, setCreatingWord] = useState(false); const [creatingWord, setCreatingWord] = useState(false);
// Yes/No answer // Yes/No answer
const [yesNoAnswer, setYesNoAnswer] = useState(null); // null | true | false const [yesNoAnswer, setYesNoAnswer] = useState(null);
// Word pickers // Word pickers — carried over between saves
const [positiveWords, setPositiveWords] = useState([]); const [positiveWords, setPositiveWords] = useState([]);
const [negativeWords, setNegativeWords] = useState([]); const [negativeWords, setNegativeWords] = useState([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Auto-detect words from text fields (only when Text type active) // Auto-detect words in sentence fields
const allText = typeText ? `${question} ${positive} ${negative}` : ''; const needsWordDetection = type === 'text' || type === 'question';
const allText = needsWordDetection ? `${question} ${positive} ${negative}` : '';
useEffect(() => { useEffect(() => {
if (!allText.trim()) { setWordMap({}); return; } if (!allText.trim()) { setWordMap({}); return; }
const t = setTimeout(async () => { const t = setTimeout(async () => {
@@ -212,40 +219,32 @@ function PairForm({ objectId, onPairSaved }) {
finally { setCreatingWord(false); } finally { setCreatingWord(false); }
} }
// Validation function canSave() {
const atLeastOne = typeText || typeYesNo || typeWord; if (!type) return false;
const textOk = !typeText || positive.trim().length > 0; if (type === 'text') return positive.trim().length > 0;
const negativeOk = !negative.trim() || (question.trim().length > 0 && positive.trim().length > 0); if (type === 'yes_no') return true;
const canSave = atLeastOne && textOk && negativeOk; if (type === 'question') return question.trim().length > 0 && positive.trim().length > 0;
if (type === 'word') return positiveWords.length > 0 || negativeWords.length > 0;
return false;
}
function validationHint() { function validationHint() {
if (!atLeastOne) return 'Wähle mindestens einen Typ (Text / Ja·Nein / Wort).'; if (!type) return 'Bitte einen Typ wählen.';
if (!textOk) return 'Text: Positive Aussage ist Pflicht.'; if (type === 'text' && !positive.trim()) return 'Statement ist Pflicht.';
if (!negativeOk) return 'Negative Aussage erfordert Frage + Positive Aussage.'; if (type === 'question' && !question.trim()) return 'Frage ist Pflicht.';
if (type === 'question' && !positive.trim()) return 'Positive Aussage ist Pflicht.';
if (type === 'word' && !positiveWords.length && !negativeWords.length)
return 'Mindestens ein Wort erforderlich.';
return ''; return '';
} }
function reset() {
setTypeText(false); setTypeYesNo(false); setTypeWord(false);
setQuestion(''); setPositive(''); setNegative('');
setWordMap({}); setYesNoAnswer(null);
setPositiveWords([]); setNegativeWords([]);
setShowForm(false);
}
async function handleSave() { async function handleSave() {
if (!canSave) return; if (!canSave()) return;
setSaving(true); setSaving(true);
try { try {
const answerTypes = [ // Question record — for yes_no, question, word (if text filled)
...(typeText ? ['text'] : []),
...(typeYesNo ? ['yes_no'] : []),
...(typeWord ? ['word'] : []),
];
// Question (only if Text + question text filled)
let questionId = null; let questionId = null;
if (typeText && question.trim()) { if (type !== 'text' && question.trim()) {
const q = await apiPost('/questions', { const q = await apiPost('/questions', {
[`sentence_${lang}`]: withPlaceholders(question, wordMap), [`sentence_${lang}`]: withPlaceholders(question, wordMap),
status: 'draft', status: 'draft',
@@ -254,87 +253,97 @@ function PairForm({ objectId, onPairSaved }) {
} }
// Positive statement // Positive statement
let posStmtId = null;
if (type === 'text' || type === 'question') {
const posBody = { status: 'draft' }; const posBody = { status: 'draft' };
if (typeText && positive.trim()) if (positive.trim())
posBody[`positive_sentence_${lang}`] = withPlaceholders(positive, wordMap); posBody[`positive_sentence_${lang}`] = withPlaceholders(positive, wordMap);
if (typeYesNo && yesNoAnswer !== null) const s = await apiPost('/statements', posBody);
posBody.answer = yesNoAnswer; posStmtId = s.id;
} else if (type === 'yes_no') {
const posStmt = await apiPost('/statements', posBody); const posBody = { status: 'draft' };
if (yesNoAnswer !== null) posBody.answer = yesNoAnswer;
// Link positive words to positive statement const s = await apiPost('/statements', posBody);
if (typeWord && positiveWords.length) posStmtId = s.id;
await Promise.all(positiveWords.map(w => apiLink(`/statements/${posStmt.id}/positive-words/${w.id}`))); } else if (type === 'word' && positiveWords.length) {
const s = await apiPost('/statements', { status: 'draft' });
// Negative statement (Text + negative text, OR Word + negativeWords) posStmtId = s.id;
let negStmtId = null; await Promise.all(positiveWords.map(w => apiLink(`/statements/${posStmtId}/positive-words/${w.id}`)));
const hasNegText = typeText && negative.trim();
const hasNegWords = typeWord && negativeWords.length > 0;
if (hasNegText || hasNegWords) {
const negBody = { status: 'draft' };
if (hasNegText)
negBody[`negative_sentence_${lang}`] = withPlaceholders(negative, wordMap);
const negStmt = await apiPost('/statements', negBody);
negStmtId = negStmt.id;
if (hasNegWords)
await Promise.all(negativeWords.map(w => apiLink(`/statements/${negStmt.id}/negative-words/${w.id}`)));
} }
// Pair // Negative statement
let negStmtId = null;
if (type === 'question' && negative.trim()) {
const negBody = { status: 'draft', [`negative_sentence_${lang}`]: withPlaceholders(negative, wordMap) };
const s = await apiPost('/statements', negBody);
negStmtId = s.id;
} else if (type === 'word' && negativeWords.length) {
const s = await apiPost('/statements', { status: 'draft' });
negStmtId = s.id;
await Promise.all(negativeWords.map(w => apiLink(`/statements/${negStmtId}/negative-words/${w.id}`)));
}
// Create pair
const pair = await apiPost('/pairs', { const pair = await apiPost('/pairs', {
answer_type: answerTypes, answer_type: type,
question_id: questionId, question_id: questionId,
positive_statement_id: posStmt.id, positive_statement_id: posStmtId,
negative_statement_id: negStmtId, negative_statement_id: negStmtId,
status: 'draft', status: 'draft',
}); });
await apiLink(`/objects/${objectId}/pairs/${pair.id}`); await apiLink(`/objects/${objectId}/pairs/${pair.id}`);
reset(); // Carry-over: keep all text/word values, only reset type to prompt re-selection
onPairSaved({ ...pair, question: questionId ? { [`sentence_${lang}`]: question } : null, setType('');
positive_statement: posStmt, negative_statement: null }); setYesNoAnswer(null);
setSavedFlash(true);
setTimeout(() => setSavedFlash(false), 2000);
onPairSaved(pair);
} catch (e) { alert('Fehler: ' + e.message); } } catch (e) { alert('Fehler: ' + e.message); }
finally { setSaving(false); } finally { setSaving(false); }
} }
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{/* Toggle button */}
<button <button
onClick={() => setShowForm(s => !s)} onClick={() => setShowForm(s => !s)}
className="w-full py-1.5 text-xs font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors" className="w-full py-1.5 text-xs font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors"
> >
{showForm ? '✕ Abbrechen' : '+ Add new pair'} {showForm ? '✕ Abbrechen' : '+ Pair hinzufügen'}
</button> </button>
{showForm && ( {showForm && (
<div className="border border-indigo-100 rounded-xl p-3 bg-indigo-50/30 space-y-3"> <div className="border border-indigo-100 rounded-xl p-3 bg-indigo-50/30 space-y-3">
{/* ── Type checkboxes ── */} {/* ── Saved flash ── */}
<div className="flex items-center gap-4"> {savedFlash && (
{[['typeText', typeText, setTypeText, 'Text'], <div className="text-xs text-green-700 bg-green-50 border border-green-200 rounded-lg px-3 py-1.5 font-medium">
['typeYesNo', typeYesNo, setTypeYesNo, 'Ja / Nein'], Pair gespeichert Texte übernommen
['typeWord', typeWord, setTypeWord, 'Wort'], </div>
].map(([key, val, setter, label]) => ( )}
<label key={key} className="flex items-center gap-1.5 text-xs font-medium text-slate-700 cursor-pointer select-none">
<input {/* ── Type dropdown ── */}
type="checkbox" <div>
checked={val} <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Typ</label>
onChange={e => setter(e.target.checked)} <select
className="w-3.5 h-3.5 accent-indigo-600" value={type}
/> onChange={e => setType(e.target.value)}
{label} className="w-full border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white"
</label> >
<option value=""> Typ wählen </option>
{PAIR_TYPES.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label} {opt.hint}</option>
))} ))}
</select>
</div> </div>
{/* ── Text section ── */} {/* ── Per-type fields ── */}
{typeText && ( {type && (
<div className="space-y-2.5 pt-1 border-t border-indigo-100"> <div className="space-y-2.5 pt-1 border-t border-indigo-100">
{/* "Als Wort erstellen" banner */}
{selection && ( {/* Word creation banner (text/question) */}
{(type === 'text' || type === 'question') && selection && (
<div className="flex items-center gap-2 bg-white border border-indigo-200 rounded-lg px-2.5 py-1.5"> <div className="flex items-center gap-2 bg-white border border-indigo-200 rounded-lg px-2.5 py-1.5">
<span className="text-xs text-slate-600 flex-1 truncate"> <span className="text-xs text-slate-600 flex-1 truncate">
<strong className="text-indigo-700">{selection}</strong>" <strong className="text-indigo-700">{selection}</strong>"
@@ -346,6 +355,57 @@ function PairForm({ objectId, onPairSaved }) {
</div> </div>
)} )}
{/* TEXT — only positive statement */}
{type === 'text' && (
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
Statement <span className="text-red-400">*</span>
</label>
<HighlightedTextarea value={positive} onChange={setPositive} wordMap={wordMap}
rows={2} placeholder="Das ist ein Hund." onSelectionChange={setSelection} />
</div>
)}
{/* YES_NO — question + answer picker */}
{type === 'yes_no' && (
<>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
Frage <span className="text-slate-400 font-normal normal-case">(optional)</span>
</label>
<HighlightedTextarea value={question} onChange={setQuestion} wordMap={wordMap}
rows={2} placeholder="Ist das ein Hund?" onSelectionChange={setSelection} />
</div>
<div>
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Antwort</p>
<div className="flex gap-2">
{[
[null, '— Offen', 'bg-slate-100 text-slate-600 border-slate-200'],
[true, '✓ Ja', 'bg-green-50 text-green-700 border-green-300'],
[false, '✗ Nein', 'bg-red-50 text-red-600 border-red-300'],
].map(([val, label, cls]) => (
<button key={String(val)} onClick={() => setYesNoAnswer(val)}
className={`flex-1 py-1.5 text-xs font-medium rounded-lg border transition-all
${yesNoAnswer === val
? `${cls} ring-2 ring-offset-1 ring-indigo-400`
: 'bg-white text-slate-500 border-slate-200 hover:border-slate-300'}`}
>{label}</button>
))}
</div>
</div>
</>
)}
{/* QUESTION — question + positive + negative */}
{type === 'question' && (
<>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
Frage <span className="text-red-400">*</span>
</label>
<HighlightedTextarea value={question} onChange={setQuestion} wordMap={wordMap}
rows={2} placeholder="Was ist das?" onSelectionChange={setSelection} />
</div>
<div> <div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1"> <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
Positive Aussage <span className="text-red-400">*</span> Positive Aussage <span className="text-red-400">*</span>
@@ -353,63 +413,26 @@ function PairForm({ objectId, onPairSaved }) {
<HighlightedTextarea value={positive} onChange={setPositive} wordMap={wordMap} <HighlightedTextarea value={positive} onChange={setPositive} wordMap={wordMap}
rows={2} placeholder="Das ist ein Hund." onSelectionChange={setSelection} /> rows={2} placeholder="Das ist ein Hund." onSelectionChange={setSelection} />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1"> <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
Frage <span className="text-slate-300">(optional)</span> Negative Aussage <span className="text-slate-400 font-normal normal-case">(optional)</span>
</label>
<HighlightedTextarea value={negative} onChange={setNegative} wordMap={wordMap}
rows={2} placeholder="Das ist keine Katze." onSelectionChange={setSelection} />
</div>
</>
)}
{/* WORD — question + positive/negative word pickers */}
{type === 'word' && (
<>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
Frage <span className="text-slate-400 font-normal normal-case">(optional)</span>
</label> </label>
<HighlightedTextarea value={question} onChange={setQuestion} wordMap={wordMap} <HighlightedTextarea value={question} onChange={setQuestion} wordMap={wordMap}
rows={2} placeholder="Was ist das?" onSelectionChange={setSelection} /> rows={2} placeholder="Was ist das?" onSelectionChange={setSelection} />
</div> </div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
Negative Aussage
<span className="text-slate-300 ml-1">(optional — braucht Frage + Positiv)</span>
</label>
<HighlightedTextarea value={negative} onChange={setNegative} wordMap={wordMap}
rows={2} placeholder="Das ist keine Katze." onSelectionChange={setSelection} />
{negative.trim() && !negativeOk && (
<p className="text-xs text-red-500 mt-0.5">Frage + Positive Aussage erforderlich.</p>
)}
</div>
{Object.keys(wordMap).length > 0 && (
<div className="flex flex-wrap gap-1">
<span className="text-xs text-slate-400 w-full">Erkannte Wörter:</span>
{Object.entries(wordMap).map(([title, w]) => (
<span key={w.id} className="text-xs bg-indigo-100 text-indigo-700 rounded px-1.5 py-0.5">{title}</span>
))}
</div>
)}
</div>
)}
{/* ── Ja/Nein section ── */}
{typeYesNo && (
<div className="pt-2 border-t border-indigo-100">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Antwort</p>
<div className="flex gap-2">
{[
[null, '— Offen', 'bg-slate-100 text-slate-600 border-slate-200'],
[true, '✓ Ja', 'bg-green-50 text-green-700 border-green-300'],
[false, '✗ Nein', 'bg-red-50 text-red-600 border-red-300'],
].map(([val, label, cls]) => (
<button key={String(val)}
onClick={() => setYesNoAnswer(val)}
className={`flex-1 py-1.5 text-xs font-medium rounded-lg border transition-all
${yesNoAnswer === val ? `${cls} ring-2 ring-offset-1 ring-indigo-400` : 'bg-white text-slate-500 border-slate-200 hover:border-slate-300'}`}
>
{label}
</button>
))}
</div>
</div>
)}
{/* ── Wort section ── */}
{typeWord && (
<div className="pt-2 border-t border-indigo-100 space-y-2.5">
<div> <div>
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Positive Wörter</p> <p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Positive Wörter</p>
<div className="flex flex-wrap gap-1 mb-1.5"> <div className="flex flex-wrap gap-1 mb-1.5">
@@ -423,7 +446,6 @@ function PairForm({ objectId, onPairSaved }) {
placeholder="Positives Wort suchen" placeholder="Positives Wort suchen"
/> />
</div> </div>
<div> <div>
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Negative Wörter</p> <p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Negative Wörter</p>
<div className="flex flex-wrap gap-1 mb-1.5"> <div className="flex flex-wrap gap-1 mb-1.5">
@@ -437,15 +459,29 @@ function PairForm({ objectId, onPairSaved }) {
placeholder="Negatives Wort suchen" placeholder="Negatives Wort suchen"
/> />
</div> </div>
</>
)}
{/* Detected words indicator */}
{(type === 'text' || type === 'question') && Object.keys(wordMap).length > 0 && (
<div className="flex flex-wrap gap-1">
<span className="text-xs text-slate-400 w-full">Erkannte Wörter:</span>
{Object.entries(wordMap).map(([title, w]) => (
<span key={w.id} className="text-xs bg-indigo-100 text-indigo-700 rounded px-1.5 py-0.5">{title}</span>
))}
</div>
)}
</div> </div>
)} )}
{/* ── Save ── */} {/* ── Save ── */}
<div className="pt-2 border-t border-indigo-100 space-y-1.5"> <div className="pt-2 border-t border-indigo-100 space-y-1.5">
{!canSave && <p className="text-xs text-amber-600">{validationHint()}</p>} {type && !canSave() && (
<p className="text-xs text-amber-600">{validationHint()}</p>
)}
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving || !canSave} disabled={saving || !canSave()}
className="w-full py-2 text-xs font-medium rounded-lg bg-green-600 hover:bg-green-700 disabled:opacity-40 text-white transition-colors" className="w-full py-2 text-xs font-medium rounded-lg bg-green-600 hover:bg-green-700 disabled:opacity-40 text-white transition-colors"
> >
{saving ? 'Speichern…' : '✓ Pair speichern'} {saving ? 'Speichern…' : '✓ Pair speichern'}
@@ -528,6 +564,7 @@ function PairsPanel({ selectedObject, objectPairs, loadingPairs, onPairSaved })
</div> </div>
))} ))}
</div> </div>
</div>
</aside> </aside>
); );
} }