diff --git a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx index 9e3e5cab..f055a3a3 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx @@ -2351,16 +2351,15 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap showPluginDock: openDockPanels.length > 0, }; - useEffect(() => { + const [prevOpenDockPanels, setPrevOpenDockPanels] = useState(openDockPanels); + if (openDockPanels !== prevOpenDockPanels) { + setPrevOpenDockPanels(openDockPanels); if (!openDockPanels.length) { - setActiveDockPanelId(null); - return; - } - - if (!activeDockPanelId || !openDockPanels.some((panel) => panel.id === activeDockPanelId)) { + if (activeDockPanelId !== null) setActiveDockPanelId(null); + } else if (!activeDockPanelId || !openDockPanels.some((panel) => panel.id === activeDockPanelId)) { setActiveDockPanelId(openDockPanels[0].id); } - }, [activeDockPanelId, openDockPanels]); + } const viewModeItems = useMemo(() => { if (!hasGraphContent) { diff --git a/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx index 1407bedc..440c3e33 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx @@ -369,7 +369,9 @@ export function GraphWorkspaceShell() { } }, []); - useEffect(() => { + const [prevFetchedAt, setPrevFetchedAt] = useState(snapshot?.fetchedAt); + if (snapshot?.fetchedAt !== prevFetchedAt) { + setPrevFetchedAt(snapshot?.fetchedAt); if (snapshot) { setIsGraphStageReady(false); setActiveNodeCount(null); @@ -383,7 +385,7 @@ export function GraphWorkspaceShell() { stableSamples: 0, }); } - }, [snapshot?.fetchedAt]); + } useEffect(() => { let cancelled = false; diff --git a/explorer/src/workspaces/LineageWorkspace/LineageDiagram.tsx b/explorer/src/workspaces/LineageWorkspace/LineageDiagram.tsx index bd53cc14..edc138d2 100644 --- a/explorer/src/workspaces/LineageWorkspace/LineageDiagram.tsx +++ b/explorer/src/workspaces/LineageWorkspace/LineageDiagram.tsx @@ -51,12 +51,18 @@ export function LineageDiagram() { document.body.removeChild(anchor); }; - useEffect(() => { + const [prevActiveId, setPrevActiveId] = useState(activeId); + if (activeId !== prevActiveId) { + setPrevActiveId(activeId); if (!activeId) { setNodes([]); setEdges([]); - return; } + } + + useEffect(() => { + let ignore = false; + if (!activeId) return; const xLanes = [ { id: "group_agent", type: "group", position: { x: 50, y: 50 }, style: { width: 800, height: 120 } }, @@ -89,7 +95,7 @@ export function LineageDiagram() { counters[n.parent_id] = c + 1; return { id: n.id, - data: { label: n.label + "\\n(" + n.prov_type + ")" }, + data: { label: n.label + "\n(" + n.prov_type + ")" }, position: { x: 50 + c * 180, y: 30 }, parentId: n.parent_id, extent: "parent", @@ -106,13 +112,16 @@ export function LineageDiagram() { style: { stroke: "#58a6ff" } })); - setNodes([...xLanes, ...mappedNodes]); - setEdges(mappedEdges); + if (!ignore) { + setNodes([...xLanes, ...mappedNodes]); + setEdges(mappedEdges); + } } catch (err) { console.error(err); } }; - fetchLineage(); + void fetchLineage(); + return () => { ignore = true; }; }, [activeId]); return ( diff --git a/explorer/src/workspaces/ManageWorkspace/KGOverviewTab.tsx b/explorer/src/workspaces/ManageWorkspace/KGOverviewTab.tsx index e882ba91..85a0a422 100644 --- a/explorer/src/workspaces/ManageWorkspace/KGOverviewTab.tsx +++ b/explorer/src/workspaces/ManageWorkspace/KGOverviewTab.tsx @@ -116,8 +116,51 @@ export function KGOverviewTab() { }, []); useEffect(() => { - void fetchOverview(); - }, [fetchOverview]); + let ignore = false; + async function fetchInitial() { + try { + const [statsRes, nodesRes] = await Promise.all([ + fetch("/api/graph/stats"), + fetch("/api/graph/nodes?limit=500"), + ]); + + if (!ignore) { + if (statsRes.ok) { + const statsData: KGStats = await statsRes.json(); + setStats(statsData); + } + + if (nodesRes.ok) { + const nodesData: NodeListResponse = await nodesRes.json(); + const nodes = nodesData.nodes ?? []; + setNodeTypeMap(buildTypeMap(nodes, "type")); + + const edgesRes = await fetch("/api/graph/edges?limit=2000"); + if (edgesRes.ok) { + const edgesData = await edgesRes.json(); + const edges: { source: string; target: string }[] = edgesData.edges ?? []; + const degreeMap: Record = {}; + for (const edge of edges) { + degreeMap[edge.source] = (degreeMap[edge.source] ?? 0) + 1; + degreeMap[edge.target] = (degreeMap[edge.target] ?? 0) + 1; + } + const sorted = nodes + .map((n) => ({ node: n, neighborCount: degreeMap[n.id] ?? 0 })) + .sort((a, b) => b.neighborCount - a.neighborCount) + .slice(0, 10); + setTopNodes(sorted); + } + } + } + } catch { + if (!ignore) setError("Failed to load graph overview. Ensure the server is running."); + } finally { + if (!ignore) setLoading(false); + } + } + void fetchInitial(); + return () => { ignore = true; }; + }, []); const nodeTypeEntries = Object.entries(nodeTypeMap).sort((a, b) => b[1] - a[1]); const edgeTypeEntries = stats?.edge_types diff --git a/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx b/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx index 8034705d..67f4024f 100644 --- a/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx +++ b/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx @@ -66,8 +66,27 @@ export function AlignmentsTab() { }, []); useEffect(() => { - void reload(); - }, [reload]); + let ignore = false; + async function fetchInitial() { + const [registryResult, alignmentResult] = await Promise.allSettled([ + loadOntologyRegistry(), + loadAlignments(), + ]); + + if (!ignore) { + if (registryResult.status === "fulfilled") { + setRegistry(registryResult.value); + setSourceOntology((current) => current || registryResult.value[0]?.uri || ""); + setTargetOntology((current) => current || registryResult.value[1]?.uri || registryResult.value[0]?.uri || ""); + } + if (alignmentResult.status === "fulfilled") { + setAlignments(alignmentResult.value); + } + } + } + void fetchInitial(); + return () => { ignore = true; }; + }, []); const relationCounts = useMemo(() => { const counts = new Map(); diff --git a/explorer/src/workspaces/OntologyWorkspace/HealthTab.tsx b/explorer/src/workspaces/OntologyWorkspace/HealthTab.tsx index 44d2ffed..87640db6 100644 --- a/explorer/src/workspaces/OntologyWorkspace/HealthTab.tsx +++ b/explorer/src/workspaces/OntologyWorkspace/HealthTab.tsx @@ -29,23 +29,31 @@ export function HealthTab({ onFixInEditor }: HealthTabProps) { }; }, []); - const loadHealth = useCallback(async (uri: string) => { - if (!uri) return; - setLoading(true); - setError(""); - try { - setHealth(await loadOntologyHealth(uri)); - } catch { - // Backend unavailable — show "select an ontology" placeholder, not an error - setHealth(null); - } finally { - setLoading(false); + const [prevUri, setPrevUri] = useState(selectedUri); + if (selectedUri !== prevUri) { + setPrevUri(selectedUri); + if (selectedUri) { + setLoading(true); + setError(""); } - }, []); + } useEffect(() => { - void loadHealth(selectedUri); - }, [selectedUri, loadHealth]); + let ignore = false; + async function fetchHealth() { + if (!selectedUri) return; + try { + const data = await loadOntologyHealth(selectedUri); + if (!ignore) setHealth(data); + } catch { + if (!ignore) setHealth(null); + } finally { + if (!ignore) setLoading(false); + } + } + void fetchHealth(); + return () => { ignore = true; }; + }, [selectedUri]); const exportReport = useCallback(() => { if (!health) return; diff --git a/explorer/src/workspaces/OntologyWorkspace/OntologyManager.tsx b/explorer/src/workspaces/OntologyWorkspace/OntologyManager.tsx index 363de1bc..71c98961 100644 --- a/explorer/src/workspaces/OntologyWorkspace/OntologyManager.tsx +++ b/explorer/src/workspaces/OntologyWorkspace/OntologyManager.tsx @@ -248,6 +248,15 @@ export function OntologyManager() { const [rightPanel, setRightPanel] = useState("none"); const [actionMsg, setActionMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); + const [prevSearchQ, setPrevSearchQ] = useState(searchQ); + const [prevStatusFilter, setPrevStatusFilter] = useState(statusFilter); + if (searchQ !== prevSearchQ || statusFilter !== prevStatusFilter) { + setPrevSearchQ(searchQ); + setPrevStatusFilter(statusFilter); + setLoading(true); + setActionMsg(null); + } + const fetchRegistry = useCallback(async () => { setLoading(true); setActionMsg(null); @@ -268,8 +277,27 @@ export function OntologyManager() { }, [searchQ, statusFilter]); useEffect(() => { - fetchRegistry(); - }, [fetchRegistry]); + let ignore = false; + async function fetchInitial() { + try { + const params = new URLSearchParams(); + if (searchQ) params.set("q", searchQ); + const res = await fetch(`/api/ontology/registry?${params}`); + if (res.ok) { + const data = await res.json(); + if (!ignore) setEntries(data); + } else { + if (!ignore) setEntries([]); + } + } catch { + if (!ignore) setEntries([]); + } finally { + if (!ignore) setLoading(false); + } + } + void fetchInitial(); + return () => { ignore = true; }; + }, [searchQ, statusFilter]); const flashMsg = (type: "ok" | "err", text: string) => { setActionMsg({ type, text }); diff --git a/explorer/src/workspaces/OntologyWorkspace/OntologySearch.tsx b/explorer/src/workspaces/OntologyWorkspace/OntologySearch.tsx index 5285aad4..37094013 100644 --- a/explorer/src/workspaces/OntologyWorkspace/OntologySearch.tsx +++ b/explorer/src/workspaces/OntologyWorkspace/OntologySearch.tsx @@ -178,17 +178,31 @@ function DetailPanel({ const [loading, setLoading] = useState(true); const [error, setError] = useState(""); - useEffect(() => { + const [prevUri, setPrevUri] = useState(uri); + if (uri !== prevUri) { + setPrevUri(uri); setLoading(true); setError(""); + setDetail(null); + } + + useEffect(() => { + let ignore = false; fetch(`/api/ontology/entity/${encodeURIComponent(uri)}`) .then((r) => { if (!r.ok) throw new Error("Not found"); return r.json(); }) - .then(setDetail) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); + .then((data) => { + if (!ignore) setDetail(data); + }) + .catch((e) => { + if (!ignore) setError(e.message); + }) + .finally(() => { + if (!ignore) setLoading(false); + }); + return () => { ignore = true; }; }, [uri]); return ( diff --git a/explorer/src/workspaces/OntologyWorkspace/ProposalReview.tsx b/explorer/src/workspaces/OntologyWorkspace/ProposalReview.tsx index 226778a3..030987a9 100644 --- a/explorer/src/workspaces/OntologyWorkspace/ProposalReview.tsx +++ b/explorer/src/workspaces/OntologyWorkspace/ProposalReview.tsx @@ -40,19 +40,6 @@ export function ProposalReview({ proposalId }: { proposalId: string }) { const [selectedElement, setSelectedElement] = useState(null); const [commentText, setCommentText] = useState(""); - const loadProposal = useCallback(async () => { - try { - const response = await fetch(`/api/ontology/proposals/${proposalId}`); - if (response.ok) { - const data = await response.json(); - setProposal(data); - generateDiff(data); - } - } catch (error) { - console.error("Failed to load proposal:", error); - } - }, [proposalId]); - const generateDiff = useCallback((prop: Proposal) => { const changes: DiffChange[] = []; @@ -75,9 +62,38 @@ export function ProposalReview({ proposalId }: { proposalId: string }) { setDiff(changes); }, []); + const loadProposal = useCallback(async () => { + try { + const response = await fetch(`/api/ontology/proposals/${proposalId}`); + if (response.ok) { + const data = await response.json(); + setProposal(data); + generateDiff(data); + } + } catch (error) { + console.error("Failed to load proposal:", error); + } + }, [proposalId, generateDiff]); + useEffect(() => { - loadProposal(); - }, [loadProposal]); + let ignore = false; + async function fetchInitial() { + try { + const response = await fetch(`/api/ontology/proposals/${proposalId}`); + if (response.ok) { + const data = await response.json(); + if (!ignore) { + setProposal(data); + generateDiff(data); + } + } + } catch (error) { + console.error("Failed to load proposal:", error); + } + } + void fetchInitial(); + return () => { ignore = true; }; + }, [proposalId, generateDiff]); const addComment = useCallback(async () => { if (!selectedElement || !commentText || !proposal) return; diff --git a/explorer/src/workspaces/OntologyWorkspace/SKOSVocabularyManager.tsx b/explorer/src/workspaces/OntologyWorkspace/SKOSVocabularyManager.tsx index d46951d5..d9284e64 100644 --- a/explorer/src/workspaces/OntologyWorkspace/SKOSVocabularyManager.tsx +++ b/explorer/src/workspaces/OntologyWorkspace/SKOSVocabularyManager.tsx @@ -77,17 +77,33 @@ function ConceptDetailPanel({ const [loading, setLoading] = useState(true); const [error, setError] = useState(""); - useEffect(() => { + const [prevUri, setPrevUri] = useState(uri); + if (uri !== prevUri) { + setPrevUri(uri); setLoading(true); setError(""); + setDetail(null); + } + + useEffect(() => { + let ignore = false; fetch(`/api/ontology/skos/concept/${encodeURIComponent(uri)}`) .then((r) => { if (!r.ok) throw new Error("Concept not found"); return r.json(); }) - .then(setDetail) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); + .then((data) => { + if (!ignore) setDetail(data); + }) + .catch((e) => { + if (!ignore) setError(e.message); + }) + .finally(() => { + if (!ignore) setLoading(false); + }); + return () => { + ignore = true; + }; }, [uri]); const renderUriList = (label: string, uris: string[]) => { @@ -322,16 +338,36 @@ function SchemePanel({ }) { const [expanded, setExpanded] = useState(true); const [hierarchy, setHierarchy] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(expanded); + + const [prevExpanded, setPrevExpanded] = useState(expanded); + const [prevSchemeUri, setPrevSchemeUri] = useState(scheme.uri); + if (expanded !== prevExpanded || scheme.uri !== prevSchemeUri) { + setPrevExpanded(expanded); + setPrevSchemeUri(scheme.uri); + if (expanded) { + setLoading(true); + setHierarchy([]); + } + } useEffect(() => { + let ignore = false; if (!expanded) return; - setLoading(true); fetch(`/api/vocabulary/hierarchy?scheme=${encodeURIComponent(scheme.uri)}`) .then((r) => (r.ok ? r.json() : [])) - .then(setHierarchy) - .catch(() => setHierarchy([])) - .finally(() => setLoading(false)); + .then((data) => { + if (!ignore) setHierarchy(data); + }) + .catch(() => { + if (!ignore) setHierarchy([]); + }) + .finally(() => { + if (!ignore) setLoading(false); + }); + return () => { + ignore = true; + }; }, [scheme.uri, expanded]); const totalConcepts = countConcepts(hierarchy); @@ -410,7 +446,6 @@ export function SKOSVocabularyManager({ schemeUri }: Props) { const [selectedUri, setSelectedUri] = useState(null); useEffect(() => { - setLoading(true); fetch("/api/ontology/skos/schemes") .then((r) => (r.ok ? r.json() : [])) .then(setSchemes) diff --git a/explorer/src/workspaces/OntologyWorkspace/ShaclStudio.tsx b/explorer/src/workspaces/OntologyWorkspace/ShaclStudio.tsx index 5a64ee9b..50a44763 100644 --- a/explorer/src/workspaces/OntologyWorkspace/ShaclStudio.tsx +++ b/explorer/src/workspaces/OntologyWorkspace/ShaclStudio.tsx @@ -39,32 +39,42 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) { }; }, []); - const loadShapes = useCallback(async (uri: string) => { - if (!uri) return; - setLoading(true); - setError(""); - try { - const data = await loadShaclShapes(uri); - setShapes(data.shapes); - const turtle = data.shacl_turtle; - setFullShacl(turtle); - setShacl((current) => current || turtle); - setSelectedShapeId(null); - setValidation(null); - } catch { - // Shapes not yet generated or backend unavailable — show empty shape list - setShapes([]); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { + const [prevUri, setPrevUri] = useState(selectedUri); + if (selectedUri !== prevUri) { + setPrevUri(selectedUri); setShacl(""); setFullShacl(""); setSelectedShapeId(null); - void loadShapes(selectedUri); - }, [selectedUri, loadShapes]); + setValidation(null); + setLoading(true); + setError(""); + } + + useEffect(() => { + let ignore = false; + async function fetchShapes() { + if (!selectedUri) return; + try { + const data = await loadShaclShapes(selectedUri); + if (!ignore) { + setShapes(data.shapes); + const turtle = data.shacl_turtle; + setFullShacl(turtle); + setShacl((current) => current || turtle); + setSelectedShapeId(null); + setValidation(null); + } + } catch { + if (!ignore) setShapes([]); + } finally { + if (!ignore) setLoading(false); + } + } + void fetchShapes(); + return () => { + ignore = true; + }; + }, [selectedUri]); const handleGenerate = useCallback(async () => { if (!selectedUri) return; diff --git a/explorer/src/workspaces/OntologyWorkspace/VersionsTab.tsx b/explorer/src/workspaces/OntologyWorkspace/VersionsTab.tsx index f9176c32..33f2efec 100644 --- a/explorer/src/workspaces/OntologyWorkspace/VersionsTab.tsx +++ b/explorer/src/workspaces/OntologyWorkspace/VersionsTab.tsx @@ -74,9 +74,34 @@ export function VersionsTab() { }, []); useEffect(() => { - loadVersions(); - loadProposals(); - }, [loadVersions, loadProposals]); + let ignore = false; + async function fetchInitial() { + try { + const [propRes] = await Promise.all([ + fetch("/api/ontology/proposals"), + ]); + if (propRes.ok) { + const propData = await propRes.json(); + if (!ignore) setProposals(propData); + } + } catch (e) { + console.error("Failed to load proposals", e); + } + + if (!ontologyUri) return; + try { + const verRes = await fetch(`/api/ontology/versions/${encodeURIComponent(ontologyUri)}`); + if (verRes.ok) { + const verData = await verRes.json(); + if (!ignore) setVersions(verData); + } + } catch (e) { + console.error("Failed to load versions", e); + } + } + void fetchInitial(); + return () => { ignore = true; }; + }, [ontologyUri]); const approveProposal = useCallback(async (proposalId: string) => { try {