Add relation manager: link/unlink records directly in modal

- RelationManager component: shows linked items as removable tags,
  live search to find and add new links (×-button to unlink)
- tables.js: full fetchRelated config with linkEndpoint + searchEndpoint
  for words↔pictures, words↔categories, objects↔words, objects↔pictures
- api.js: add apiLink, apiUnlink, apiDelete helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 22:43:48 +02:00
parent a8ff541117
commit a02705734d
3 changed files with 202 additions and 49 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from 'react';
import { apiPatch, apiFetchOne } from '../lib/api';
import { apiPatch, apiFetchOne, apiFetch, apiLink, apiUnlink } from '../lib/api';
import { STATUS_COLORS } from '../lib/tables';
const READ_ONLY_FIELDS = new Set(['id', 'created_at', 'updated_at']);
@@ -104,12 +104,143 @@ function EditableField({ fieldKey, value, fieldDef, onChange }) {
);
}
function RelationManager({ recordId, rel }) {
const [items, setItems] = useState(undefined); // undefined = loading
const [search, setSearch] = useState('');
const [searchResults, setSearchResults] = useState(null);
const [searching, setSearching] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [error, setError] = useState('');
// Load linked items
useEffect(() => {
setItems(undefined);
apiFetchOne(rel.endpoint(recordId))
.then(data => setItems(Array.isArray(data) ? data : []))
.catch(() => setItems(null));
}, [recordId, rel]);
// Search available items
useEffect(() => {
if (!showSearch) return;
if (!search.trim()) { setSearchResults([]); return; }
const timer = setTimeout(() => {
setSearching(true);
apiFetch(`${rel.searchEndpoint}?limit=20&offset=0`)
.then(data => {
const arr = Array.isArray(data) ? data : [];
const q = search.toLowerCase();
const linked = new Set((items || []).map(i => i.id));
setSearchResults(
arr.filter(r => rel.searchLabel(r).toLowerCase().includes(q) && !linked.has(r.id))
);
})
.catch(() => setSearchResults([]))
.finally(() => setSearching(false));
}, 250);
return () => clearTimeout(timer);
}, [search, showSearch, items, rel]);
async function handleLink(targetId) {
try {
await apiLink(rel.linkEndpoint(recordId, targetId));
// refresh
const data = await apiFetchOne(rel.endpoint(recordId));
setItems(Array.isArray(data) ? data : []);
setSearch('');
setSearchResults([]);
setShowSearch(false);
} catch (e) { setError(e.message); }
}
async function handleUnlink(targetId) {
try {
await apiUnlink(rel.linkEndpoint(recordId, targetId));
setItems(prev => (prev || []).filter(i => i.id !== targetId));
} catch (e) { setError(e.message); }
}
return (
<section>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest">{rel.label}</h3>
<button
onClick={() => { setShowSearch(s => !s); setSearch(''); setSearchResults([]); }}
className="text-xs text-indigo-600 hover:text-indigo-800 font-medium flex items-center gap-1"
>
{showSearch ? '✕ Schließen' : '+ Verknüpfen'}
</button>
</div>
{/* Search box */}
{showSearch && (
<div className="mb-2">
<input
autoFocus
type="text"
placeholder={`${rel.label} suchen…`}
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
{searching && <p className="text-xs text-slate-400 mt-1">Suche</p>}
{searchResults && searchResults.length === 0 && search && !searching && (
<p className="text-xs text-slate-400 mt-1">Keine Ergebnisse</p>
)}
{searchResults && searchResults.length > 0 && (
<div className="mt-1 border border-slate-200 rounded-lg overflow-hidden max-h-36 overflow-y-auto">
{searchResults.map(r => (
<button
key={r.id}
onClick={() => handleLink(r.id)}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-indigo-50 hover:text-indigo-700 transition-colors border-b border-slate-100 last:border-0"
>
{rel.searchLabel(r)}
</button>
))}
</div>
)}
</div>
)}
{/* Linked items */}
<div className="bg-slate-50 rounded-lg p-3 min-h-[40px]">
{items === undefined && (
<div className="flex gap-1">
{[1,2,3].map(i => <div key={i} className="h-6 w-16 bg-slate-200 rounded animate-pulse" />)}
</div>
)}
{items === null && <span className="text-xs text-red-500">Fehler beim Laden</span>}
{Array.isArray(items) && items.length === 0 && (
<span className="text-sm text-slate-400">Keine verknüpften Einträge</span>
)}
{Array.isArray(items) && items.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{items.map(item => (
<span key={item.id} className="group flex items-center gap-1 text-xs bg-indigo-50 text-indigo-700 rounded-full pl-2.5 pr-1 py-1 font-medium">
{rel.display(item)}
<button
onClick={() => handleUnlink(item.id)}
className="text-indigo-400 hover:text-red-500 hover:bg-red-50 rounded-full w-4 h-4 flex items-center justify-center transition-colors"
title="Verknüpfung entfernen"
>
×
</button>
</span>
))}
</div>
)}
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
</section>
);
}
export default function RecordModal({ record, meta, onClose, onSaved }) {
const [values, setValues] = useState({});
const [dirty, setDirty] = useState({});
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState('');
const [related, setRelated] = useState({});
// Init values from record
useEffect(() => {
@@ -117,22 +248,8 @@ export default function RecordModal({ record, meta, onClose, onSaved }) {
setValues({ ...record });
setDirty({});
setSaveError('');
setRelated({});
}, [record]);
// Fetch related data (e.g. pictures → words)
useEffect(() => {
if (!record || !meta.fetchRelated?.length) return;
meta.fetchRelated.forEach(rel => {
apiFetchOne(rel.endpoint(record.id))
.then(data => {
const arr = Array.isArray(data) ? data : [];
setRelated(prev => ({ ...prev, [rel.key]: arr }));
})
.catch(() => setRelated(prev => ({ ...prev, [rel.key]: null })));
});
}, [record, meta]);
const handleChange = useCallback((key, val) => {
setValues(prev => ({ ...prev, [key]: val }));
setDirty(prev => ({ ...prev, [key]: true }));
@@ -249,37 +366,9 @@ export default function RecordModal({ record, meta, onClose, onSaved }) {
</section>
)}
{/* Related data (fetched separately) */}
{/* Related data with link/unlink */}
{meta.fetchRelated?.map(rel => (
<section key={rel.key}>
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">
{rel.label}
</h3>
<div className="bg-slate-50 rounded-lg p-3">
{related[rel.key] === undefined && (
<div className="flex gap-1">
{[1,2,3].map(i => (
<div key={i} className="h-6 w-16 bg-slate-200 rounded animate-pulse" />
))}
</div>
)}
{related[rel.key] === null && (
<span className="text-xs text-red-500">Fehler beim Laden</span>
)}
{Array.isArray(related[rel.key]) && related[rel.key].length === 0 && (
<span className="text-sm text-slate-400">Keine Einträge</span>
)}
{Array.isArray(related[rel.key]) && related[rel.key].length > 0 && (
<div className="flex flex-wrap gap-1.5">
{related[rel.key].map((item, i) => (
<span key={item.id || i} className="text-xs bg-indigo-50 text-indigo-700 rounded-full px-2.5 py-1 font-medium">
{rel.display(item)}
</span>
))}
</div>
)}
</div>
</section>
<RelationManager key={rel.key} recordId={record.id} rel={rel} />
))}
{/* Read-only metadata */}