Files
learning-platform/src/hooks/useGraphData.js
RaymondVerhoef eb08c4ad96
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 48s
On Pull Request to Main / publish (pull_request) Successful in 1m20s
On Pull Request to Main / deploy-dev (pull_request) Successful in 1m52s
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) <noreply@anthropic.com>
2026-06-03 17:08:51 +02:00

193 lines
7.2 KiB
JavaScript

import { useState, useEffect, useCallback, useRef } from 'react';
import * as db from '../lib/db';
/**
* Centralises all knowledge-graph data fetching and mutation.
*
* Returns stable callback refs so callers can safely list them in
* useEffect dependency arrays without causing infinite loops.
*
* Relation shape contract: source and target are always plain strings.
* The normalization in db.getRelations() enforces this at the DB boundary;
* the filter here drops any orphaned edges whose endpoints no longer exist.
*
* Snapshot contract:
* bulkSave() writes a snapshot of the CURRENT state to PocketBase before
* overwriting it — so the admin can always roll back one step. snapshotMeta
* is non-null whenever a rollback point exists; restoreSnapshot() applies it
* and then clears it so it cannot be applied twice.
*/
export function useGraphData() {
const [topics, setTopics] = useState([]);
const [relations, setRelations] = useState([]);
// { ts: number, topicCount: number, relationCount: number }
const [snapshotMeta, setSnapshotMeta] = useState(null);
// Refs that always hold the latest state — lets mutation callbacks avoid
// stale-closure bugs without including state in their dependency arrays.
const topicsRef = useRef([]);
const relationsRef = useRef([]);
useEffect(() => { topicsRef.current = topics; }, [topics]);
useEffect(() => { relationsRef.current = relations; }, [relations]);
// ── Load ────────────────────────────────────────────────────────────────────
const reload = useCallback(async () => {
const [allTopics, allRelations] = await Promise.all([
db.getTopics(),
db.getRelations(), // already normalised to string IDs
]);
const topicIds = new Set(allTopics.map(t => t.id));
setTopics(allTopics);
setRelations(allRelations.filter(r => topicIds.has(r.source) && topicIds.has(r.target)));
}, []);
useEffect(() => {
reload();
window.addEventListener('respellion:kb-updated', reload);
return () => window.removeEventListener('respellion:kb-updated', reload);
}, [reload]);
// On mount, check whether a snapshot from a previous session exists.
useEffect(() => {
const snap = db.getGraphSnapshot();
if (snap?.ts) {
setSnapshotMeta({ ts: snap.ts, topicCount: snap.topicCount, relationCount: snap.relationCount });
}
}, []);
// ── Single-topic mutations ───────────────────────────────────────────────────
/**
* Persist a topic update and mirror it into local state.
* Returns the saved topic object.
*/
const updateTopic = useCallback(async (topic) => {
await db.upsertTopic(topic);
setTopics(prev => prev.map(t => (t.id === topic.id ? topic : t)));
return topic;
}, []);
/**
* Delete a topic plus all relations that reference it.
* Uses the ref so the callback is stable even as state changes.
*/
const deleteTopic = useCallback(async (topicId) => {
const updatedTopics = topicsRef.current.filter(t => t.id !== topicId);
const updatedRelations = relationsRef.current.filter(
r => r.source !== topicId && r.target !== topicId,
);
await db.saveTopics(updatedTopics);
await db.saveRelations(updatedRelations);
await db.deleteContent(topicId);
setTopics(updatedTopics);
setRelations(updatedRelations);
}, []);
// ── Relation mutations ───────────────────────────────────────────────────────
/**
* Add a relation if not already present.
* Returns true if added, false if it was a duplicate.
*/
const addRelation = useCallback(async (relation) => {
const isDup = relationsRef.current.some(
r => r.source === relation.source &&
r.target === relation.target &&
r.type === relation.type,
);
if (isDup) return false;
await db.addRelation(relation);
setRelations(prev => [...prev, relation]);
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);
setRelations(prev =>
prev.filter(r => !(r.source === source && r.target === target && r.type === type)),
);
}, []);
// ── Bulk mutations (used by AI analysis) ────────────────────────────────────
/**
* Snapshot the current graph then replace everything in PocketBase and local
* state. The snapshot is written BEFORE the destructive delete-all/create-all
* so it survives a mid-operation failure.
*
* Called by analyzeGraph after all AI actions have been applied in memory.
*/
const bulkSave = useCallback(async (newTopics, newRelations) => {
// Persist a rollback point of the state we are about to overwrite.
db.saveGraphSnapshot(topicsRef.current, relationsRef.current);
setSnapshotMeta({
ts: Date.now(),
topicCount: topicsRef.current.length,
relationCount: relationsRef.current.length,
});
await db.saveTopics(newTopics);
await db.saveRelations(newRelations);
setTopics(newTopics);
setRelations(newRelations);
}, []);
/**
* Load the latest snapshot from PocketBase and apply it as the current graph.
* Clears the snapshot afterwards so the admin cannot restore the same point
* twice (which would silently re-apply the AI changes they just rolled back).
*
* Returns true on success, false if no snapshot exists.
*/
const restoreSnapshot = useCallback(async () => {
const snap = db.getGraphSnapshot();
if (!snap?.topics) return false;
// Apply without taking a new snapshot — we don't want to overwrite
// the only rollback point we have.
await db.saveTopics(snap.topics);
await db.saveRelations(snap.relations);
setTopics(snap.topics);
setRelations(snap.relations);
db.clearGraphSnapshot();
setSnapshotMeta(null);
return true;
}, []);
return {
topics,
relations,
snapshotMeta,
reload,
updateTopic,
bulkUpdateTopics,
deleteTopic,
addRelation,
removeRelation,
bulkSave,
restoreSnapshot,
};
}