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 { 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';
|
import { STATUS_COLORS } from '../lib/tables';
|
||||||
|
|
||||||
const READ_ONLY_FIELDS = new Set(['id', 'created_at', 'updated_at']);
|
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 }) {
|
export default function RecordModal({ record, meta, onClose, onSaved }) {
|
||||||
const [values, setValues] = useState({});
|
const [values, setValues] = useState({});
|
||||||
const [dirty, setDirty] = useState({});
|
const [dirty, setDirty] = useState({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState('');
|
const [saveError, setSaveError] = useState('');
|
||||||
const [related, setRelated] = useState({});
|
|
||||||
|
|
||||||
// Init values from record
|
// Init values from record
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,22 +248,8 @@ export default function RecordModal({ record, meta, onClose, onSaved }) {
|
|||||||
setValues({ ...record });
|
setValues({ ...record });
|
||||||
setDirty({});
|
setDirty({});
|
||||||
setSaveError('');
|
setSaveError('');
|
||||||
setRelated({});
|
|
||||||
}, [record]);
|
}, [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) => {
|
const handleChange = useCallback((key, val) => {
|
||||||
setValues(prev => ({ ...prev, [key]: val }));
|
setValues(prev => ({ ...prev, [key]: val }));
|
||||||
setDirty(prev => ({ ...prev, [key]: true }));
|
setDirty(prev => ({ ...prev, [key]: true }));
|
||||||
@@ -249,37 +366,9 @@ export default function RecordModal({ record, meta, onClose, onSaved }) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Related data (fetched separately) */}
|
{/* Related data with link/unlink */}
|
||||||
{meta.fetchRelated?.map(rel => (
|
{meta.fetchRelated?.map(rel => (
|
||||||
<section key={rel.key}>
|
<RelationManager key={rel.key} recordId={record.id} rel={rel} />
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Read-only metadata */}
|
{/* Read-only metadata */}
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ export async function apiFetchOne(path) {
|
|||||||
return apiFetch(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)
|
// Multipart upload — does NOT set Content-Type (browser sets boundary automatically)
|
||||||
export async function apiUpload(path, formData) {
|
export async function apiUpload(path, formData) {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
|||||||
@@ -10,10 +10,32 @@ export const TABLES = {
|
|||||||
editableFields: {
|
editableFields: {
|
||||||
titel_de: { type: 'text' },
|
titel_de: { type: 'text' },
|
||||||
titel_en: { type: 'text' },
|
titel_en: { type: 'text' },
|
||||||
|
titel_sv: { type: 'text' },
|
||||||
status: { type: 'select', options: ['published', 'blocked', 'draft', 'requested', 'translated'] },
|
status: { type: 'select', options: ['published', 'blocked', 'draft', 'requested', 'translated'] },
|
||||||
difficulty_level:{ type: 'number', min: 1, max: 10 },
|
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: {
|
pictures: {
|
||||||
label: 'Bilder',
|
label: 'Bilder',
|
||||||
@@ -30,7 +52,16 @@ export const TABLES = {
|
|||||||
blurhash: { type: 'text' },
|
blurhash: { type: 'text' },
|
||||||
},
|
},
|
||||||
fetchRelated: [
|
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: {
|
objects: {
|
||||||
@@ -45,7 +76,28 @@ export const TABLES = {
|
|||||||
notes: { type: 'textarea' },
|
notes: { type: 'textarea' },
|
||||||
status: { type: 'select', options: ['published', 'blocked', 'draft'] },
|
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: {
|
pairs: {
|
||||||
label: 'Pairs',
|
label: 'Pairs',
|
||||||
|
|||||||
Reference in New Issue
Block a user