From 43cc78a91b73166cf85e15c36b99aae1ce9754f0 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Tue, 26 Aug 2025 23:54:59 -0700 Subject: [PATCH 1/6] feat: normalize endpoint URLs and enhance JSON-LD parsing for term predicates --- src/api/endpoints/apiActions.ts | 18 ++- src/api/endpoints/apiService.ts | 66 +++++++-- src/api/endpoints/hiearchies-parser.ts | 71 +++++----- .../SingleTermView/OverView/Hierarchy.jsx | 86 ++++++----- .../SingleTermView/OverView/OverView.jsx | 133 +++++++++++------- .../SingleTermView/OverView/Predicates.jsx | 113 +++------------ .../OverView/ViewDiagramDialog.jsx | 116 +++++++++------ src/main.jsx | 1 - src/parsers/predicateParser.tsx | 97 +++++++++++++ 9 files changed, 416 insertions(+), 285 deletions(-) create mode 100644 src/parsers/predicateParser.tsx diff --git a/src/api/endpoints/apiActions.ts b/src/api/endpoints/apiActions.ts index ca5a41c7..fbc166d1 100644 --- a/src/api/endpoints/apiActions.ts +++ b/src/api/endpoints/apiActions.ts @@ -20,23 +20,27 @@ export const createPostRequest = (endpoint: string, headers : export const createGetRequest = (endpoint: string, contentType?: string) => { return (params?: P, options?: SecondParameter, signal?: AbortSignal) => { + // ✅ normalize to absolute URL so SPA routes don't prefix it + const absUrl = endpoint.startsWith('http') + ? endpoint + : new URL(endpoint.startsWith('/') ? endpoint : `/${endpoint}`, window.location.origin).toString(); + const config: AxiosRequestConfig = { - url: endpoint, + url: absUrl, method: "GET", params, signal, withCredentials: true - } + }; + // ✅ for GET, set Accept header (Content-Type is irrelevant for GET) if (contentType) { config.headers = { ...config.headers, - "Content-Type": contentType, - } + Accept: contentType, + }; } - return customInstance(config, options).then(response => { - return response; - }); + return customInstance(config, options).then(response => response); } } \ No newline at end of file diff --git a/src/api/endpoints/apiService.ts b/src/api/endpoints/apiService.ts index b3a0b17a..0339d21b 100644 --- a/src/api/endpoints/apiService.ts +++ b/src/api/endpoints/apiService.ts @@ -2,6 +2,7 @@ import { createPostRequest, createGetRequest } from "./apiActions"; import { API_CONFIG } from "../../config"; import termParser from "../../parsers/termParser"; import { jsonldToTriplesAndEdges, PART_OF_IRI } from './hiearchies-parser' +import { buildPredicateGroupsForFocus } from "../../parsers/predicateParser"; export interface LoginRequest { username: string @@ -302,7 +303,7 @@ export const getVariant = (group: string, term: string) => { return createGetRequest(`/${group}/variant/${term}`, "application/json")(); }; -export const getTermHierarchies = async ({ +export const getTermPredicates = async ({ groupname, termId, objToSub = true, @@ -311,20 +312,55 @@ export const getTermHierarchies = async ({ termId: string; objToSub?: boolean; }) => { - const base = `/${groupname}/query/transitive/${encodeURIComponent(termId)}/ilx.partOf:`; - const url1 = `${base}?obj-to-sub=${objToSub}`; - const url2 = `${base}.jsonld?obj-to-sub=${objToSub}`; + const url = new URL( + `/${groupname}/query/transitive/${encodeURIComponent(termId)}/ilx.partOf:?obj-to-sub=true`, + window.location.origin + ).toString(); + + const resp = await fetch(url, { + headers: { Accept: "application/ld+json" }, + credentials: "include", + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const ct = resp.headers.get("content-type") || ""; + if (!/application\/(ld\+json|json)/i.test(ct)) { + throw new Error(`Server did not return JSON-LD (content-type: ${ct || "n/a"})`); + } + const jsonld = await resp.json(); - try { - const res1 = await createGetRequest(url1, 'application/ld+json')(); - return jsonldToTriplesAndEdges(res1); - } catch { - try { - const res2 = await createGetRequest(url2, 'application/ld+json')(); - return jsonldToTriplesAndEdges(res2); - } catch (e2: any) { - console.error('getTermHierarchies failed', e2); - return { error: true, message: e2?.message || String(e2) }; - } + // Build gold-standard predicate groups for the focus owl:Class + const predicates = buildPredicateGroupsForFocus(jsonld, termId); + return { predicates }; +}; + +export const getTermHierarchies = async ({ + groupname, + termId, + objToSub = false, +}: { + groupname: string; + termId: string; + objToSub?: boolean; +}) => { + const url = new URL( + `/${groupname}/query/transitive/${encodeURIComponent(termId)}/ilx.partOf:?obj-to-sub=${objToSub}`, + window.location.origin + ).toString(); + + const resp = await fetch(url, { + headers: { Accept: "application/ld+json" }, + credentials: "include", + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const ct = resp.headers.get("content-type") || ""; + if (!/application\/(ld\+json|json)/i.test(ct)) { + throw new Error(`Server did not return JSON-LD (content-type: ${ct || "n/a"})`); } + const jsonld = await resp.json(); + + // Only what Hierarchy needs + const parsed = jsonldToTriplesAndEdges(jsonld); + const triples = Array.isArray(parsed) ? parsed : (parsed?.triples || parsed?.edges || []); + + return { triples }; }; diff --git a/src/api/endpoints/hiearchies-parser.ts b/src/api/endpoints/hiearchies-parser.ts index 4ae132dd..ece5af5a 100644 --- a/src/api/endpoints/hiearchies-parser.ts +++ b/src/api/endpoints/hiearchies-parser.ts @@ -1,53 +1,47 @@ -// Minimal JSON-LD → { triples, edges } converter used by getTermHierarchies - -export const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; export const RDFS_LABEL = 'http://www.w3.org/2000/01/rdf-schema#label'; +export const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; export const PART_OF_IRI = 'http://uri.interlex.org/base/ilx_0112785'; -type JsonLdNode = { - '@id': string; - '@type'?: string[] | string; - [k: string]: any; -}; +type NodeRef = { id: string; label: string }; +type Triple = { subject: NodeRef; predicate: { id: string; label: string }; object: NodeRef }; +type Edge = { from: NodeRef; to: NodeRef }; -function firstString(o: any): string | undefined { - if (o == null) return; - if (typeof o === 'string') return o; - if (Array.isArray(o)) return firstString(o[0]); - if (typeof o === 'object') { - if ('@value' in o) return String(o['@value']); - if ('@id' in o) return String(o['@id']); +function firstString(v: any): string | undefined { + if (!v) return undefined; + if (typeof v === 'string') return v; + if (Array.isArray(v)) { + for (const x of v) { + if (typeof x === 'string') return x; + if (x && typeof x === 'object' && typeof x['@value'] === 'string') return x['@value']; + } } + if (typeof v === 'object' && typeof v['@value'] === 'string') return v['@value']; + return undefined; } -function ids(objs: any): string[] { - if (!objs) return []; - const arr = Array.isArray(objs) ? objs : [objs]; - return arr.map(v => (typeof v === 'string' ? v : v?.['@id'])).filter(Boolean); +function ids(v: any): string[] { + if (!v) return []; + if (Array.isArray(v)) { + return v.flatMap(ids); + } + if (typeof v === 'object' && typeof v['@id'] === 'string') { + return [v['@id']]; + } + if (typeof v === 'string') return [v]; + return []; } -export type Triple = { - subject: { id: string; label: string }; - predicate: { id: string; label: string }; - object: { id: string; label: string }; -}; - -export type Edge = { - from: { id: string; label: string }; - to: { id: string; label: string }; -}; - export function jsonldToTriplesAndEdges(jsonld: any): { triples: Triple[]; edges: Edge[] } { - const graph: JsonLdNode[] = + const graph: any[] = Array.isArray(jsonld) ? jsonld : Array.isArray(jsonld?.['@graph']) ? jsonld['@graph'] : jsonld?.['@id'] ? [jsonld] : []; - // id → label map (prefer rdfs:label) + // Build id → label const labelById = new Map(); for (const n of graph) { const id = n['@id']; if (!id) continue; - const lbl = firstString(n['label']) ?? firstString(n['rdfs:label']) ?? firstString(n[RDFS_LABEL]); + const lbl = firstString(n['rdfs:label']) ?? firstString(n['label']); if (lbl) labelById.set(id, lbl); } @@ -68,11 +62,16 @@ export function jsonldToTriplesAndEdges(jsonld: any): { triples: Triple[]; edges for (const t of ids(n['@type'])) addTriple(s, RDF_TYPE, t); // rdfs:label - const lbl = firstString(n['label']) ?? firstString(n['rdfs:label']) ?? firstString(n[RDFS_LABEL]); + const lbl = firstString(n['rdfs:label']) ?? firstString(n['label']); if (lbl) addTriple(s, RDFS_LABEL, undefined, lbl); - // ilx.partOf (accept compact or expanded) - for (const o of [...ids(n['partOf']), ...ids(n[PART_OF_IRI])]) { + // ilx.partOf in ALL its forms + const partOfTargets = [ + ...ids(n['ilx.partOf']), // compact (what your endpoint returns) + ...ids(n['partOf']), // generic compact + ...ids(n[PART_OF_IRI]) // expanded IRI (just in case) + ]; + for (const o of partOfTargets) { addTriple(s, PART_OF_IRI, o); edges.push({ from: { id: s, label: labelById.get(s) ?? s }, diff --git a/src/components/SingleTermView/OverView/Hierarchy.jsx b/src/components/SingleTermView/OverView/Hierarchy.jsx index 7b85a47b..fecc19ed 100644 --- a/src/components/SingleTermView/OverView/Hierarchy.jsx +++ b/src/components/SingleTermView/OverView/Hierarchy.jsx @@ -15,21 +15,32 @@ import CustomSingleSelect from "../../common/CustomSingleSelect"; const { gray600, gray800 } = vars; +/** + * Extract simple label and containment maps from triples. + * We detect part-of edges broadly to be resilient to label/id variants. + */ function mapsFromTriples(triples) { const idLabelMap = {}; const parentToChildren = {}; for (const t of triples || []) { - const pred = (t.predicate?.label || t.predicate?.id || "").toLowerCase(); + const pred = (t?.predicate?.label || t?.predicate?.id || '').toLowerCase(); - if (pred.endsWith("label") || pred.includes("rdfs:label")) { - if (t.subject?.id) idLabelMap[t.subject.id] = t.object?.label || t.object?.id || t.subject.id; + // collect labels + if (pred.endsWith('label') || pred.includes('rdfs:label')) { + if (t.subject?.id) { + idLabelMap[t.subject.id] = t.subject?.label || t.subject.id; + } + if (t.object?.id) { + idLabelMap[t.object.id] = t.object?.label || t.object.id; + } continue; } - if (pred.endsWith("ilx.partof:") || pred.includes("partof") || pred.includes("is part of")) { - const child = t.subject?.id; - const parent = t.object?.id; + // connect part-of edges: child --partOf--> parent => parent -> child + if (pred.includes('partof')) { + const child = t?.subject?.id; + const parent = t?.object?.id; if (child && parent) { if (!parentToChildren[parent]) parentToChildren[parent] = []; if (!parentToChildren[parent].includes(child)) parentToChildren[parent].push(child); @@ -43,7 +54,9 @@ function mapsFromTriples(triples) { function buildTree(rootId, mapping, idLabelMap) { const visited = new Set(); const build = (id) => { - if (visited.has(id)) return { id, label: idLabelMap[id] || id, children: [], isCycle: true }; + if (visited.has(id)) { + return { id, label: idLabelMap[id] || id, children: [], isCycle: true }; + } visited.add(id); const kids = (mapping[id] || []).map(build); return { id, label: idLabelMap[id] || id, children: kids }; @@ -64,7 +77,7 @@ const Hierarchy = ({ React.useEffect(() => { const triples = type === "children" ? triplesChildren : triplesSuperclasses; - if (!selectedValue?.handler || !Array.isArray(triples)) { + if (!selectedValue?.id || !Array.isArray(triples)) { setTreeData([]); return; } @@ -72,17 +85,20 @@ const Hierarchy = ({ try { const { idLabelMap, parentToChildren } = mapsFromTriples(triples); let mapping = parentToChildren; + + // for superclasses we invert the direction (parent mapping becomes child->parents) if (type === "superclasses") { const childToParents = {}; - for (const [parent, kids] of Object.entries(parentToChildren)) { + for (const [p, kids] of Object.entries(parentToChildren)) { for (const kid of kids) { if (!childToParents[kid]) childToParents[kid] = []; - if (!childToParents[kid].includes(parent)) childToParents[kid].push(parent); + if (!childToParents[kid].includes(p)) childToParents[kid].push(p); } } mapping = childToParents; } - const tree = buildTree(selectedValue.handler, mapping, idLabelMap); + + const tree = buildTree(selectedValue.id, mapping, idLabelMap); setTreeData(tree); } catch (e) { console.error(e); @@ -94,49 +110,47 @@ const Hierarchy = ({ const childCount = treeData?.[0]?.children?.length || 0; + const handleSelectChange = (_event, value) => { + onSelect?.(value); + }; + return ( - - + + Hierarchy - - - Type: - + + Type: setType(v)} options={[ - { value: "children", label: "Children" }, - { value: "superclasses", label: "Superclasses" }, + { value: 'children', label: 'Children' }, + { value: 'superclasses', label: 'Superclasses' }, ]} /> - - - - - Total number of first generation {type === "children" ? "children" : "superclasses"}: {childCount} + + + + Total number of first generation {type === 'children' ? 'children' : 'superclasses'}: {childCount} ); @@ -151,7 +165,7 @@ Hierarchy.propTypes = { ), selectedValue: PropTypes.shape({ label: PropTypes.string, - handler: PropTypes.string, + id: PropTypes.string, }), onSelect: PropTypes.func, triplesChildren: PropTypes.array, diff --git a/src/components/SingleTermView/OverView/OverView.jsx b/src/components/SingleTermView/OverView/OverView.jsx index c35bdc77..9a6ef302 100644 --- a/src/components/SingleTermView/OverView/OverView.jsx +++ b/src/components/SingleTermView/OverView/OverView.jsx @@ -10,7 +10,7 @@ import Hierarchy from "./Hierarchy"; import Predicates from "./Predicates"; import RawDataViewer from "./RawDataViewer"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { getMatchTerms, getRawData, getTermHierarchies } from "../../../api/endpoints/apiService"; +import { getMatchTerms, getRawData, getTermHierarchies, getTermPredicates } from "../../../api/endpoints/apiService"; const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = "base" }) => { const [data, setData] = useState(null); @@ -25,54 +25,65 @@ const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = " const [triplesChildren, setTriplesChildren] = useState([]); const [triplesSuperclasses, setTriplesSuperclasses] = useState([]); - // eslint-disable-next-line react-hooks/exhaustive-deps + // predicates for the Predicates panel (gold-standard shape) + const [predicateGroups, setPredicateGroups] = useState([]); + + // Build SingleSearch options from hierarchy triples +const toHierarchyOptionsFromTriples = (triples = []) => { + const toCurie = (id) => { + if (!id) return id; + // http://uri.interlex.org/base/ilx_0100573 -> ILX:0100573 + const m = id.match(/\/ilx_(\d+)/i); + return m ? `ILX:${m[1]}` : id; + }; + + const add = (map, node) => { + const id = node?.id; + const label = node?.label; + if (!id || !label) return; + + // skip OWL/property nodes and empty labels + if (id === 'owl:Class') return; + if (id.startsWith('http://www.w3.org/')) return; + + map.set(id, { label, handler: toCurie(id) }); + }; + + const uniq = new Map(); + for (const t of triples) { + add(uniq, t.subject); + add(uniq, t.object); + } + return Array.from(uniq.values()); +}; + + // Debounced search → populate options + default selection const fetchTerms = useCallback( debounce((term) => { - if (term) { - getMatchTerms(group, term).then(apiData => { - const results = apiData?.results || []; - setData(results?.[0] || null); - - // Build options { label, handler } - const opts = results - .map((r) => { - const label = - r.label || - r.rdfsLabel || - r.prefLabel || - r.term || - r.name || - r.curie || - r.id; - const handler = - r.curie || r.ilx || r.id || r.termId || r.identifier; - return label && handler ? { label, handler } : null; - }) - .filter(Boolean); - - // de-dupe by handler - const seen = new Set(); - const deduped = opts.filter(o => (seen.has(o.handler) ? false : (seen.add(o.handler), true))); - - setHierarchyOptions(deduped); - - // default selection - setSelectedValue(prev => - prev && deduped.some(o => o.handler === prev?.handler) ? prev : deduped[0] || null - ); - - setLoading(false); - }); - } else { + if (!term) { setData(null); setHierarchyOptions([]); setSelectedValue(null); setLoading(false); + return; } + + getMatchTerms(group, term).then(apiData => { + const results = apiData?.results || []; + setData(results?.[0] || null); + + // default selection if needed + setSelectedValue(prev => + prev && results.some(o => o.id === prev?.id) ? prev : results[0] || null + ); + + setLoading(false); + }); }, 300), [group] ); + // JSON-LD for the raw viewer const fetchJSONFile = useCallback(() => { if (!searchTerm) { setJsonData(null); @@ -83,13 +94,16 @@ const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = " }); }, [searchTerm, group]); - // Fetch hierarchies for selectedValue + // Fetch both hierarchy directions for the selected value const fetchHierarchies = useCallback(async (curieLike, groupname) => { try { - await Promise.all([ - getTermHierarchies({ groupname, termId: curieLike, objToSub: true }), - getTermHierarchies({ groupname, termId: curieLike, objToSub: false }), - ]); + let termId = curieLike.split('/').pop() || curieLike; + termId = termId.replace(/^ilx_/, "ILX:"); + + const superRes = await getTermHierarchies({ groupname, termId, objToSub: false }); + setTriplesSuperclasses(superRes.triples || []); + const deduped = toHierarchyOptionsFromTriples(superRes?.triples); + setHierarchyOptions(deduped); } catch (e) { console.error("fetchHierarchies error:", e); setTriplesChildren([]); @@ -97,6 +111,25 @@ const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = " } }, []); + // Fetch gold-standard predicate groups for the Predicates UI + // Fetch gold-standard predicate groups for the Predicates UI +const fetchPredicates = useCallback(async (curieLike, groupname) => { + try { + // Normalize to ILX:NNNNNN form (same normalization you use for hierarchies) + let termId = curieLike.split('/').pop() || curieLike; // e.g. 'ilx_0100573' or 'ILX:0100573' + termId = termId.replace(/^ilx_/i, 'ILX:'); + + // Optional: debug so you can see it’s being called + // console.debug('getTermPredicates →', { groupname, termId }); + + const groups = await getTermPredicates({ groupname, termId }); + setPredicateGroups(groups || []); + } catch (e) { + console.error("fetchPredicates error:", e); + setPredicateGroups([]); + } +}, []); + useEffect(() => { setLoading(true); fetchTerms(searchTerm); @@ -107,15 +140,15 @@ const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = " }, [searchTerm, fetchTerms, fetchJSONFile]); useEffect(() => { - // infer groupname from data if you store it in context; fallback to "base" - const groupname = group || "base"; - if (selectedValue?.handler) { - fetchHierarchies(selectedValue.handler, groupname); + if (selectedValue?.id) { + fetchPredicates(selectedValue.id, "base"); + fetchHierarchies(selectedValue.id, "base"); } else { setTriplesChildren([]); setTriplesSuperclasses([]); + setPredicateGroups([]); } - }, [selectedValue, group, fetchHierarchies]); + }, [selectedValue, group, fetchHierarchies, fetchPredicates]); const memoData = useMemo(() => data, [data]); @@ -140,9 +173,7 @@ const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = " diff --git a/src/components/SingleTermView/OverView/Predicates.jsx b/src/components/SingleTermView/OverView/Predicates.jsx index 9af95822..314c3254 100644 --- a/src/components/SingleTermView/OverView/Predicates.jsx +++ b/src/components/SingleTermView/OverView/Predicates.jsx @@ -1,114 +1,43 @@ import React from "react"; -import PropTypes from 'prop-types'; -import ExpandIcon from '@mui/icons-material/Expand'; -import RemoveIcon from '@mui/icons-material/Remove'; +import PropTypes from "prop-types"; +import ExpandIcon from "@mui/icons-material/Expand"; +import RemoveIcon from "@mui/icons-material/Remove"; import PredicatesAccordion from "./PredicatesAccordion"; import { Box, Typography, ToggleButton, ToggleButtonGroup } from "@mui/material"; import { vars } from "../../../theme/variables"; const { gray800 } = vars; -const FALLBACK_PREDICATE = "predicate"; +const Predicates = ({ data, isGraphVisible }) => { + const [toggleButtonValue, setToggleButtonValue] = React.useState("expand"); -function groupsFromTriples(triples) { - const byPred = new Map(); - for (const t of triples || []) { - const key = (t?.predicate?.label || t?.predicate?.id || "").trim() || FALLBACK_PREDICATE; - if (!byPred.has(key)) byPred.set(key, []); - byPred.get(key).push(t); - } - const groups = []; - for (const [predLabel, items] of byPred.entries()) { - const rows = items.map((t) => ({ - subject: t.subject?.label || t.subject?.id, - subjectId: t.subject?.id, - object: t.object?.label || t.object?.id, - objectId: t.object?.id, - })); - const edges = items.map((t) => ({ - from: t.subject, - to: t.object, - predicate: t.predicate, - })); - groups.push({ - title: predLabel, - count: items.length, - rows, - values: rows, - edges, - forceGraph: false, - }); - } - return groups; -} + const predicates = React.useMemo(() => { + if (Array.isArray(data)) return data; + if (Array.isArray(data?.predicates)) return data.predicates; + return []; + }, [data]); -const Predicates = ({ basePredicates = [], triplesChildren = [], triplesSuperclasses = [], isGraphVisible }) => { - const [predicates, setPredicates] = React.useState([]); - const [toggleButtonValue, setToggleButtonValue] = React.useState('expand'); - - const onToggleButtonChange = (_event, newValue) => { - if (newValue) setToggleButtonValue(newValue); - }; - - React.useEffect(() => { - // Build groups from both directions and merge with existing basePredicates - const groupsChildren = groupsFromTriples(triplesChildren); - const groupsSupers = groupsFromTriples(triplesSuperclasses); - - const all = [...(Array.isArray(basePredicates) ? basePredicates : [])]; - - const pushOrMerge = (g) => { - const idx = all.findIndex(p => p.title === g.title); - if (idx === -1) { - all.push(g); - } else { - const existing = all[idx]; - const combinedRows = [...(existing.rows || existing.values || []), ...(g.rows || g.values || [])]; - const combinedEdges = [...(existing.edges || []), ...(g.edges || [])]; - all[idx] = { - ...existing, - count: (existing.count || 0) + (g.count || 0), - rows: combinedRows, - values: combinedRows, - edges: combinedEdges, - }; - } - }; - - groupsChildren.forEach(pushOrMerge); - groupsSupers.forEach(pushOrMerge); - - setPredicates(all); - }, [basePredicates, triplesChildren, triplesSuperclasses]); + const onToggleButtonChange = (_e, v) => v && setToggleButtonValue(v); return ( - - + + Predicates - + - - - - - - + + @@ -116,10 +45,8 @@ const Predicates = ({ basePredicates = [], triplesChildren = [], triplesSupercla }; Predicates.propTypes = { - basePredicates: PropTypes.array, - triplesChildren: PropTypes.array, - triplesSuperclasses: PropTypes.array, - isGraphVisible: PropTypes.bool + data: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), + isGraphVisible: PropTypes.bool, }; export default Predicates; diff --git a/src/components/SingleTermView/OverView/ViewDiagramDialog.jsx b/src/components/SingleTermView/OverView/ViewDiagramDialog.jsx index 06fddfb6..261170c0 100644 --- a/src/components/SingleTermView/OverView/ViewDiagramDialog.jsx +++ b/src/components/SingleTermView/OverView/ViewDiagramDialog.jsx @@ -13,79 +13,103 @@ import PlaylistAddOutlinedIcon from "@mui/icons-material/PlaylistAddOutlined"; import {vars} from "../../../theme/variables"; const {gray600, gray700} = vars -const HeaderRightSideContent = ({handleOpenAddPredicate, selectedItem, predicates}) => { - const [type, setType] = React.useState(selectedItem?.title); - const [count, setCount] = React.useState(selectedItem?.count) +const HeaderRightSideContent = ({ handleOpenAddPredicate, selectedItem, predicates }) => { + // predicates is an array of groups: [{ title, count, rows }, ...] + const options = React.useMemo( + () => (predicates || []).map(p => ({ label: p.title, value: p.title })), + [predicates] + ); + const countByTitle = React.useMemo(() => { + const m = new Map(); + (predicates || []).forEach(p => m.set(p.title, p.count ?? 0)); + return m; + }, [predicates]); + + const [type, setType] = React.useState(selectedItem?.title || ''); + const [count, setCount] = React.useState( + selectedItem?.title ? (countByTitle.get(selectedItem.title) ?? selectedItem?.count ?? 0) : 0 + ); + + // keep in sync when selection/predicates change + React.useEffect(() => { + const t = selectedItem?.title || ''; + setType(t); + setCount(countByTitle.get(t) ?? selectedItem?.count ?? 0); + }, [selectedItem, countByTitle]); const handleChangeType = (value) => { - setType(value) - const selectedTypeCount = predicates.find(predicate => predicate.label === value).count - setCount(selectedTypeCount) - } + setType(value); + setCount(countByTitle.get(value) ?? 0); + }; return ( - - + + Number of this type: {count} - Predicate type: + + Predicate type: + - + - ) -} -const ViewDiagramDialog = ({open, handleClose, image, selectedItem, predicates}) => { - const [openAddPredicate, setOpenAddPredicate] = useState(false) - - const handleCloseAddPredicate = () => { - setOpenAddPredicate(false) - } - const handleOpenAddPredicate = () => { - setOpenAddPredicate(true) - } - const predicatesOptions = predicates.map(row => ({ + ); +}; +const ViewDiagramDialog = ({ open, handleClose, image, selectedItem, predicates }) => { + const [openAddPredicate, setOpenAddPredicate] = useState(false); + + const handleCloseAddPredicate = () => setOpenAddPredicate(false); + const handleOpenAddPredicate = () => setOpenAddPredicate(true); + + // simple label/value list only for the add dialog + const predicatesOptions = (predicates || []).map(row => ({ label: row.title, value: row.title - })) + })); + return ( <> - } + HeaderRightSideContent={ + + } > - { - openAddPredicate && - } + + {openAddPredicate && ( + + )} - ) -} + ); +}; HeaderRightSideContent.propTypes = { handleOpenAddPredicate: PropTypes.func, diff --git a/src/main.jsx b/src/main.jsx index 566b0418..180d3402 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -8,7 +8,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { CookiesProvider } from 'react-cookie'; const queryClient = new QueryClient(); -worker.start(); ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/src/parsers/predicateParser.tsx b/src/parsers/predicateParser.tsx new file mode 100644 index 00000000..49dbe55c --- /dev/null +++ b/src/parsers/predicateParser.tsx @@ -0,0 +1,97 @@ +export type PredicateTriple = { + subject: string; + predicate: string; + object: string; + }; + + export type PredicateGroup = { + title: string; + count: number; + tableData: PredicateTriple[]; + }; + + /** + * Build predicate groups for the focus owl:Class in a JSON-LD document. + * Collects: + * - All outgoing predicates on the focus node (including @id/@type). + * - All inbound predicates from any other node where the object equals the focus @id. + */ + export function buildPredicateGroupsForFocus(jsonld: any, termId?: string): PredicateGroup[] { + const graph: any[] = Array.isArray(jsonld?.['@graph']) ? jsonld['@graph'] : []; + + // Resolve focus node + const ilxSuffix = (termId || '').replace(/^ILX:/i, 'ilx_'); + const endsWithIlx = (id: string) => + typeof id === 'string' && + ilxSuffix && + id.toLowerCase().endsWith(`/${ilxSuffix.toLowerCase()}`); + + let focus: any = + graph.find((o) => o?.['@type'] === 'owl:Class' && endsWithIlx(o?.['@id'])) || + graph.find((o) => o?.['@type'] === 'owl:Class') || + null; + + const flatten = (v: any): string => { + if (v == null) return ''; + if (typeof v === 'string') return v; + if (Array.isArray(v)) return v.map(flatten).join(', '); + if (typeof v === 'object') return v['@id'] ?? v['@value'] ?? v.value ?? JSON.stringify(v); + return String(v); + }; + + const predicatesMap: Record = {}; + const pushTriple = (subjectId: string, predicateKey: string, value: any) => { + const triple = { subject: subjectId, predicate: predicateKey, object: flatten(value) }; + (predicatesMap[predicateKey] ||= []).push(triple); + }; + + if (!focus || !focus['@id']) return []; + + const focusId = String(focus['@id']); + + // 1) Outgoing: everything on the focus node + pushTriple(focusId, '@id', focusId); + const typeVal = focus['@type']; + if (typeVal !== undefined) { + if (Array.isArray(typeVal)) typeVal.forEach((v) => pushTriple(focusId, '@type', v)); + else pushTriple(focusId, '@type', typeVal); + } + for (const key of Object.keys(focus)) { + if (key === '@id' || key === '@type' || key === 'isAbout' || key === 'ilx.isAbout') continue; + const val = (focus as any)[key]; + if (Array.isArray(val)) val.forEach((v) => pushTriple(focusId, key, v)); + else pushTriple(focusId, key, val); + } + + // 2) Inbound: for every other node, if any property value equals the focus @id, include that triple + for (const node of graph) { + const subjId = node?.['@id']; + if (!subjId) continue; + + for (const key of Object.keys(node)) { + if (key === '@id' || key === '@type') continue; + const val = (node as any)[key]; + + // helper to check a value (object|string|array) for references to focusId + const addIfMatches = (v: any) => { + if (v && typeof v === 'object' && v['@id'] === focusId) { + pushTriple(String(subjId), key, v); + } else if (typeof v === 'string' && v === focusId) { + pushTriple(String(subjId), key, v); + } + }; + + if (Array.isArray(val)) val.forEach(addIfMatches); + else addIfMatches(val); + } + } + + // Group into the gold-standard shape + return Object.keys(predicatesMap).map((title) => ({ + title, + count: predicatesMap[title].length, + tableData: predicatesMap[title], + })); + } + + \ No newline at end of file From 650943332648eafab68e7e5ec0cf0e5b872a7f3e Mon Sep 17 00:00:00 2001 From: jrmartin Date: Wed, 27 Aug 2025 07:43:03 -0700 Subject: [PATCH 2/6] refactor: reorganize imports and enhance hierarchy component functionality --- src/api/endpoints/apiService.ts | 2 +- src/components/GraphViewer/GraphStructure.jsx | 141 +++++++----- .../SingleTermView/OverView/Hierarchy.jsx | 3 +- .../SingleTermView/OverView/OverView.jsx | 213 +++++++++--------- .../OverView/PredicatesAccordion.jsx | 12 +- src/main.jsx | 2 - .../hierarchies-parser.tsx} | 45 +++- 7 files changed, 242 insertions(+), 176 deletions(-) rename src/{api/endpoints/hiearchies-parser.ts => parsers/hierarchies-parser.tsx} (67%) diff --git a/src/api/endpoints/apiService.ts b/src/api/endpoints/apiService.ts index 0339d21b..126eb23d 100644 --- a/src/api/endpoints/apiService.ts +++ b/src/api/endpoints/apiService.ts @@ -1,7 +1,7 @@ import { createPostRequest, createGetRequest } from "./apiActions"; import { API_CONFIG } from "../../config"; import termParser from "../../parsers/termParser"; -import { jsonldToTriplesAndEdges, PART_OF_IRI } from './hiearchies-parser' +import { jsonldToTriplesAndEdges, PART_OF_IRI } from '../../parsers/hierarchies-parser' import { buildPredicateGroupsForFocus } from "../../parsers/predicateParser"; export interface LoginRequest { diff --git a/src/components/GraphViewer/GraphStructure.jsx b/src/components/GraphViewer/GraphStructure.jsx index 99eb007e..0151290f 100644 --- a/src/components/GraphViewer/GraphStructure.jsx +++ b/src/components/GraphViewer/GraphStructure.jsx @@ -1,3 +1,5 @@ +// GraphStructure.jsx (pure JS) + export const OBJECT = "object"; export const PREDICATE = "predicate"; export const SUBJECT = "subject"; @@ -15,96 +17,115 @@ const safe = (v, fallback = "unknown") => { function normalizeRows(pred) { if (!pred) return []; - // 1) new shape from getTermHierarchies grouping + // new shapes if (Array.isArray(pred.rows) && pred.rows.length) return pred.rows; if (Array.isArray(pred.values) && pred.values.length) return pred.values; - // 2) legacy tableData + // legacy tableData (strings only) if (Array.isArray(pred.tableData) && pred.tableData.length) { - return pred.tableData.map(r => ({ + return pred.tableData.map((r) => ({ subject: r.subject, - subjectId: r.subject, // no id in legacy, use label + subjectId: r.subject || r.subjectId, object: r.object, - objectId: r.object, + objectId: r.object || r.objectId, })); } - // 3) edges fallback + // edges fallback if (Array.isArray(pred.edges) && pred.edges.length) { - return pred.edges.map(e => ({ - subject: e?.from?.label || e?.from?.id, - subjectId: e?.from?.id || e?.from?.label, - object: e?.to?.label || e?.to?.id, - objectId: e?.to?.id || e?.to?.label, + return pred.edges.map((e) => ({ + subject: (e?.from && (e.from.label || e.from.id)) || "unknown", + subjectId: (e?.from && (e.from.id || e.from.label)) || "unknown", + object: (e?.to && (e.to.label || e.to.id)) || "unknown", + objectId: (e?.to && (e.to.id || e.to.label)) || "unknown", })); } return []; } -// Pick a root subject (most frequent). Fallback to first row. -function pickRoot(rows) { - if (!rows.length) return { key: "unknown", label: "unknown" }; - const counts = new Map(); - const firstLabelById = new Map(); - for (const r of rows) { - const id = r.subjectId || r.subject; - if (!id) continue; - counts.set(id, (counts.get(id) || 0) + 1); - if (!firstLabelById.has(id)) firstLabelById.set(id, r.subject || r.subjectId || id); - } - let rootKey = null, max = -1; - for (const [k, v] of counts.entries()) { - if (v > max) { max = v; rootKey = k; } - } - if (!rootKey) { - const r0 = rows[0]; - const id = r0.subjectId || r0.subject || "unknown"; - return { key: id, label: r0.subject || id }; - } - return { key: rootKey, label: firstLabelById.get(rootKey) || rootKey }; -} - -// Build Root → Predicate → Unique Objects +/** + * Behavior: + * - If all rows share the same subject → keep old layout: + * Root(Subject) → Predicate → Unique Objects + * - If multiple subjects → show all subjects: + * Root(Predicate) → Subject_1 → Objects + * Subject_2 → Objects + */ export const getGraphStructure = (pred) => { const rows = normalizeRows(pred); if (!rows.length) { return { name: "No data", id: "no-data", type: ROOT, value: 0, children: [] }; } - const { key: rootKey, label: rootLabel } = pickRoot(rows); - const forRoot = rows.filter(r => (r.subjectId || r.subject) === rootKey); + // Bucket rows by subject + const subjMap = new Map(); // subjectId -> { label, rows[] } + for (const r of rows) { + const sid = safe(r.subjectId || r.subject); + const slabel = safe(r.subject); + if (!subjMap.has(sid)) subjMap.set(sid, { label: slabel, rows: [] }); + subjMap.get(sid).rows.push({ + subject: slabel, + subjectId: sid, + object: safe(r.object), + objectId: safe(r.objectId || r.object), + }); + } + const uniqueSubjects = Array.from(subjMap.keys()); const predicateLabel = safe(pred?.title, "predicate"); const predicateId = predicateLabel; - const seen = new Set(); - const objects = []; - for (const r of forRoot) { - const oid = r.objectId || r.object; - if (!oid) continue; - if (seen.has(oid)) continue; - seen.add(oid); - objects.push({ - name: safe(r.object), - id: safe(oid), - type: OBJECT, - children: [], + // SINGLE-SUBJECT layout (backwards-compatible) + if (uniqueSubjects.length === 1) { + const sid = uniqueSubjects[0]; + const bucket = subjMap.get(sid); + const seen = new Set(); + const objects = []; + for (const r of bucket.rows) { + const oid = r.objectId; + if (!oid || seen.has(oid)) continue; + seen.add(oid); + objects.push({ name: safe(r.object), id: oid, type: OBJECT, children: [] }); + } + return { + name: bucket.label, + id: sid, + type: ROOT, + value: bucket.rows.length || pred?.count || 0, + children: [ + { name: predicateLabel, id: predicateId, type: PREDICATE, children: objects }, + ], + }; + } + + // MULTI-SUBJECT layout + const subjectNodes = []; + for (const sid of uniqueSubjects) { + const bucket = subjMap.get(sid); + const seen = new Set(); + const objects = []; + for (const r of bucket.rows) { + const oid = r.objectId; + if (!oid || seen.has(oid)) continue; + seen.add(oid); + // ensure uniqueness under the predicate root + objects.push({ name: safe(r.object), id: `${sid}::${oid}`, type: OBJECT, children: [] }); + } + subjectNodes.push({ + name: bucket.label, + id: sid, + type: SUBJECT, + value: bucket.rows.length, + children: objects, }); } return { - name: safe(rootLabel), - id: safe(rootKey), + name: predicateLabel, + id: predicateId, type: ROOT, - value: forRoot.length || pred?.count || 0, - children: [ - { - name: predicateLabel, - id: predicateId, - type: PREDICATE, - children: objects, - }, - ], + value: rows.length || pred?.count || 0, + children: subjectNodes, }; }; diff --git a/src/components/SingleTermView/OverView/Hierarchy.jsx b/src/components/SingleTermView/OverView/Hierarchy.jsx index fecc19ed..5c5ee4e1 100644 --- a/src/components/SingleTermView/OverView/Hierarchy.jsx +++ b/src/components/SingleTermView/OverView/Hierarchy.jsx @@ -148,7 +148,7 @@ const Hierarchy = ({ - + Total number of first generation {type === 'children' ? 'children' : 'superclasses'}: {childCount} @@ -161,6 +161,7 @@ Hierarchy.propTypes = { PropTypes.shape({ label: PropTypes.string, handler: PropTypes.string.isRequired, + id : PropTypes.string.isRequired, }) ), selectedValue: PropTypes.shape({ diff --git a/src/components/SingleTermView/OverView/OverView.jsx b/src/components/SingleTermView/OverView/OverView.jsx index 9a6ef302..70c91a89 100644 --- a/src/components/SingleTermView/OverView/OverView.jsx +++ b/src/components/SingleTermView/OverView/OverView.jsx @@ -2,180 +2,183 @@ import { Box, Divider, Grid, + CircularProgress, } from "@mui/material"; import Details from "./Details"; -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; +import { debounce } from "lodash"; +import PropTypes from "prop-types"; import Hierarchy from "./Hierarchy"; import Predicates from "./Predicates"; import RawDataViewer from "./RawDataViewer"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { getMatchTerms, getRawData, getTermHierarchies, getTermPredicates } from "../../../api/endpoints/apiService"; +import { + getMatchTerms, + getRawData, + getTermHierarchies, + getTermPredicates, +} from "../../../api/endpoints/apiService"; +import { toHierarchyOptionsFromTriples } from "../../../parsers/hierarchies-parser"; const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = "base" }) => { const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); + const [pageLoading, setPageLoading] = useState(true); const [jsonData, setJsonData] = useState(null); // options for Hierarchy’s SingleSearch const [hierarchyOptions, setHierarchyOptions] = useState([]); const [selectedValue, setSelectedValue] = useState(null); - // hierarchies for the currently selected value + // hierarchies (triples) for the currently selected value const [triplesChildren, setTriplesChildren] = useState([]); const [triplesSuperclasses, setTriplesSuperclasses] = useState([]); - // predicates for the Predicates panel (gold-standard shape) + // predicates panel data (already gold-standard shaped) const [predicateGroups, setPredicateGroups] = useState([]); - // Build SingleSearch options from hierarchy triples -const toHierarchyOptionsFromTriples = (triples = []) => { - const toCurie = (id) => { - if (!id) return id; - // http://uri.interlex.org/base/ilx_0100573 -> ILX:0100573 - const m = id.match(/\/ilx_(\d+)/i); - return m ? `ILX:${m[1]}` : id; - }; - - const add = (map, node) => { - const id = node?.id; - const label = node?.label; - if (!id || !label) return; - - // skip OWL/property nodes and empty labels - if (id === 'owl:Class') return; - if (id.startsWith('http://www.w3.org/')) return; - - map.set(id, { label, handler: toCurie(id) }); - }; - - const uniq = new Map(); - for (const t of triples) { - add(uniq, t.subject); - add(uniq, t.object); - } - return Array.from(uniq.values()); -}; - - // Debounced search → populate options + default selection - const fetchTerms = useCallback( - debounce((term) => { - if (!term) { - setData(null); - setHierarchyOptions([]); - setSelectedValue(null); - setLoading(false); - return; - } - - getMatchTerms(group, term).then(apiData => { - const results = apiData?.results || []; - setData(results?.[0] || null); - - // default selection if needed - setSelectedValue(prev => - prev && results.some(o => o.id === prev?.id) ? prev : results[0] || null - ); - - setLoading(false); - }); - }, 300), - [group] + // fine-grained loading states + const [loadingHierarchies, setLoadingHierarchies] = useState(false); + const [loadingPredicates, setLoadingPredicates] = useState(false); + + // ---------- FIX: debounced search with explicit deps ---------- + const debouncedFetchTerms = useMemo( + () => + debounce(async (term, groupname) => { + if (!term) { + setData(null); + setHierarchyOptions([]); + setSelectedValue(null); + setPageLoading(false); + return; + } + try { + const apiData = await getMatchTerms(groupname, term); + const results = apiData?.results || []; + setData(results?.[0] || null); + + // keep selection if still present; else first result + setSelectedValue((prev) => + prev && results.some((o) => o.id === prev?.id) ? prev : results[0] || null + ); + } finally { + setPageLoading(false); + } + }, 300), + [] ); + // ------------------------------------------------------------- - // JSON-LD for the raw viewer + // JSON-LD for raw viewer const fetchJSONFile = useCallback(() => { if (!searchTerm) { setJsonData(null); return; } - getRawData(group, searchTerm, 'jsonld').then(rawResponse => { + getRawData(group, searchTerm, "jsonld").then((rawResponse) => { setJsonData(rawResponse); }); }, [searchTerm, group]); - // Fetch both hierarchy directions for the selected value + // Fetch hierarchies for the selected value (superclasses for now) const fetchHierarchies = useCallback(async (curieLike, groupname) => { + setLoadingHierarchies(true); try { - let termId = curieLike.split('/').pop() || curieLike; + let termId = curieLike.split("/").pop() || curieLike; termId = termId.replace(/^ilx_/, "ILX:"); const superRes = await getTermHierarchies({ groupname, termId, objToSub: false }); - setTriplesSuperclasses(superRes.triples || []); - const deduped = toHierarchyOptionsFromTriples(superRes?.triples); + const triples = superRes?.triples || []; + setTriplesSuperclasses(triples); + + // Build SingleSearch options from the triples we got back + const deduped = toHierarchyOptionsFromTriples(triples); setHierarchyOptions(deduped); } catch (e) { console.error("fetchHierarchies error:", e); setTriplesChildren([]); setTriplesSuperclasses([]); + setHierarchyOptions([]); + } finally { + setLoadingHierarchies(false); } }, []); - // Fetch gold-standard predicate groups for the Predicates UI - // Fetch gold-standard predicate groups for the Predicates UI -const fetchPredicates = useCallback(async (curieLike, groupname) => { - try { - // Normalize to ILX:NNNNNN form (same normalization you use for hierarchies) - let termId = curieLike.split('/').pop() || curieLike; // e.g. 'ilx_0100573' or 'ILX:0100573' - termId = termId.replace(/^ilx_/i, 'ILX:'); - - // Optional: debug so you can see it’s being called - // console.debug('getTermPredicates →', { groupname, termId }); - - const groups = await getTermPredicates({ groupname, termId }); - setPredicateGroups(groups || []); - } catch (e) { - console.error("fetchPredicates error:", e); - setPredicateGroups([]); - } -}, []); + // Fetch predicates for the Predicates panel + const fetchPredicates = useCallback(async (curieLike, groupname) => { + setLoadingPredicates(true); + try { + // Normalize to ILX:NNNN… for the endpoint + let termId = curieLike.split("/").pop() || curieLike; + termId = termId.replace(/^ilx_/i, "ILX:"); + + const groups = await getTermPredicates({ groupname, termId }); + setPredicateGroups(groups || []); + } catch (e) { + console.error("fetchPredicates error:", e); + setPredicateGroups([]); + } finally { + setLoadingPredicates(false); + } + }, []); + // bootstrapping on search change useEffect(() => { - setLoading(true); - fetchTerms(searchTerm); + setPageLoading(true); + debouncedFetchTerms(searchTerm, group); fetchJSONFile(); return () => { - fetchTerms.cancel(); + debouncedFetchTerms.cancel(); }; - }, [searchTerm, fetchTerms, fetchJSONFile]); + }, [searchTerm, group, debouncedFetchTerms, fetchJSONFile]); + // react to selection changes useEffect(() => { if (selectedValue?.id) { + // Trigger both in parallel; each has its own loading state fetchPredicates(selectedValue.id, "base"); fetchHierarchies(selectedValue.id, "base"); } else { setTriplesChildren([]); setTriplesSuperclasses([]); setPredicateGroups([]); + setHierarchyOptions([]); } }, [selectedValue, group, fetchHierarchies, fetchPredicates]); const memoData = useMemo(() => data, [data]); return ( - + {isCodeViewVisible ? ( ) : ( <> -
- +
+ - + - + {loadingHierarchies ? ( + + + + ) : ( + + )} - + {loadingPredicates ? ( + + + + ) : ( + + )} @@ -183,13 +186,13 @@ const fetchPredicates = useCallback(async (curieLike, groupname) => { )} ); -} +}; OverView.propTypes = { searchTerm: PropTypes.string, isCodeViewVisible: PropTypes.bool, selectedDataFormat: PropTypes.string, - group: PropTypes.string -} + group: PropTypes.string, +}; export default OverView; diff --git a/src/components/SingleTermView/OverView/PredicatesAccordion.jsx b/src/components/SingleTermView/OverView/PredicatesAccordion.jsx index ed38e43b..3fdbb0bd 100644 --- a/src/components/SingleTermView/OverView/PredicatesAccordion.jsx +++ b/src/components/SingleTermView/OverView/PredicatesAccordion.jsx @@ -22,9 +22,11 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { vars } from "../../../theme/variables"; const { gray600 } = vars; +const TABLE_VIEW = 'tableView'; +const GRAPH_VIEW = 'graphView'; const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { - const [toggleButtonValues, setToggleButtonValues] = useState(data?.map(() => 'tableView') || []); + const [toggleButtonValues, setToggleButtonValues] = useState(data?.map(() => TABLE_VIEW) || []); const [openViewDiagram, setOpenViewDiagram] = React.useState(false); const [selectedItem, setSelectedItem] = useState(null); const [expandedItems, setExpandedItems] = useState(data?.map(() => false) || []); @@ -54,7 +56,7 @@ const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { }; useEffect(() => { - const newToggleButtonValues = data?.map((d) => (d.forceGraph ? "graphView" : "tableView")) || []; + const newToggleButtonValues = data?.map((d) => (d.forceGraph ? GRAPH_VIEW : TABLE_VIEW)) || []; const newExpandedItems = data?.map(() => expandAllPredicates) || []; setToggleButtonValues(newToggleButtonValues); setExpandedItems(newExpandedItems); @@ -100,10 +102,10 @@ const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { exclusive onChange={onToggleButtonChange(index)} > - + - + @@ -112,7 +114,7 @@ const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { - {toggleButtonValues[index] === 'tableView' ? ( + {toggleButtonValues[index] === TABLE_VIEW ? ( diff --git a/src/api/endpoints/hiearchies-parser.ts b/src/parsers/hierarchies-parser.tsx similarity index 67% rename from src/api/endpoints/hiearchies-parser.ts rename to src/parsers/hierarchies-parser.tsx index ece5af5a..6517a2a4 100644 --- a/src/api/endpoints/hiearchies-parser.ts +++ b/src/parsers/hierarchies-parser.tsx @@ -1,5 +1,9 @@ +// constants +export const ILX_PART_OF = 'Is part of'; +export const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; +export const OWL_OBJECT_PROPERTY = 'owl:ObjectProperty'; + export const RDFS_LABEL = 'http://www.w3.org/2000/01/rdf-schema#label'; -export const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; export const PART_OF_IRI = 'http://uri.interlex.org/base/ilx_0112785'; type NodeRef = { id: string; label: string }; @@ -31,6 +35,43 @@ function ids(v: any): string[] { return []; } +// http://uri.../ilx_0100573 -> ILX:0100573 +export const toCurie = (id?: string) => { + if (!id) return id; + const m = id.match(/\/ilx_(\d+)$/i); + return m ? `ILX:${m[1]}` : id; +}; + +// turn superclasses triples into SingleSearch options +export const toHierarchyOptionsFromTriples = (triples: any[] = []) => { + const objectPropertySubjects = new Set( + triples + .filter(t => t?.predicate?.id === RDF_TYPE && t?.object?.id === OWL_OBJECT_PROPERTY) + .map(t => t?.subject?.id) + .filter(Boolean) + ); + + const seen = new Set(); + const out: Array<{label: string; handler: string; id: String}> = []; + + const add = (node: any) => { + const id = node?.id as string; + const label = (node?.label || '').trim(); + if (!id || !label) return; + const handler = toCurie(id) as string; + seen.add(handler); + out.push({ label, handler, id }); + }; + + for (const t of triples) { + // only harvest nodes that actually participate in the hierarchy relation somewhere + if (t?.predicate?.label === RDF_TYPE && t?.subject?.label !== ILX_PART_OF) { + if (t?.subject) add(t.subject); + } + } + return out; +}; + export function jsonldToTriplesAndEdges(jsonld: any): { triples: Triple[]; edges: Edge[] } { const graph: any[] = Array.isArray(jsonld) ? jsonld : @@ -81,4 +122,4 @@ export function jsonldToTriplesAndEdges(jsonld: any): { triples: Triple[]; edges } return { triples, edges }; -} +} \ No newline at end of file From 2ce2190fb68b79f92b63e30fbf06dd330dc0779b Mon Sep 17 00:00:00 2001 From: jrmartin Date: Wed, 27 Aug 2025 17:51:22 -0700 Subject: [PATCH 3/6] refactor: clean up unused code and improve component structure --- src/api/endpoints/apiActions.ts | 2 - src/api/endpoints/apiService.ts | 2 +- src/components/GraphViewer/GraphStructure.jsx | 2 - .../SingleTermView/OverView/Hierarchy.jsx | 198 +++++------ .../SingleTermView/OverView/OverView.jsx | 160 +++++---- .../SingleTermView/OverView/Predicates.jsx | 12 +- src/components/common/CustomizedTreeView.jsx | 65 +++- src/parsers/hierarchies-parser.tsx | 317 ++++++++++++++++-- 8 files changed, 520 insertions(+), 238 deletions(-) diff --git a/src/api/endpoints/apiActions.ts b/src/api/endpoints/apiActions.ts index fbc166d1..06fe6f0f 100644 --- a/src/api/endpoints/apiActions.ts +++ b/src/api/endpoints/apiActions.ts @@ -20,7 +20,6 @@ export const createPostRequest = (endpoint: string, headers : export const createGetRequest = (endpoint: string, contentType?: string) => { return (params?: P, options?: SecondParameter, signal?: AbortSignal) => { - // ✅ normalize to absolute URL so SPA routes don't prefix it const absUrl = endpoint.startsWith('http') ? endpoint : new URL(endpoint.startsWith('/') ? endpoint : `/${endpoint}`, window.location.origin).toString(); @@ -33,7 +32,6 @@ export const createGetRequest = (endpoint: string, contentType withCredentials: true }; - // ✅ for GET, set Accept header (Content-Type is irrelevant for GET) if (contentType) { config.headers = { ...config.headers, diff --git a/src/api/endpoints/apiService.ts b/src/api/endpoints/apiService.ts index 126eb23d..03b3fe09 100644 --- a/src/api/endpoints/apiService.ts +++ b/src/api/endpoints/apiService.ts @@ -330,7 +330,7 @@ export const getTermPredicates = async ({ // Build gold-standard predicate groups for the focus owl:Class const predicates = buildPredicateGroupsForFocus(jsonld, termId); - return { predicates }; + return predicates; }; export const getTermHierarchies = async ({ diff --git a/src/components/GraphViewer/GraphStructure.jsx b/src/components/GraphViewer/GraphStructure.jsx index 0151290f..7e7109c0 100644 --- a/src/components/GraphViewer/GraphStructure.jsx +++ b/src/components/GraphViewer/GraphStructure.jsx @@ -1,5 +1,3 @@ -// GraphStructure.jsx (pure JS) - export const OBJECT = "object"; export const PREDICATE = "predicate"; export const SUBJECT = "subject"; diff --git a/src/components/SingleTermView/OverView/Hierarchy.jsx b/src/components/SingleTermView/OverView/Hierarchy.jsx index 5c5ee4e1..c3e9706e 100644 --- a/src/components/SingleTermView/OverView/Hierarchy.jsx +++ b/src/components/SingleTermView/OverView/Hierarchy.jsx @@ -1,118 +1,79 @@ +// SingleTermView/OverView/Hierarchy.jsx +import React from "react"; +import PropTypes from "prop-types"; import { Box, Button, Divider, Stack, - Typography + Typography, + CircularProgress } from "@mui/material"; import { vars } from "../../../theme/variables"; -import React from "react"; -import PropTypes from "prop-types"; import { RestartAlt, TargetCross } from "../../../Icons"; import SingleSearch from "../SingleSearch"; import CustomizedTreeView from "../../common/CustomizedTreeView"; import CustomSingleSelect from "../../common/CustomSingleSelect"; const { gray600, gray800 } = vars; - -/** - * Extract simple label and containment maps from triples. - * We detect part-of edges broadly to be resilient to label/id variants. - */ -function mapsFromTriples(triples) { - const idLabelMap = {}; - const parentToChildren = {}; - - for (const t of triples || []) { - const pred = (t?.predicate?.label || t?.predicate?.id || '').toLowerCase(); - - // collect labels - if (pred.endsWith('label') || pred.includes('rdfs:label')) { - if (t.subject?.id) { - idLabelMap[t.subject.id] = t.subject?.label || t.subject.id; - } - if (t.object?.id) { - idLabelMap[t.object.id] = t.object?.label || t.object.id; - } - continue; - } - - // connect part-of edges: child --partOf--> parent => parent -> child - if (pred.includes('partof')) { - const child = t?.subject?.id; - const parent = t?.object?.id; - if (child && parent) { - if (!parentToChildren[parent]) parentToChildren[parent] = []; - if (!parentToChildren[parent].includes(child)) parentToChildren[parent].push(child); - } - } +const CHILDREN = 'children'; +const SUPERCLASSES = 'superclasses'; + +// Find first tree item whose .iri matches focusIri +const findFirstRenderedId = (items = [], focusIri) => { + const stack = [...items]; + while (stack.length) { + const node = stack.shift(); + if (node?.iri === focusIri) return node.id; + if (Array.isArray(node?.children)) stack.push(...node.children); } + return null; +}; - return { idLabelMap, parentToChildren }; -} - -function buildTree(rootId, mapping, idLabelMap) { - const visited = new Set(); - const build = (id) => { - if (visited.has(id)) { - return { id, label: idLabelMap[id] || id, children: [], isCycle: true }; - } - visited.add(id); - const kids = (mapping[id] || []).map(build); - return { id, label: idLabelMap[id] || id, children: kids }; - }; - return [build(rootId)]; -} +// Normalize "ILX:0100573" -> "http://uri.interlex.org/base/ilx_0100573" +const toIri = (idLike) => { + if (!idLike) return ""; + if (/^https?:\/\//i.test(idLike)) return idLike; + const m = String(idLike).match(/^ILX:(\d+)$/i); + if (m) return `http://uri.interlex.org/base/ilx_${m[1]}`; + return idLike; +}; const Hierarchy = ({ - options = [], - selectedValue, + options = { children: [], superclasses: [] }, // {children:[{id,label}], superclasses:[...]} + selectedValue, // { id, label } onSelect, - triplesChildren = [], - triplesSuperclasses = [], + // prebuilt trees (arrays of {id,label,iri,children}) + treeChildren = [], + treeSuperclasses = [], + loading = false, }) => { - const [type, setType] = React.useState("children"); - const [treeData, setTreeData] = React.useState([]); - const [loading, setLoading] = React.useState(false); + const [type, setType] = React.useState(SUPERCLASSES); // default to superclasses + const [currentId, setCurrentId] = React.useState(null); + // when selection or type changes, recompute which tree + currentId to show React.useEffect(() => { - const triples = type === "children" ? triplesChildren : triplesSuperclasses; - if (!selectedValue?.id || !Array.isArray(triples)) { - setTreeData([]); - return; - } - setLoading(true); - try { - const { idLabelMap, parentToChildren } = mapsFromTriples(triples); - let mapping = parentToChildren; - - // for superclasses we invert the direction (parent mapping becomes child->parents) - if (type === "superclasses") { - const childToParents = {}; - for (const [p, kids] of Object.entries(parentToChildren)) { - for (const kid of kids) { - if (!childToParents[kid]) childToParents[kid] = []; - if (!childToParents[kid].includes(p)) childToParents[kid].push(p); - } - } - mapping = childToParents; - } - - const tree = buildTree(selectedValue.id, mapping, idLabelMap); - setTreeData(tree); - } catch (e) { - console.error(e); - setTreeData([]); - } finally { - setLoading(false); - } - }, [selectedValue, type, triplesChildren, triplesSuperclasses]); + const focusIri = toIri(selectedValue?.id); + const items = type === CHILDREN ? treeChildren : treeSuperclasses; + setCurrentId(findFirstRenderedId(items, focusIri)); + }, [selectedValue, type, treeChildren, treeSuperclasses]); + + const items = type === CHILDREN ? treeChildren : treeSuperclasses; + const childCount = items?.[0]?.children?.length || 0; + + const handleSelectChange = (_event, value) => onSelect?.(value); + const gotoFirstOption = () => { + const opts = type === CHILDREN ? options.children : options.superclasses; + if (opts?.length) onSelect?.(opts[0]); + }; - const childCount = treeData?.[0]?.children?.length || 0; + const singleSearchOptions = type === CHILDREN ? options.children : options.superclasses; - const handleSelectChange = (_event, value) => { - onSelect?.(value); - }; + if (loading) { + return + + + } return ( @@ -124,53 +85,50 @@ const Hierarchy = ({ value={type} onChange={(v) => setType(v)} options={[ - { value: 'children', label: 'Children' }, - { value: 'superclasses', label: 'Superclasses' }, + { value: CHILDREN, label: 'Children' }, + { value: SUPERCLASSES, label: 'Superclasses' }, ]} /> - - - - + + + + - Total number of first generation {type === 'children' ? 'children' : 'superclasses'}: {childCount} + Total number of first generation {type === CHILDREN ? CHILDREN : SUPERCLASSES}: {childCount} ); }; Hierarchy.propTypes = { - options: PropTypes.arrayOf( - PropTypes.shape({ - label: PropTypes.string, - handler: PropTypes.string.isRequired, - id : PropTypes.string.isRequired, - }) - ), - selectedValue: PropTypes.shape({ - label: PropTypes.string, - id: PropTypes.string, + options: PropTypes.shape({ + children: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, label: PropTypes.string })), + superclasses: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, label: PropTypes.string })), }), + selectedValue: PropTypes.shape({ id: PropTypes.string, label: PropTypes.string }), onSelect: PropTypes.func, - triplesChildren: PropTypes.array, - triplesSuperclasses: PropTypes.array, + treeChildren: PropTypes.array, // array of TreeItem + treeSuperclasses: PropTypes.array, // array of TreeItem + loading: PropTypes.bool, }; export default Hierarchy; diff --git a/src/components/SingleTermView/OverView/OverView.jsx b/src/components/SingleTermView/OverView/OverView.jsx index 70c91a89..4ff4c96e 100644 --- a/src/components/SingleTermView/OverView/OverView.jsx +++ b/src/components/SingleTermView/OverView/OverView.jsx @@ -1,8 +1,8 @@ +// SingleTermView/OverView/OverView.jsx import { Box, Divider, Grid, - CircularProgress, } from "@mui/material"; import Details from "./Details"; import { debounce } from "lodash"; @@ -17,35 +17,40 @@ import { getTermHierarchies, getTermPredicates, } from "../../../api/endpoints/apiService"; -import { toHierarchyOptionsFromTriples } from "../../../parsers/hierarchies-parser"; -const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = "base" }) => { +import { + toHierarchyOptionsFromTriples, + buildChildrenTreeFromTriples, + buildSuperclassesTreeFromTriples, + dedupePredicateGroups +} from "../../../parsers/hierarchies-parser"; + +const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, group = "base" }) => { const [data, setData] = useState(null); const [pageLoading, setPageLoading] = useState(true); const [jsonData, setJsonData] = useState(null); - // options for Hierarchy’s SingleSearch - const [hierarchyOptions, setHierarchyOptions] = useState([]); - const [selectedValue, setSelectedValue] = useState(null); + // SingleSearch options + selection + const [hierarchyOptions, setHierarchyOptions] = useState({}); + const [selectedValue, setSelectedValue] = useState(null); // {id, label} - // hierarchies (triples) for the currently selected value - const [triplesChildren, setTriplesChildren] = useState([]); - const [triplesSuperclasses, setTriplesSuperclasses] = useState([]); + // computed trees + const [treeChildren, setTreeChildren] = useState([]); + const [treeSuperclasses, setTreeSuperclasses] = useState([]); - // predicates panel data (already gold-standard shaped) + // predicates const [predicateGroups, setPredicateGroups] = useState([]); - // fine-grained loading states - const [loadingHierarchies, setLoadingHierarchies] = useState(false); - const [loadingPredicates, setLoadingPredicates] = useState(false); + // loading flags + const [loadingHierarchies, setLoadingHierarchies] = useState(true); + const [loadingPredicates, setLoadingPredicates] = useState(true); - // ---------- FIX: debounced search with explicit deps ---------- + // debounced search (explicit deps to satisfy eslint) const debouncedFetchTerms = useMemo( () => debounce(async (term, groupname) => { if (!term) { setData(null); - setHierarchyOptions([]); setSelectedValue(null); setPageLoading(false); return; @@ -53,21 +58,33 @@ const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = " try { const apiData = await getMatchTerms(groupname, term); const results = apiData?.results || []; - setData(results?.[0] || null); - - // keep selection if still present; else first result - setSelectedValue((prev) => - prev && results.some((o) => o.id === prev?.id) ? prev : results[0] || null - ); + const first = results?.[0] || null; + + // normalize first result -> { id, label } + let id = + first?.curie || + first?.ilx || + first?.id || + first?.termId || + first?.identifier || + null; + let label = + first?.label || + first?.rdfsLabel || + first?.prefLabel || + first?.term || + first?.name || + id; + + if (id) setSelectedValue({ id, label }); + setData(first); } finally { setPageLoading(false); } }, 300), [] ); - // ------------------------------------------------------------- - // JSON-LD for raw viewer const fetchJSONFile = useCallback(() => { if (!searchTerm) { setJsonData(null); @@ -78,38 +95,48 @@ const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = " }); }, [searchTerm, group]); - // Fetch hierarchies for the selected value (superclasses for now) + // normalize ID for API calls + const toILX = (curieLike) => { + let t = (curieLike || "").split("/").pop() || curieLike; + return t.replace(/^ilx_/i, "ILX:"); + }; + + // fetch both directions + compute trees + build options const fetchHierarchies = useCallback(async (curieLike, groupname) => { setLoadingHierarchies(true); try { - let termId = curieLike.split("/").pop() || curieLike; - termId = termId.replace(/^ilx_/, "ILX:"); + const termId = toILX(curieLike); - const superRes = await getTermHierarchies({ groupname, termId, objToSub: false }); - const triples = superRes?.triples || []; - setTriplesSuperclasses(triples); + const [childRes, superRes] = await Promise.all([ + getTermHierarchies({ groupname, termId, objToSub: true }), + getTermHierarchies({ groupname, termId, objToSub: false }), + ]); - // Build SingleSearch options from the triples we got back - const deduped = toHierarchyOptionsFromTriples(triples); - setHierarchyOptions(deduped); + const childTriples = childRes?.triples || []; + const superTriples = superRes?.triples || []; + + // trees for the currently selected ID + setTreeChildren(buildChildrenTreeFromTriples(childTriples, termId)); + setTreeSuperclasses(buildSuperclassesTreeFromTriples(superTriples, termId)); + + // update SingleSearch options (union of both) + const children = toHierarchyOptionsFromTriples(childTriples); + const superclasses = toHierarchyOptionsFromTriples(superTriples); + setHierarchyOptions({children: children, superclasses: superclasses}); + setLoadingHierarchies(false); } catch (e) { console.error("fetchHierarchies error:", e); - setTriplesChildren([]); - setTriplesSuperclasses([]); + setTreeChildren([]); + setTreeSuperclasses([]); setHierarchyOptions([]); - } finally { setLoadingHierarchies(false); } }, []); - // Fetch predicates for the Predicates panel const fetchPredicates = useCallback(async (curieLike, groupname) => { setLoadingPredicates(true); try { - // Normalize to ILX:NNNN… for the endpoint - let termId = curieLike.split("/").pop() || curieLike; - termId = termId.replace(/^ilx_/i, "ILX:"); - + const termId = toILX(curieLike); const groups = await getTermPredicates({ groupname, termId }); setPredicateGroups(groups || []); } catch (e) { @@ -120,32 +147,36 @@ const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = " } }, []); - // bootstrapping on search change useEffect(() => { setPageLoading(true); + setLoadingHierarchies(true); + setLoadingPredicates(true); debouncedFetchTerms(searchTerm, group); fetchJSONFile(); - return () => { - debouncedFetchTerms.cancel(); - }; + return () => debouncedFetchTerms.cancel(); }, [searchTerm, group, debouncedFetchTerms, fetchJSONFile]); - // react to selection changes useEffect(() => { if (selectedValue?.id) { - // Trigger both in parallel; each has its own loading state - fetchPredicates(selectedValue.id, "base"); fetchHierarchies(selectedValue.id, "base"); + fetchPredicates(selectedValue.id, "base"); } else { - setTriplesChildren([]); - setTriplesSuperclasses([]); + setTreeChildren([]); + setTreeSuperclasses([]); setPredicateGroups([]); setHierarchyOptions([]); } - }, [selectedValue, group, fetchHierarchies, fetchPredicates]); + }, [selectedValue, fetchHierarchies, fetchPredicates]); const memoData = useMemo(() => data, [data]); + const rawPredicates = [ + ...(Array.isArray(predicateGroups) ? predicateGroups : []), + ...(memoData && Array.isArray(memoData.predicates) ? memoData.predicates : []), + ]; + + const predicates = dedupePredicateGroups(rawPredicates); + return ( {isCodeViewVisible ? ( @@ -157,28 +188,17 @@ const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = " - {loadingHierarchies ? ( - - - - ) : ( - - )} + - {loadingPredicates ? ( - - - - ) : ( - - )} + diff --git a/src/components/SingleTermView/OverView/Predicates.jsx b/src/components/SingleTermView/OverView/Predicates.jsx index 314c3254..bd23e3ae 100644 --- a/src/components/SingleTermView/OverView/Predicates.jsx +++ b/src/components/SingleTermView/OverView/Predicates.jsx @@ -3,22 +3,29 @@ import PropTypes from "prop-types"; import ExpandIcon from "@mui/icons-material/Expand"; import RemoveIcon from "@mui/icons-material/Remove"; import PredicatesAccordion from "./PredicatesAccordion"; +import CircularProgress from '@mui/material/CircularProgress'; import { Box, Typography, ToggleButton, ToggleButtonGroup } from "@mui/material"; import { vars } from "../../../theme/variables"; const { gray800 } = vars; -const Predicates = ({ data, isGraphVisible }) => { +const Predicates = ({ data, isGraphVisible, loading }) => { const [toggleButtonValue, setToggleButtonValue] = React.useState("expand"); const predicates = React.useMemo(() => { if (Array.isArray(data)) return data; - if (Array.isArray(data?.predicates)) return data.predicates; + if (Array.isArray(data)) return data; return []; }, [data]); const onToggleButtonChange = (_e, v) => v && setToggleButtonValue(v); + if (loading) { + return + + + } + return ( @@ -47,6 +54,7 @@ const Predicates = ({ data, isGraphVisible }) => { Predicates.propTypes = { data: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), isGraphVisible: PropTypes.bool, + loading: PropTypes.bool }; export default Predicates; diff --git a/src/components/common/CustomizedTreeView.jsx b/src/components/common/CustomizedTreeView.jsx index 0648b07f..1cd75279 100644 --- a/src/components/common/CustomizedTreeView.jsx +++ b/src/components/common/CustomizedTreeView.jsx @@ -5,6 +5,7 @@ import { RichTreeView } from '@mui/x-tree-view/RichTreeView'; import { TreeItem, treeItemClasses } from '@mui/x-tree-view/TreeItem'; import ExpandLessOutlinedIcon from '@mui/icons-material/ExpandLessOutlined'; import ChevronRightOutlinedIcon from '@mui/icons-material/ChevronRightOutlined'; +import { useEffect, useMemo, useRef, useState } from "react"; import { vars } from "../../theme/variables"; const { gray500, brand200, brand50 } = vars; @@ -29,11 +30,12 @@ const StyledTreeItemBase = (props) => ( ); StyledTreeItemBase.propTypes = { - label: PropTypes.node, + label: PropTypes.string, currentId: PropTypes.string, - itemId: PropTypes.string, // provided by MUI TreeItem + itemId: PropTypes.string }; + const StyledTreeItem = styled(StyledTreeItemBase)(() => ({ color: gray500, [`& .${treeItemClasses.content}`]: { @@ -42,7 +44,49 @@ const StyledTreeItem = styled(StyledTreeItemBase)(() => ({ [`& .${treeItemClasses.groupTransition}`]: { marginLeft: 14 }, })); -const CustomizedTreeView = ({ items = [], loading = false, currentId = null }) => { +const kidsOf = (n, getItemChildren) => { + const k = getItemChildren(n); + return Array.isArray(k) ? k : []; +}; + +// depth-first: return ids from root → target; [] if not found +function findPathToId(nodes, targetId, getItemId, getItemChildren) { + for (const n of nodes || []) { + const id = getItemId(n); + if (id === targetId) return [id]; + const childPath = findPathToId(kidsOf(n, getItemChildren), targetId, getItemId, getItemChildren); + if (childPath.length) return [id, ...childPath]; + } + return []; +} + +const CustomizedTreeView = ({ + items = [], + loading = false, + currentId = null, + getItemId = (i) => i.id, + getItemLabel = (i) => i.label, + getItemChildren = (i) => i.children || [], +}) => { + const [expanded, setExpanded] = useState([]); + + // compute path to current node + const pathToCurrent = useMemo( + () => (currentId ? findPathToId(items, currentId, getItemId, getItemChildren) : []), + [items, currentId, getItemId, getItemChildren] + ); + + // only auto-expand when the path actually changes + const pathKey = useMemo(() => pathToCurrent.join('|'), [pathToCurrent]); + const lastPathKeyRef = useRef(''); + useEffect(() => { + if (pathKey && pathKey !== lastPathKeyRef.current) { + const ancestors = pathToCurrent.slice(0, -1); + setExpanded(ancestors); // set once per path change + lastPathKeyRef.current = pathKey; + } + }, [pathKey, pathToCurrent]); + if (loading) { return ( @@ -54,16 +98,18 @@ const CustomizedTreeView = ({ items = [], loading = false, currentId = null }) = return ( setExpanded(ids)} // user can toggle slots={{ item: StyledTreeItem, expandIcon: ChevronRightOutlinedIcon, - collapseIcon: ExpandLessOutlinedIcon - }} - slotProps={{ - item: { currentId }, + collapseIcon: ExpandLessOutlinedIcon, }} + slotProps={{ item: { currentId } }} /> ); }; @@ -72,6 +118,9 @@ CustomizedTreeView.propTypes = { items: PropTypes.array, loading: PropTypes.bool, currentId: PropTypes.string, + getItemId: PropTypes.func, + getItemLabel: PropTypes.func, + getItemChildren: PropTypes.func, }; export default CustomizedTreeView; diff --git a/src/parsers/hierarchies-parser.tsx b/src/parsers/hierarchies-parser.tsx index 6517a2a4..bde2aa2e 100644 --- a/src/parsers/hierarchies-parser.tsx +++ b/src/parsers/hierarchies-parser.tsx @@ -1,4 +1,3 @@ -// constants export const ILX_PART_OF = 'Is part of'; export const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; export const OWL_OBJECT_PROPERTY = 'owl:ObjectProperty'; @@ -7,7 +6,6 @@ export const RDFS_LABEL = 'http://www.w3.org/2000/01/rdf-schema#label'; export const PART_OF_IRI = 'http://uri.interlex.org/base/ilx_0112785'; type NodeRef = { id: string; label: string }; -type Triple = { subject: NodeRef; predicate: { id: string; label: string }; object: NodeRef }; type Edge = { from: NodeRef; to: NodeRef }; function firstString(v: any): string | undefined { @@ -42,36 +40,6 @@ export const toCurie = (id?: string) => { return m ? `ILX:${m[1]}` : id; }; -// turn superclasses triples into SingleSearch options -export const toHierarchyOptionsFromTriples = (triples: any[] = []) => { - const objectPropertySubjects = new Set( - triples - .filter(t => t?.predicate?.id === RDF_TYPE && t?.object?.id === OWL_OBJECT_PROPERTY) - .map(t => t?.subject?.id) - .filter(Boolean) - ); - - const seen = new Set(); - const out: Array<{label: string; handler: string; id: String}> = []; - - const add = (node: any) => { - const id = node?.id as string; - const label = (node?.label || '').trim(); - if (!id || !label) return; - const handler = toCurie(id) as string; - seen.add(handler); - out.push({ label, handler, id }); - }; - - for (const t of triples) { - // only harvest nodes that actually participate in the hierarchy relation somewhere - if (t?.predicate?.label === RDF_TYPE && t?.subject?.label !== ILX_PART_OF) { - if (t?.subject) add(t.subject); - } - } - return out; -}; - export function jsonldToTriplesAndEdges(jsonld: any): { triples: Triple[]; edges: Edge[] } { const graph: any[] = Array.isArray(jsonld) ? jsonld : @@ -122,4 +90,287 @@ export function jsonldToTriplesAndEdges(jsonld: any): { triples: Triple[]; edges } return { triples, edges }; -} \ No newline at end of file +} + +// parsers/hierarchies-parser.tsx +export type Triple = { + subject?: { id?: string; label?: string }; + predicate?: { id?: string; label?: string }; + object?: { id?: string; label?: string }; +}; + +export type TreeItem = { + id: string; // must be unique per node instance in the tree + label: string; + iri: string; // stable node identity (IRI), used to find the "current" item + children?: TreeItem[]; +}; + +// ---------- helpers ---------- +const CURIE_BASE = "http://uri.interlex.org/base/"; + +const toIri = (idLike: string): string => { + if (!idLike) return ""; + if (/^https?:\/\//i.test(idLike)) return idLike; + // ILX:0100573 -> http://uri.interlex.org/base/ilx_0100573 + const m = idLike.match(/^ILX:(\d+)$/i); + if (m) return `${CURIE_BASE}ilx_${m[1]}`; + return idLike; // fallback +}; + +const labelOf = (iri: string, labelMap: Record): string => { + if (labelMap[iri]) return labelMap[iri]; + // friendly fallback label from tail + try { + const tail = iri.split("/").pop() || iri; + return tail.replace(/^ilx_/i, "ILX:").toUpperCase(); + } catch { + return iri; + } +}; + +const isPartOf = (pred?: { id?: string; label?: string }) => { + const s = (pred?.label || pred?.id || "").toLowerCase(); + // match ilx.partOf or anything that includes 'partof' robustly + return s.includes("partof") || s.endsWith("0112785"); +}; + +// Collect node labels from any triple (subject/object labels are present even when the +// predicate is not rdfs:label). This makes us resilient to the sample payloads. +const buildLabelMap = (triples: Triple[]) => { + const map: Record = {}; + for (const t of triples) { + if (t.subject?.id) { + if (t.subject.label) map[t.subject.id] = t.subject.label; + else if (!map[t.subject.id]) map[t.subject.id] = t.subject.id; + } + if (t.object?.id) { + if (t.object.label) map[t.object.id] = t.object.label; + else if (!map[t.object.id]) map[t.object.id] = t.object.id; + } + } + return map; +}; + +// Extract edges child -> parent +const extractEdges = (triples: Triple[]) => { + const edges: Array<{ child: string; parent: string }> = []; + for (const t of triples) { + if (!isPartOf(t.predicate)) continue; + const child = toIri(t.subject?.id || ""); + const parent = toIri(t.object?.id || ""); + if (child && parent) edges.push({ child, parent }); + } + return edges; +}; + +// Ensure RichTreeView item ids are unique even when the same node appears in multiple branches. +// We derive a unique id from the path. +const makeItem = (iri: string, label: string, path: string[]): TreeItem => ({ + id: [...path, iri].join(" > "), // path-based unique id + label, + iri, +}); + +// Depth-first build from a mapping +const buildFrom = ( + rootIri: string, + childrenOf: Record, + labelMap: Record, + path: string[] = [] +): TreeItem => { + const item = makeItem(rootIri, labelOf(rootIri, labelMap), path); + const kids = childrenOf[rootIri] || []; + item.children = kids.map((kid) => + buildFrom(kid, childrenOf, labelMap, [...path, rootIri]) + ); + return item; +}; + +// Walk downwards but keep only branches that eventually reach `focusIri`. +const includesFocus = ( + iri: string, + childrenOf: Record, + focusIri: string, + memo = new Map() +): boolean => { + if (memo.has(iri)) return memo.get(iri)!; + if (iri === focusIri) { memo.set(iri, true); return true; } + const kids = childrenOf[iri] || []; + const res = kids.some((k) => includesFocus(k, childrenOf, focusIri, memo)); + memo.set(iri, res); + return res; +}; + +// ---------- API expected by OverView.jsx ---------- + +/** + * Options for SingleSearch derived from the triples in a direction. + * Returns [{ id, label }] + */ +export const toHierarchyOptionsFromTriples = (triples: Triple[] = []) => { + const labelMap = buildLabelMap(triples); + const ids = new Set(); + for (const t of triples) { + if (t.subject?.id) ids.add(toIri(t.subject.id)); + if (t.object?.id) ids.add(toIri(t.object.id)); + } + const out = Array.from(ids).map((iri) => ({ id: iri, label: labelOf(iri, labelMap) })); + // stable order: label asc + out.sort((a, b) => a.label.localeCompare(b.label)); + return out; +}; + +/** + * Build a tree where the selected focus is the ROOT and its descendants expand below. + */ +export const buildChildrenTreeFromTriples = (triples: Triple[] = [], focusIdLike: string) => { + const focusIri = toIri(focusIdLike); + const labelMap = buildLabelMap(triples); + const edges = extractEdges(triples); + + // parent -> children mapping (for "children" view we want focus -> descendants) + const childrenOf: Record = {}; + for (const { child, parent } of edges) { + if (!childrenOf[parent]) childrenOf[parent] = []; + if (!childrenOf[parent].includes(child)) childrenOf[parent].push(child); + } + + // If the focus has no entry in childrenOf, still create a solitary node + if (!childrenOf[focusIri]) childrenOf[focusIri] = []; + + // Build the subtree only from the focus downwards: + const pruneToFocus = (iri: string, seen = new Set()) => { + if (seen.has(iri)) return [] as string[]; // break cycles + seen.add(iri); + const kids = childrenOf[iri] || []; + const list: string[] = []; + for (const k of kids) { + list.push(k); + pruneToFocus(k, seen); + } + return list; + }; + pruneToFocus(focusIri); + + return [buildFrom(focusIri, childrenOf, labelMap)]; +}; + +/** + * Build a tree of ANCESTORS only (selected focus at the BOTTOM). + * We start at graph roots (nodes with no parents) and keep only branches that reach the focus. + */ +export const buildSuperclassesTreeFromTriples = (triples: Triple[] = [], focusIdLike: string) => { + const focusIri = toIri(focusIdLike); + const labelMap = buildLabelMap(triples); + const edges = extractEdges(triples); + + // Build child->parents and parent->children + const parentsOf: Record = {}; + const childrenOf: Record = {}; + + const allNodes = new Set(); + for (const { child, parent } of edges) { + allNodes.add(child); allNodes.add(parent); + if (!parentsOf[child]) parentsOf[child] = []; + if (!parentsOf[child].includes(parent)) parentsOf[child].push(parent); + + if (!childrenOf[parent]) childrenOf[parent] = []; + if (!childrenOf[parent].includes(child)) childrenOf[parent].push(child); + } + + // Focus might not appear in edges (edge case). Still create a trivial leaf. + allNodes.add(focusIri); + + // Keep only nodes on a path to focus + const memo = new Map(); + const onPathToFocus = (iri: string) => includesFocus(iri, childrenOf, focusIri, memo); + + // Roots: nodes with no parents in this subgraph + const roots = Array.from(allNodes).filter((n) => (parentsOf[n] || []).length === 0); + + // Build filtered tree(s) from roots down, pruning branches that don't reach focus + const buildFiltered = (iri: string, path: string[]): TreeItem | null => { + if (!onPathToFocus(iri)) return null; + const item = makeItem(iri, labelOf(iri, labelMap), path); + const kids = (childrenOf[iri] || []) + .map((k) => buildFiltered(k, [...path, iri])) + .filter(Boolean) as TreeItem[]; + + // In an ancestor tree, we want to stop exactly at the focus leaf (don’t expand its children) + if (iri === focusIri) { + item.children = []; + return item; + } + + item.children = kids; + return item; + }; + + const forest = roots + .map((r) => buildFiltered(r, [])) + .filter(Boolean) as TreeItem[]; + + // If nothing reached the focus (degenerate), just render the focus as a single node + if (!forest.length) { + forest.push(makeItem(focusIri, labelOf(focusIri, labelMap), [])); + } + return forest; +}; + +// --- add in OverView.jsx --- +const rowSig = (r = {}) => { + const s = r.subjectId ?? r.subject ?? ""; + const p = r.predicate ?? ""; // some rows won’t have this; ok + const o = r.objectId ?? r.object ?? ""; + return `${s}|${p}|${o}`; +}; + +const pickRowsArray = (g = {}) => + g.rows ?? g.values ?? g.tableData ?? g.edges ?? []; + +export const dedupePredicateGroups = (groups = []) => { + const byTitle = new Map(); + + for (const g of groups.filter(Boolean)) { + const key = String(g.title ?? "").trim().toLowerCase(); + if (!key) continue; + + if (!byTitle.has(key)) { + // normalize count + const base = { ...g }; + const rows = pickRowsArray(base); + base.count = Array.isArray(rows) ? rows.length : (base.count ?? 0); + byTitle.set(key, base); + continue; + } + + // merge with existing + const cur = byTitle.get(key); + const a = pickRowsArray(cur); + const b = pickRowsArray(g); + + const seen = new Set(a.map(rowSig)); + const merged = [...a]; + for (const r of (Array.isArray(b) ? b : [])) { + const sig = rowSig(r); + if (!seen.has(sig)) { + seen.add(sig); + merged.push(r); + } + } + + // put merged rows back into whichever property exists + if (cur.rows || g.rows) cur.rows = merged; + else if (cur.values || g.values) cur.values = merged; + else if (cur.tableData || g.tableData) cur.tableData = merged; + else cur.edges = merged; + + cur.forceGraph = cur.forceGraph || g.forceGraph; + cur.count = merged.length; + + byTitle.set(key, cur); + } + + return Array.from(byTitle.values()); +}; From fec7e4ceab50ce6aaecb1c1594d95a888be4f7a4 Mon Sep 17 00:00:00 2001 From: ddelpiano Date: Fri, 29 Aug 2025 17:16:41 +0200 Subject: [PATCH 4/6] fixing deployment --- nginx/default.conf | 4 ++-- vite.config.js | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/nginx/default.conf b/nginx/default.conf index a048d664..6af93430 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -8,8 +8,8 @@ server { # Proxy for Elasticsearch API requests location /api/elasticsearch { - proxy_pass https://scicrunch.org/api/1/elastic/Interlex_pr/_search; - proxy_set_header Host scicrunch.org; + proxy_pass https://api.scicrunch.io/elastic/v1/Interlex_pr/_search; + proxy_set_header Host api.scicrunch.io; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/vite.config.js b/vite.config.js index b3b39372..ca7b3561 100644 --- a/vite.config.js +++ b/vite.config.js @@ -38,6 +38,48 @@ export default defineConfig({ }); }, }, + '^/([^/]+)/priv/entity(.*)': { + target: "https://uri.olympiangods.org", + secure: false, + changeOrigin: true, + headers: { + "Content-Type": "application/json", + }, + configure: (proxy, _options) => { + console.log(_options); + proxy.on('error', (err, _req, _res) => { + console.log('proxy error', err); + console.log('proxy request', _req); + console.log('proxy response', _res); + }); + proxy.on('proxyReq', (proxyReq, req, _res) => { + console.log('Sending Request to the Target:', req.method, req.url); + console.log('Headers sent to backend:', proxyReq.getHeaders()); + console.log('Response:', _res); + console.log('Request:', proxyReq); + }); + proxy.on('proxyRes', (proxyRes, req, res) => { + console.log('Received response', res); + console.log('Received Response from the Target:', proxyRes.statusCode, req.url); + const location = proxyRes.headers['location']; + console.log('Received location', location); + if (proxyRes.statusCode === 303 && location) { + // Prevent browser from seeing the actual Location + delete proxyRes.headers['location']; + // Inject the location into a custom header we can use in Axios + res.setHeader('X-Redirect-Location', location); + } + + // Required for credentialed CORS + const origin = req.headers.origin; + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + } + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Expose-Headers', 'X-Redirect-Location'); + }); + }, + }, '^/([^/]+)/priv/(.*)': { target: "https://uri.olympiangods.org", secure: false, From d84c4d2fea2f2c01f3dbeea424ec055791db9d17 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Fri, 29 Aug 2025 08:47:23 -0700 Subject: [PATCH 5/6] feat: integrate mock service worker for API mocking during development --- src/main.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main.jsx b/src/main.jsx index 7d0676b1..566b0418 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,12 +1,15 @@ import './index.css' import App from './App'; import React from 'react' +import worker from './mock' import ReactDOM from 'react-dom/client' import { AuthProvider } from './../mock/mutator/auth.context'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { CookiesProvider } from 'react-cookie'; const queryClient = new QueryClient(); +worker.start(); + ReactDOM.createRoot(document.getElementById('root')).render( From 239bf18899a84d5844546f7ec0708699e37d5dde Mon Sep 17 00:00:00 2001 From: jrmartin Date: Fri, 29 Aug 2025 11:34:32 -0700 Subject: [PATCH 6/6] refactor: remove unused hierarchy endpoint and related mocks to prevent interference with real endpoint --- interlex.yaml | 43 ------------------- .../swaggerMockMissingEndpoints.msw.ts | 16 ------- .../endpoints/swaggerMockMissingEndpoints.ts | 14 ------ 3 files changed, 73 deletions(-) diff --git a/interlex.yaml b/interlex.yaml index 126323ca..6313d8f9 100644 --- a/interlex.yaml +++ b/interlex.yaml @@ -345,49 +345,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /{group}/query/transitive/{property}/{start}?depth: - get: - summary: Retrieve hierarchy results - operationId: get_hierarchy_results - tags: - - API - parameters: - - name: group - in: path - required: true - description: base group - schema: - type: string - - name: property - in: path - required: true - description: property - schema: - type: string - - name: start - in: path - required: true - description: start - schema: - type: string - responses: - 200: - description: A paged array of results - headers: - x-next: - description: A link to the next page of responses - schema: - type: string - content: - application/json: - schema: - $ref: '#/components/schemas/Hierarchies' - default: - description: unexpected error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' /{group}/addTerm: post: summary: Used to add a new term diff --git a/src/api/endpoints/swaggerMockMissingEndpoints.msw.ts b/src/api/endpoints/swaggerMockMissingEndpoints.msw.ts index 35c48ac1..769ae86a 100644 --- a/src/api/endpoints/swaggerMockMissingEndpoints.msw.ts +++ b/src/api/endpoints/swaggerMockMissingEndpoints.msw.ts @@ -1910,7 +1910,6 @@ export const getGetOrganizationsCuriesResponseMock = () => ((() => { }]; })()) -export const getGetHierarchyResultsResponseMock = (): Hierarchies => (Array.from({ length: faker.number.int({ min: 1, max: 10 }) }, (_, i) => i + 1).map(() => ([]))) export const getAddTermResponseMock = () => ((() => { return { @@ -4294,20 +4293,6 @@ export const getGetOrganizationsCuriesMockHandler = (overrideResponse?: Curies) }) } -export const getGetHierarchyResultsMockHandler = (overrideResponse?: Hierarchies) => { - return http.get('*/:group/query/transitive/:property/:start?depth', async () => { - await delay(1000); - return new HttpResponse(JSON.stringify(overrideResponse !== undefined ? overrideResponse : getGetHierarchyResultsResponseMock()), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - } - } - ) - }) -} - export const getAddTermMockHandler = (overrideResponse?: Error) => { return http.post('*/:group/addTerm', async () => { await delay(1000); @@ -4531,7 +4516,6 @@ export const getSwaggerMockMissingEndpointsMock = () => [ getGetOrganizationsTermsMockHandler(), getGetOrganizationsOntologiesMockHandler(), getGetOrganizationsCuriesMockHandler(), - getGetHierarchyResultsMockHandler(), getAddTermMockHandler(), getBulkEditTermsMockHandler(), getGetMatchTermsMockHandler(), diff --git a/src/api/endpoints/swaggerMockMissingEndpoints.ts b/src/api/endpoints/swaggerMockMissingEndpoints.ts index d7004d7e..302b5f6a 100644 --- a/src/api/endpoints/swaggerMockMissingEndpoints.ts +++ b/src/api/endpoints/swaggerMockMissingEndpoints.ts @@ -192,19 +192,6 @@ export const getOrganizationsCuries = ( options); } -/** - * @summary Retrieve hierarchy results - */ -export const getHierarchyResults = ( - group: string, - property: string, - start: string, - options?: SecondParameter,) => { - return customInstance( - {url: `/${group}/query/transitive/${property}/${start}?depth`, method: 'GET' - }, - options); - } /** * @summary Used to add a new term @@ -430,7 +417,6 @@ export type GetOrganizationsResult = NonNullable>> export type GetOrganizationsOntologiesResult = NonNullable>> export type GetOrganizationsCuriesResult = NonNullable>> -export type GetHierarchyResultsResult = NonNullable>> export type AddTermResult = NonNullable>> export type BulkEditTermsResult = NonNullable>> export type GetMatchTermsResult = NonNullable>>