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:
@@ -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 */}
|
||||
|
||||
@@ -73,6 +73,18 @@ export async function apiFetchOne(path) {
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function apiDelete(endpoint, id) {
|
||||
return apiFetch(`${endpoint}/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function apiLink(path) {
|
||||
return apiFetch(path, { method: 'POST', body: '{}' });
|
||||
}
|
||||
|
||||
export async function apiUnlink(path) {
|
||||
return apiFetch(path, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// Multipart upload — does NOT set Content-Type (browser sets boundary automatically)
|
||||
export async function apiUpload(path, formData) {
|
||||
const token = getToken();
|
||||
|
||||
@@ -10,10 +10,32 @@ export const TABLES = {
|
||||
editableFields: {
|
||||
titel_de: { type: 'text' },
|
||||
titel_en: { type: 'text' },
|
||||
titel_sv: { type: 'text' },
|
||||
status: { type: 'select', options: ['published', 'blocked', 'draft', 'requested', 'translated'] },
|
||||
difficulty_level:{ type: 'number', min: 1, max: 10 },
|
||||
},
|
||||
fetchRelated: [],
|
||||
fetchRelated: [
|
||||
{
|
||||
key: 'pictures',
|
||||
label: 'Bilder',
|
||||
endpoint: id => `/words/${id}/pictures`,
|
||||
display: p => p.design || p.id,
|
||||
targetTable: 'pictures',
|
||||
linkEndpoint: (id, targetId) => `/words/${id}/pictures/${targetId}`,
|
||||
searchEndpoint: '/pictures',
|
||||
searchLabel: p => p.design || p.id,
|
||||
},
|
||||
{
|
||||
key: 'categories',
|
||||
label: 'Kategorien',
|
||||
endpoint: id => `/words/${id}/categories`,
|
||||
display: c => c.name_de || c.id,
|
||||
targetTable: 'categories',
|
||||
linkEndpoint: (id, targetId) => `/words/${id}/categories/${targetId}`,
|
||||
searchEndpoint: '/categories',
|
||||
searchLabel: c => c.name_de || c.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
pictures: {
|
||||
label: 'Bilder',
|
||||
@@ -30,7 +52,16 @@ export const TABLES = {
|
||||
blurhash: { type: 'text' },
|
||||
},
|
||||
fetchRelated: [
|
||||
{ key: 'words', label: 'Wörter', endpoint: id => `/pictures/${id}/words`, display: w => w.titel_de || w.id },
|
||||
{
|
||||
key: 'words',
|
||||
label: 'Wörter',
|
||||
endpoint: id => `/pictures/${id}/words`,
|
||||
display: w => w.titel_de || w.id,
|
||||
targetTable: 'words',
|
||||
linkEndpoint: (id, targetId) => `/pictures/${id}/words/${targetId}`,
|
||||
searchEndpoint: '/words',
|
||||
searchLabel: w => w.titel_de || w.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
objects: {
|
||||
@@ -45,7 +76,28 @@ export const TABLES = {
|
||||
notes: { type: 'textarea' },
|
||||
status: { type: 'select', options: ['published', 'blocked', 'draft'] },
|
||||
},
|
||||
fetchRelated: [],
|
||||
fetchRelated: [
|
||||
{
|
||||
key: 'words',
|
||||
label: 'Wörter',
|
||||
endpoint: id => `/objects/${id}/words`,
|
||||
display: w => w.titel_de || w.id,
|
||||
targetTable: 'words',
|
||||
linkEndpoint: (id, targetId) => `/objects/${id}/words/${targetId}`,
|
||||
searchEndpoint: '/words',
|
||||
searchLabel: w => w.titel_de || w.id,
|
||||
},
|
||||
{
|
||||
key: 'pictures',
|
||||
label: 'Bilder',
|
||||
endpoint: id => `/objects/${id}/pictures`,
|
||||
display: p => p.design || p.id,
|
||||
targetTable: 'pictures',
|
||||
linkEndpoint: (id, targetId) => `/objects/${id}/pictures/${targetId}`,
|
||||
searchEndpoint: '/pictures',
|
||||
searchLabel: p => p.design || p.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
pairs: {
|
||||
label: 'Pairs',
|
||||
|
||||
Reference in New Issue
Block a user