From eb08c4ad96a7fb5c8872ef0136b76c9e1424aabb Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Wed, 3 Jun 2026 17:08:51 +0200 Subject: [PATCH] feat: knowledge graph table view met sort, group en bulk edit Voegt een Graph/Table toggle toe aan de admin Knowledge Graph. De tabel ondersteunt sorteren per kolom, groeperen op type/relevance/theme/difficulty, filteren op label, multi-select en bulk-wijzigen van type, learning_relevance en relevance_locked. Closes #10 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/admin/KnowledgeGraph.jsx | 67 +++- src/components/admin/graph/GraphTable.jsx | 466 ++++++++++++++++++++++ src/hooks/useGraphData.js | 18 + 3 files changed, 540 insertions(+), 11 deletions(-) create mode 100644 src/components/admin/graph/GraphTable.jsx diff --git a/src/components/admin/KnowledgeGraph.jsx b/src/components/admin/KnowledgeGraph.jsx index c907040..35dcd79 100644 --- a/src/components/admin/KnowledgeGraph.jsx +++ b/src/components/admin/KnowledgeGraph.jsx @@ -3,9 +3,11 @@ import * as d3 from 'd3'; import * as db from '../../lib/db'; import { callLLM } from '../../lib/llm'; import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools'; +import { Network, Table2 } from 'lucide-react'; import { useGraphData } from '../../hooks/useGraphData'; import GraphControls from './graph/GraphControls'; import NodeDetailPanel from './graph/NodeDetailPanel'; +import GraphTable from './graph/GraphTable'; // ── Edge visual style per relation type ──────────────────────────────────────── // stroke — line colour @@ -72,10 +74,11 @@ const KnowledgeGraph = () => { const [isAnalyzing, setIsAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); const [isRestoring, setIsRestoring] = useState(false); + const [viewMode, setViewMode] = useState('graph'); // 'graph' | 'table' const { topics, relations, snapshotMeta, - reload, updateTopic, deleteTopic, addRelation, removeRelation, bulkSave, restoreSnapshot, + reload, updateTopic, bulkUpdateTopics, deleteTopic, addRelation, removeRelation, bulkSave, restoreSnapshot, } = useGraphData(); // ── Canvas sizing ──────────────────────────────────────────────────────────── @@ -94,6 +97,7 @@ const KnowledgeGraph = () => { // ── D3 force graph ─────────────────────────────────────────────────────────── useEffect(() => { + if (viewMode !== 'graph') return; if (!svgRef.current || topics.length === 0) return; const { width, height } = dimensions; @@ -236,7 +240,7 @@ const KnowledgeGraph = () => { }); return () => simulation.stop(); - }, [dimensions, topics, relations, showExcludeNodes]); + }, [dimensions, topics, relations, showExcludeNodes, viewMode]); // ── Pan/zoom canvas to a node ──────────────────────────────────────────────── @@ -396,17 +400,58 @@ const KnowledgeGraph = () => { return (
- {/* D3 canvas */} -
- {topics.length === 0 ? ( -
- No knowledge graph data yet. Upload source material in the Sources tab first. + {/* Main view: graph canvas or table */} +
+ {/* View-mode toggle */} +
+ + +
+ + {viewMode === 'graph' ? ( +
+ {topics.length === 0 ? ( +
+ No knowledge graph data yet. Upload source material in the Sources tab first. +
+ ) : ( + + )}
) : ( - +
+ {topics.length === 0 ? ( +
+ No knowledge graph data yet. Upload source material in the Sources tab first. +
+ ) : ( + + )} +
)}
diff --git a/src/components/admin/graph/GraphTable.jsx b/src/components/admin/graph/GraphTable.jsx new file mode 100644 index 0000000..92037bf --- /dev/null +++ b/src/components/admin/graph/GraphTable.jsx @@ -0,0 +1,466 @@ +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 + ? + : } + + + ); +} diff --git a/src/hooks/useGraphData.js b/src/hooks/useGraphData.js index 56bac46..4b6e5f5 100644 --- a/src/hooks/useGraphData.js +++ b/src/hooks/useGraphData.js @@ -104,6 +104,23 @@ export function useGraphData() { return true; }, []); + /** + * Apply the same patch to many topics in one call. `ids` is the set of + * topic IDs to update; `patch` is the partial-topic to merge into each one + * (e.g. `{ type: 'process' }` or `{ learning_relevance: 'core', relevance_locked: true }`). + * + * Persists each topic via db.upsertTopic in parallel and mirrors into state. + * Returns the number of topics actually written (selected ∩ existing). + */ + const bulkUpdateTopics = useCallback(async (ids, patch) => { + const idSet = new Set(ids); + const targets = topicsRef.current.filter(t => idSet.has(t.id)); + const updated = targets.map(t => ({ ...t, ...patch })); + await Promise.all(updated.map(t => db.upsertTopic(t))); + setTopics(prev => prev.map(t => (idSet.has(t.id) ? { ...t, ...patch } : t))); + return updated.length; + }, []); + /** Remove a specific (source, target, type) relation triple. */ const removeRelation = useCallback(async (source, target, type) => { await db.removeRelation(source, target, type); @@ -165,6 +182,7 @@ export function useGraphData() { snapshotMeta, reload, updateTopic, + bulkUpdateTopics, deleteTopic, addRelation, removeRelation,