import { useMemo, useState } from 'react'; import { ArrowUp, ArrowDown, ArrowUpDown, X, Search, ChevronRight, ChevronDown, Lock, Unlock } from 'lucide-react'; import Button from '../../ui/Button'; const TYPES = ['concept', 'role', 'process']; const RELEVANCE = ['core', 'standard', 'peripheral', 'exclude']; const SORTABLE = ['label', 'type', 'learning_relevance', 'theme', 'complexity_weight', 'difficulty', 'relations']; const GROUP_OPTIONS = [ { value: 'none', label: 'No grouping' }, { value: 'type', label: 'Type' }, { value: 'learning_relevance', label: 'Relevance' }, { value: 'theme', label: 'Theme' }, { value: 'difficulty', label: 'Difficulty' }, ]; const RELEVANCE_RANK = { core: 0, standard: 1, peripheral: 2, exclude: 3 }; const DIFFICULTY_RANK = { beginner: 0, intermediate: 1, advanced: 2 }; function compareValues(a, b, key) { if (key === 'learning_relevance') { return (RELEVANCE_RANK[a] ?? 99) - (RELEVANCE_RANK[b] ?? 99); } if (key === 'difficulty') { return (DIFFICULTY_RANK[a] ?? 99) - (DIFFICULTY_RANK[b] ?? 99); } if (typeof a === 'number' && typeof b === 'number') return a - b; return String(a ?? '').localeCompare(String(b ?? ''), undefined, { sensitivity: 'base' }); } function SortIcon({ active, dir }) { if (!active) return ; return dir === 'asc' ? : ; } /** * Table view of the knowledge graph. * * Supports sort (per column), grouping (per key), free-text label filter, * multi-row selection, and bulk-change of type / learning_relevance / lock. * * Persistence is delegated to onBulkUpdate(ids, patch). The parent (KnowledgeGraph) * provides this from useGraphData.bulkUpdateTopics. */ export default function GraphTable({ topics, relations, onBulkUpdate, onSelectNode, }) { const [sortKey, setSortKey] = useState('label'); const [sortDir, setSortDir] = useState('asc'); const [groupBy, setGroupBy] = useState('none'); const [query, setQuery] = useState(''); const [selectedIds, setSelectedIds] = useState(() => new Set()); const [collapsed, setCollapsed] = useState(() => new Set()); // Bulk-action form state const [bulkType, setBulkType] = useState(''); const [bulkRelevance, setBulkRelevance] = useState(''); const [bulkLockOnRelevance, setBulkLockOnRelevance] = useState(true); const [isApplying, setIsApplying] = useState(false); const [feedback, setFeedback] = useState(null); // Pre-compute relation counts per topic so the column is cheap to render. const relationCount = useMemo(() => { const counts = new Map(); for (const r of relations) { counts.set(r.source, (counts.get(r.source) || 0) + 1); counts.set(r.target, (counts.get(r.target) || 0) + 1); } return counts; }, [relations]); // Filter + sort const filtered = useMemo(() => { const q = query.trim().toLowerCase(); const rows = topics .map(t => ({ ...t, learning_relevance: t.learning_relevance || 'standard', type: t.type || 'concept', difficulty: t.difficulty || 'intermediate', complexity_weight: t.complexity_weight ?? 3, theme: t.theme || '', relations: relationCount.get(t.id) || 0, })) .filter(t => !q || t.label?.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q)); const sorted = [...rows].sort((a, b) => { const cmp = compareValues(a[sortKey], b[sortKey], sortKey); return sortDir === 'asc' ? cmp : -cmp; }); return sorted; }, [topics, relationCount, query, sortKey, sortDir]); // Group rows (flat array if groupBy === 'none') const groups = useMemo(() => { if (groupBy === 'none') return [{ key: '__all__', label: null, rows: filtered }]; const map = new Map(); for (const t of filtered) { const k = t[groupBy] || '—'; if (!map.has(k)) map.set(k, []); map.get(k).push(t); } return [...map.entries()] .sort(([a], [b]) => compareValues(a, b, groupBy)) .map(([key, rows]) => ({ key, label: String(key), rows })); }, [filtered, groupBy]); // ── Selection helpers ───────────────────────────────────────────────────── const allVisibleIds = useMemo(() => filtered.map(t => t.id), [filtered]); const visibleSelected = useMemo( () => allVisibleIds.filter(id => selectedIds.has(id)).length, [allVisibleIds, selectedIds], ); const allVisibleChecked = visibleSelected === allVisibleIds.length && allVisibleIds.length > 0; const someVisibleChecked = visibleSelected > 0 && !allVisibleChecked; const toggleOne = (id) => { setSelectedIds(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); }; const toggleAllVisible = () => { setSelectedIds(prev => { const next = new Set(prev); if (allVisibleChecked) { for (const id of allVisibleIds) next.delete(id); } else { for (const id of allVisibleIds) next.add(id); } return next; }); }; const toggleGroup = (groupRows) => { setSelectedIds(prev => { const next = new Set(prev); const ids = groupRows.map(r => r.id); const allOn = ids.every(id => next.has(id)); for (const id of ids) (allOn ? next.delete(id) : next.add(id)); return next; }); }; const clearSelection = () => setSelectedIds(new Set()); // ── Sorting ─────────────────────────────────────────────────────────────── const onSort = (key) => { if (!SORTABLE.includes(key)) return; if (sortKey === key) { setSortDir(d => (d === 'asc' ? 'desc' : 'asc')); } else { setSortKey(key); setSortDir('asc'); } }; // ── Bulk actions ────────────────────────────────────────────────────────── const runBulk = async (patch, label) => { if (selectedIds.size === 0) return; setIsApplying(true); setFeedback(null); try { const n = await onBulkUpdate([...selectedIds], patch); setFeedback(`${label} — ${n} topic${n === 1 ? '' : 's'} updated.`); } catch (e) { setFeedback(`Failed: ${e.message || 'unknown error'}`); } finally { setIsApplying(false); } }; const applyBulkType = () => { if (!bulkType) return; runBulk({ type: bulkType }, `Type → ${bulkType}`); }; const applyBulkRelevance = () => { if (!bulkRelevance) return; const patch = { learning_relevance: bulkRelevance }; if (bulkLockOnRelevance) patch.relevance_locked = true; runBulk(patch, `Relevance → ${bulkRelevance}${bulkLockOnRelevance ? ' (locked)' : ''}`); }; const applyLock = (locked) => { runBulk({ relevance_locked: locked }, locked ? 'Locked relevance' : 'Unlocked relevance'); }; // ── Render ──────────────────────────────────────────────────────────────── const totalSel = selectedIds.size; const isCollapsed = (key) => collapsed.has(key); const toggleCollapse = (key) => { setCollapsed(prev => { const next = new Set(prev); next.has(key) ? next.delete(key) : next.add(key); return next; }); }; return (
{/* Toolbar */}
setQuery(e.target.value)} placeholder="Filter by label or description…" className="pl-7 pr-3 py-1.5 text-sm border border-bg-warm rounded-[var(--r-sm)] bg-paper w-64" />
{filtered.length} of {topics.length} topics {totalSel > 0 && <> · {totalSel} selected}
{/* Bulk action bar */} {totalSel > 0 && (
Bulk: {/* Type */}
{/* Relevance */}
{/* Lock toggles */} {feedback && ( {feedback} )}
)} {/* Table */}
{[ ['label', 'Label'], ['type', 'Type'], ['learning_relevance', 'Relevance'], ['theme', 'Theme'], ['difficulty', 'Difficulty'], ['complexity_weight', 'Weight'], ['relations', 'Rel.'], ].map(([key, label]) => ( ))} {groups.map(group => ( toggleCollapse(group.key)} onToggleGroup={() => toggleGroup(group.rows)} selectedIds={selectedIds} onToggleOne={toggleOne} onSelectNode={onSelectNode} /> ))} {filtered.length === 0 && ( )}
{ if (el) el.indeterminate = someVisibleChecked; }} onChange={toggleAllVisible} className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal" title={allVisibleChecked ? 'Deselect all visible' : 'Select all visible'} /> onSort(key)} className="px-3 py-2 text-xs uppercase tracking-wider text-fg-muted cursor-pointer select-none hover:text-teal" > {label} Lock
No topics match the current filter.
); } function Group({ group, groupBy, collapsed, onToggleCollapse, onToggleGroup, selectedIds, onToggleOne, onSelectNode, }) { const groupHeader = groupBy !== 'none' && group.label != null; const groupSelectedCount = group.rows.filter(r => selectedIds.has(r.id)).length; const allGroupSelected = groupSelectedCount === group.rows.length && group.rows.length > 0; const someGroupSelected = groupSelectedCount > 0 && !allGroupSelected; return ( <> {groupHeader && ( { if (el) el.indeterminate = someGroupSelected; }} onChange={onToggleGroup} className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal" /> )} {!collapsed && group.rows.map(t => ( onToggleOne(t.id)} onSelectNode={onSelectNode} /> ))} ); } function Row({ topic, checked, onToggle, onSelectNode }) { return ( {topic.type} {topic.learning_relevance} {topic.theme || '—'} {topic.difficulty} {topic.complexity_weight} {topic.relations} {topic.relevance_locked ? : } ); }