From 14dd33dee3e9f595b88003c51826df07d6188ba9 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Mon, 11 Aug 2025 20:28:22 -0700 Subject: [PATCH 01/14] #89 - Implement getHierarchies --- src/api/endpoints/apiService.ts | 76 ++++++ src/components/GraphViewer/Graph.jsx | 186 +++++++-------- src/components/GraphViewer/GraphStructure.jsx | 133 +++++++---- .../OverView/CustomizedTable.jsx | 219 +++++++++++------- .../SingleTermView/OverView/Hierarchy.jsx | 163 ++++++++++--- .../SingleTermView/OverView/OverView.jsx | 165 +++++++++++-- .../SingleTermView/OverView/Predicates.jsx | 143 ++++++++---- .../OverView/PredicatesAccordion.jsx | 55 ++--- src/components/common/CustomizedTreeView.jsx | 126 ++++------ vite.config.js | 22 +- 10 files changed, 851 insertions(+), 437 deletions(-) diff --git a/src/api/endpoints/apiService.ts b/src/api/endpoints/apiService.ts index 8475050e..4534fd07 100644 --- a/src/api/endpoints/apiService.ts +++ b/src/api/endpoints/apiService.ts @@ -300,3 +300,79 @@ export const getTermDiscussions = async (group: string, variantID: string) => { export const getVariant = (group: string, term: string) => { return createGetRequest(`/${group}/variant/${term}`, "application/json")(); }; + +// Extract [subject, predicate, object] from the HTML table +function parseTransitiveHtml(html: string) { + // Browser-safe: use DOMParser + const doc = new DOMParser().parseFromString(html, 'text/html'); + const rows = Array.from(doc.querySelectorAll('table tr')).slice(1); // skip header + + const triples = rows + .map(tr => { + const tds = tr.querySelectorAll('td'); + if (tds.length < 3) return null; + + const subjLink = tds[0]?.querySelector('a'); + const predLink = tds[1]?.querySelector('a'); + const objLink = tds[2]?.querySelector('a'); + + const subject = { + id: subjLink?.getAttribute('href') || '', + label: (subjLink?.textContent || '').trim(), + }; + const predicate = { + id: predLink?.getAttribute('href') || '', + label: (predLink?.textContent || '').trim(), + }; + const object = objLink + ? { id: objLink.getAttribute('href') || '', label: (objLink.textContent || '').trim() } + : { id: '', label: (tds[2]?.textContent || '').trim() }; + + return { subject, predicate, object }; + }) + .filter(Boolean) as Array<{ + subject: { id: string; label: string }; + predicate: { id: string; label: string }; + object: { id: string; label: string }; + }>; + + // Optional: filter to just partOf edges + const edges = triples.filter(t => t.predicate.label.toLowerCase().includes('part of')) + .map(t => ({ from: t.subject, to: t.object })); + + return { triples, edges }; +} + +export const getTermHierarchies = async ({ + groupname, + termId, + objToSub = true, +}: { + groupname: string; + 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}`; + + try { + // Try JSON-LD first + const res1 = await createGetRequest(url1, 'application/ld+json')(); + if (typeof res1 !== 'string') return res1; // got JSON + + // If server ignored Accept and sent HTML, parse it + return parseTransitiveHtml(res1); + } catch { + // Try explicit .jsonld + try { + const res2 = await createGetRequest(url2, 'application/ld+json')(); + if (typeof res2 !== 'string') return res2; + // Still HTML? Parse it. + return parseTransitiveHtml(res2); + } catch (e2: any) { + console.error('Error in getTermHierarchies:', e2); + return { error: true, message: e2?.message || String(e2) }; + } + } +}; diff --git a/src/components/GraphViewer/Graph.jsx b/src/components/GraphViewer/Graph.jsx index eb5aeb1e..23231752 100644 --- a/src/components/GraphViewer/Graph.jsx +++ b/src/components/GraphViewer/Graph.jsx @@ -1,89 +1,71 @@ import * as d3 from "d3"; import PropTypes from "prop-types"; import { Box } from "@mui/material"; -import { useMemo, useEffect } from "react"; -import { getGraphStructure, PREDICATE, ROOT} from "./GraphStructure"; +import { useMemo, useEffect, useCallback } from "react"; +import { getGraphStructure, PREDICATE, ROOT } from "./GraphStructure"; import { vars } from "../../theme/variables"; const { gray600, white } = vars; const MARGIN = { top: 60, right: 60, bottom: 60, left: 60 }; const Graph = ({ width, height, predicate }) => { - const boundsWidth = width - (MARGIN.right + MARGIN.left * 4); - const boundsHeight = height - MARGIN.top - MARGIN.bottom; + const boundsWidth = Math.max(0, width - (MARGIN.right + MARGIN.left * 4)); + const boundsHeight = Math.max(0, height - MARGIN.top - MARGIN.bottom); - // Three function that change the tooltip when user hover / move / leave a cell - const mouseover = (event) => { - d3.select("#tooltip") - .html(event.currentTarget.id) - .style("opacity", 1) - d3.select("#tooltip") - } - const mousemove = (event) => { + const mouseover = useCallback((event) => { + d3.select("#tooltip").html(event.currentTarget.id).style("opacity", 1); + }, []); + const mousemove = useCallback((event) => { d3.select("#tooltip") .style("left", `${event.clientX + 10}px`) .style("top", `${event.clientY + 10}px`); - } - // eslint-disable-next-line no-unused-vars - const mouseleave = (d) => { - d3.select("#tooltip").style("opacity", 0) - } + }, []); + const mouseleave = useCallback(() => { + d3.select("#tooltip").style("opacity", 0); + }, []); + const onScroll = useCallback(() => { + d3.select("#tooltip").style("opacity", 0); + }, []); + // Attach hover handlers each time the visualization updates useEffect(() => { - // attach mouse listeners const nodes = d3.selectAll(".node--leaf-g"); - nodes - .on("mouseover", mouseover) - .on("mousemove", mousemove) - .on("mouseleave", mouseleave); - - // Hide tooltip on scroll - window.addEventListener("scroll", () => { - d3.select("#tooltip").style("opacity", 0); - }); + nodes.on("mouseover", mouseover).on("mousemove", mousemove).on("mouseleave", mouseleave); + window.addEventListener("scroll", onScroll); return () => { nodes.on("mouseover", null).on("mousemove", null).on("mouseleave", null); - window.removeEventListener("scroll", () => {}); + window.removeEventListener("scroll", onScroll); }; - }, []); + }, [predicate, mouseover, mousemove, mouseleave, onScroll]); const hierarchy = useMemo(() => { - const data = getGraphStructure(predicate) - return d3.hierarchy(data).sum((d) => d.value); + const data = getGraphStructure(predicate); + return d3.hierarchy(data).sum((d) => d.value || 1); }, [predicate]); const dendrogram = useMemo(() => { const dendrogramGenerator = d3.cluster().size([boundsHeight, boundsWidth]); return dendrogramGenerator(hierarchy); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hierarchy, width, height]); + }, [hierarchy, boundsHeight, boundsWidth]); const allNodes = dendrogram.descendants().map((node) => { - let textOffset = 0; - if (node.data.type === PREDICATE || node.data.type === ROOT) { - textOffset = -40; - } else { - textOffset = 5; - } - - const truncatedName = node.data.name.length > 25 - ? `${node.data.name.substring(0, 25)}...` - : node.data.name; - + const isGroup = node.data.type === PREDICATE || node.data.type === ROOT; + const textOffset = isGroup ? -40 : 5; + const rawName = getSafeString(node.data.name); + const truncatedName = rawName.length > 25 ? `${rawName.substring(0, 25)}...` : rawName; + return ( - + {truncatedName} @@ -93,73 +75,70 @@ const Graph = ({ width, height, predicate }) => { const allEdges = dendrogram.descendants().map((node, index) => { if (!node.parent) { - // Add a black circle at the root return ( ); } - + const line = d3 .line() - .x(d => d[0]) - .y(d => d[1]) + .x((d) => d[0]) + .y((d) => d[1]) .curve(d3.curveBundle.beta(0.75)); - + const start = [node.parent.y, node.parent.x]; const end = [node.y, node.x]; const radius = 5; - - const points = [ - start, - [start[0] - radius, end[1]], - end, - ]; - + + const points = [start, [start[0] - radius, end[1]], end]; + return ( ); }); return ( - - - - - - - - - + + + + + + + + { {allEdges} - - + ); }; +// defensive string conversion +function getSafeString(v) { + if (v == null) return "unknown"; + try { + const s = String(v); + return s.length ? s : "unknown"; + } catch { + return "unknown"; + } +} + Graph.propTypes = { width: PropTypes.number.isRequired, height: PropTypes.number.isRequired, - predicate: PropTypes.string.isRequired, + // now expects the full predicate group object (with rows/values/edges) + predicate: PropTypes.object.isRequired, }; export default Graph; diff --git a/src/components/GraphViewer/GraphStructure.jsx b/src/components/GraphViewer/GraphStructure.jsx index f6090c86..ed5c003b 100644 --- a/src/components/GraphViewer/GraphStructure.jsx +++ b/src/components/GraphViewer/GraphStructure.jsx @@ -3,58 +3,97 @@ export const PREDICATE = "predicate"; export const SUBJECT = "subject"; export const ROOT = "root"; -// TODO : Temporary until we get real data for predicates, right now parsing to make URLs -// fit on Graph -const getName = (nodeName) => { - let name = nodeName +// fallback label for empty strings/undefined +const getName = (name) => (name && String(name).trim()) || "unknown"; - if ( name == undefined ) { - return "name"; +// Build a single-root tree for a predicate group. +// Accepts groups shaped like: +// { +// title: "", +// count: number, +// rows: [{subject, subjectId, object, objectId}], +// values: same as rows (alias), +// edges: [{from:{id,label}, to:{id,label}, predicate:{id,label}}] +// } +export const getGraphStructure = (pred) => { + if (!pred) { + return { name: "unknown", id: "unknown", type: ROOT, value: 0, children: [] }; } - return name; -} + // Prefer rows/values; fall back to edges. + let rows = Array.isArray(pred.rows) && pred.rows.length ? pred.rows + : Array.isArray(pred.values) && pred.values.length ? pred.values + : []; -export const getGraphStructure = (pred) => { - let data = { - name : pred?.tableData[0]?.subject, - id : pred?.tableData[0]?.subject, - type : ROOT, - value : pred.count, - children : [] + if ((!rows || rows.length === 0) && Array.isArray(pred.edges)) { + rows = 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, + })); + } + + if (!rows || rows.length === 0) { + return { name: "unknown", id: "unknown", type: ROOT, value: 0, children: [] }; + } + + // Choose a single root subject: most frequent subject (by subjectId) + const counts = new Map(); + const firstLabelById = new Map(); + for (const r of rows) { + const key = r.subjectId || r.subject; + if (!key) continue; + counts.set(key, (counts.get(key) || 0) + 1); + if (!firstLabelById.has(key)) firstLabelById.set(key, r.subject || r.subjectId); + } + let rootKey = null; + let max = -1; + for (const [k, v] of counts.entries()) { + if (v > max) { max = v; rootKey = k; } + } + const rootLabel = getName(firstLabelById.get(rootKey) || rootKey); + + // Keep only rows for the chosen root + const rowsForRoot = rows.filter( + (r) => (r.subjectId || r.subject) === rootKey + ); + + // Predicate node (we’re already grouped by predicate) + const predicateLabel = getName(pred.title || "predicate"); + const predicateId = pred.title || "predicate"; + + // Unique objects under predicate + const seenObjects = new Set(); + const objectChildren = []; + for (const r of rowsForRoot) { + const objKey = r.objectId || r.object; + if (!objKey) continue; + if (seenObjects.has(objKey)) continue; + seenObjects.add(objKey); + objectChildren.push({ + name: getName(r.object), + id: objKey, + type: OBJECT, + children: [], + }); } - let uniqueObjects = []; - - pred?.tableData?.forEach( child => { - let newChild = { name : getName(child.object), id : child.object, type : OBJECT}; - - let getExistingObject = uniqueObjects?.find( c => c.id === child.object ); - if ( getExistingObject ) { - // Object already exists, just add it to the predicate's children - let getExistingPredicate = data.children?.find( c => c.id === child.predicate ); - if ( getExistingPredicate ) { - getExistingPredicate.children.push(newChild) - } - } else { - // New object, add it to uniqueObjects and process normally - uniqueObjects.push(newChild); - - let getExistingPredicate = data?.children?.find( c => c.id === child.predicate ); - if ( getExistingPredicate ) { - getExistingPredicate.children.push(newChild) - } else { - let newPredicate = { - name : getName(child.predicate), - id : child.predicate, - type : PREDICATE, - children : [newChild] - } - - data.children.push(newPredicate) - } - } - }) + // Root -> Predicate -> Objects + const data = { + name: rootLabel, + id: rootKey, + type: ROOT, + value: rowsForRoot.length || pred.count || 0, + children: [ + { + name: predicateLabel, + id: predicateId, + type: PREDICATE, + children: objectChildren, + }, + ], + }; return data; -} +}; diff --git a/src/components/SingleTermView/OverView/CustomizedTable.jsx b/src/components/SingleTermView/OverView/CustomizedTable.jsx index fc0fbec3..63d0bdf5 100644 --- a/src/components/SingleTermView/OverView/CustomizedTable.jsx +++ b/src/components/SingleTermView/OverView/CustomizedTable.jsx @@ -7,10 +7,9 @@ import { getMatchTerms } from "../../../api/endpoints"; import { Box, IconButton, Typography } from "@mui/material"; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import AddOutlinedIcon from '@mui/icons-material/AddOutlined'; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, useContext } from "react"; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import { GlobalDataContext } from "../../../contexts/DataContext"; -import { useContext } from "react"; import { vars } from "../../../theme/variables"; const { gray100, gray50, gray600, gray500, brand600, brand50, brand700, gray700 } = vars; @@ -20,11 +19,7 @@ const tableStyles = { display: 'flex', p: '0.75rem 0.5rem 0.5rem 0.5rem', borderBottom: `1px solid ${gray100}`, - - '& > .MuiBox-root': { - paddingRight: '0.75rem', - paddingLeft: 0 - }, + '& > .MuiBox-root': { paddingRight: '0.75rem', paddingLeft: 0 }, '& .MuiTypography-root': { color: gray600, fontWeight: 500, @@ -40,7 +35,6 @@ const tableStyles = { position: 'relative', borderBottom: `1px solid ${gray100}`, marginTop: '.25rem', - '& .MuiLink-root': { color: 'red', gap: '0.5rem', @@ -52,10 +46,7 @@ const tableStyles = { '& .MuiIconButton-root': { padding: '0', backgroundColor: 'transparent', - '& .MuiSvgIcon-root': { - fontSize: '1rem', - color: gray500, - }, + '& .MuiSvgIcon-root': { fontSize: '1rem', color: gray500 }, }, '& .MuiTypography-root': { color: gray700, @@ -79,7 +70,6 @@ const tableStyles = { background: gray50, borderColor: gray100, borderRadius: '0.5rem', - '&:before': { content: '""', height: '1.5rem', @@ -120,10 +110,7 @@ const tableStyles = { background: '#fff', boxShadow: '0px 1px 2px 0px rgba(16, 24, 40, 0.05)' }, - '& input': { - padding: '0.5rem 0.75rem', - height: '2.25rem' - }, + '& input': { padding: '0.5rem 0.75rem', height: '2.25rem' }, '& .Mui-focused': { border: '2px solid #1C5F54', background: '#F0F2F2', @@ -134,16 +121,71 @@ const tableStyles = { p: '0.5rem 0.75rem', background: 'transparent', color: brand700, - '&:hover': { - background: brand50, - color: brand700 - } + '&:hover': { background: brand50, color: brand700 } } }; +// ---------- helpers ---------- +const safe = (v) => (v == null ? "" : String(v)); + +/** + * Normalize incoming `data` to the legacy `tableData` shape the row renderer expects: + * [{ id, subject, predicate, object }] + * Supports: + * - data.tableData (legacy) + * - data.rows / data.values (groups built from getTermHierarchies) + * - data.edges (fallback) + */ +function normalizeTableData(data) { + if (!data) return []; + + // 1) legacy straight-through + if (Array.isArray(data.tableData) && data.tableData.length) { + // ensure id + return data.tableData.map((r, i) => ({ + id: r.id ?? `${safe(r.subject)}|${safe(r.predicate)}|${safe(r.object)}|${i}`, + subject: safe(r.subject), + predicate: safe(r.predicate), + object: safe(r.object), + })); + } + + // 2) rows / values from grouped predicates (we inject the group title as predicate) + const rows = Array.isArray(data.rows) && data.rows.length + ? data.rows + : (Array.isArray(data.values) ? data.values : []); + + if (rows && rows.length) { + return rows.map((r, i) => ({ + id: `${safe(r.subjectId || r.subject)}|${safe(data.title)}|${safe(r.objectId || r.object)}|${i}`, + subject: safe(r.subject || r.subjectId), + predicate: safe(data.title || "predicate"), + object: safe(r.object || r.objectId), + })); + } + + // 3) edges fallback + if (Array.isArray(data.edges) && data.edges.length) { + return data.edges.map((e, i) => { + const subj = e?.from?.label || e?.from?.id; + const obj = e?.to?.label || e?.to?.id; + const pred = e?.predicate?.label || e?.predicate?.id || data.title || "predicate"; + return { + id: `${safe(subj)}|${safe(pred)}|${safe(obj)}|${i}`, + subject: safe(subj), + predicate: safe(pred), + object: safe(obj), + }; + }); + } + + return []; +} + +// ---------- component ---------- const CustomizedTable = ({ data, term, isAddButtonVisible }) => { const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); - const [tableContent, setTableContent] = useState(data?.tableData); + const [tableContent, setTableContent] = useState(() => normalizeTableData(data)); const [tableHeader, setTableHeader] = useState([ { key: 'subject', label: 'Subject', allowSort: false, direction: 'desc' }, { key: 'predicate', label: 'Predicates', allowSort: false }, @@ -162,32 +204,27 @@ const CustomizedTable = ({ data, term, isAddButtonVisible }) => { const targetRow = useRef(); const sourceRow = useRef(); - const move = (arr, fromIndex, toIndex) => { - let element = arr[fromIndex]; - arr.splice(fromIndex, 1); - arr.splice(toIndex, 0, element); - return arr; - }; - const dragStart = (id, index) => { - sourceRow.current = { id, index }; - }; + // keep table in sync with prop `data` + useEffect(() => { + setTableContent(normalizeTableData(data)); + }, [data]); - const dragEnter = (id, index) => { - targetRow.current = { id, index }; + const move = (arr, fromIndex, toIndex) => { + const element = arr[fromIndex]; + const copy = [...arr]; + copy.splice(fromIndex, 1); + copy.splice(toIndex, 0, element); + return copy; }; + const dragStart = (id, index) => { sourceRow.current = { id, index }; }; + const dragEnter = (id, index) => { targetRow.current = { id, index }; }; const onReorder = (source, target) => { - if (source.index === target.index) { - return; - } - const updatedContent = move([...tableContent], source.index, target.index); - setTableContent(updatedContent); - }; - - const dragEnd = () => { - onReorder(sourceRow.current, targetRow.current); + if (!source || !target || source.index === target.index) return; + setTableContent((prev) => move(prev, source.index, target.index)); }; + const dragEnd = () => onReorder(sourceRow.current, targetRow.current); const requestSort = (e, key) => { if (!key) return; @@ -195,52 +232,39 @@ const CustomizedTable = ({ data, term, isAddButtonVisible }) => { e.preventDefault(); let direction = 'asc'; - if (sortConfig.key === key && sortConfig.direction === 'asc') { - direction = 'desc'; - } else if (sortConfig.key === key && sortConfig.direction === 'desc') { - direction = 'asc'; - } + if (sortConfig.key === key && sortConfig.direction === 'asc') direction = 'desc'; + else if (sortConfig.key === key && sortConfig.direction === 'desc') direction = 'asc'; + setSortConfig({ key, direction }); - const updatedHeader = tableHeader.map((item) => { - if (item.key === key) { - return { ...item, direction }; - } - return item; - }); - setTableHeader(updatedHeader); + setTableHeader((prev) => + prev.map((item) => (item.key === key ? { ...item, direction } : item)) + ); setTableContent((prevContent) => { - const sortedContent = [...prevContent]; - sortedContent.sort((a, b) => { - if (a[key] < b[key]) return direction === 'asc' ? -1 : 1; - if (a[key] > b[key]) return direction === 'asc' ? 1 : -1; - return 0; + const sorted = [...prevContent].sort((a, b) => { + const av = safe(a[key]).toLowerCase(); + const bv = safe(b[key]).toLowerCase(); + const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }); + return direction === 'asc' ? cmp : -cmp; }); - return sortedContent; + return sorted; }); }; const getSortIcon = (key) => { const column = tableHeader.find((item) => item.key === key); - if (column && column.direction) { - return column.direction === 'asc' ? : ; + if (column?.direction) { + return column.direction === 'asc' + ? + : ; } return ; }; - const handleOpenEditTermDialog = () => { - setEditTermDialogOpen(true); - }; - - const handleCloseEditTermDialog = () => { - setEditTermDialogOpen(false); - }; - - const handleUndoDelete = () => { - console.log("Undo deletion!") - }; - + const handleOpenEditTermDialog = () => setEditTermDialogOpen(true); + const handleCloseEditTermDialog = () => setEditTermDialogOpen(false); + const handleUndoDelete = () => { /* no-op for now */ }; const handleSnackbarClose = (event, reason) => { if (reason === "clickaway") return; setSnackbarOpen(false); @@ -249,17 +273,15 @@ const CustomizedTable = ({ data, term, isAddButtonVisible }) => { // eslint-disable-next-line react-hooks/exhaustive-deps const fetchTerms = useCallback(debounce(async (searchTerm) => { const data = await getMatchTerms(user?.groupname, searchTerm); - setTerms(data?.results[0]); - }, 500), [getMatchTerms]); + setTerms(data?.results?.[0]); + }, 500), [user?.groupname]); useEffect(() => { - if (objectSearchTerm) { - fetchTerms(objectSearchTerm); - } + if (objectSearchTerm) fetchTerms(objectSearchTerm); }, [objectSearchTerm, fetchTerms]); const tableWidth = 800; - const columnWidth = "100%"; + const columnWidth = "100%"; // keeping your layout; adjust if needed return ( <> @@ -272,19 +294,17 @@ const CustomizedTable = ({ data, term, isAddButtonVisible }) => { requestSort(e, head.key)} - sx={{ - transition: 'opacity 0.3s', - marginLeft: '0.5rem' - }} + sx={{ transition: 'opacity 0.3s', marginLeft: '0.5rem' }} > {getSortIcon(head.key)} )} ))} - + - {tableContent.map((row, index) => + + {(tableContent || []).map((row, index) => ( { onDragEnter={dragEnter} onDragEnd={dragEnd} /> - )} + ))} + {isAddButtonVisible && ( @@ -306,14 +327,32 @@ const CustomizedTable = ({ data, term, isAddButtonVisible }) => { )} - - + + + ); }; CustomizedTable.propTypes = { - data: PropTypes.object, + data: PropTypes.shape({ + title: PropTypes.string, + count: PropTypes.number, + tableData: PropTypes.array, // legacy + rows: PropTypes.array, // new + values: PropTypes.array, // new alias + edges: PropTypes.array // fallback + }), term: PropTypes.string, isAddButtonVisible: PropTypes.bool }; diff --git a/src/components/SingleTermView/OverView/Hierarchy.jsx b/src/components/SingleTermView/OverView/Hierarchy.jsx index e1cf61da..aa6f248f 100644 --- a/src/components/SingleTermView/OverView/Hierarchy.jsx +++ b/src/components/SingleTermView/OverView/Hierarchy.jsx @@ -7,55 +7,150 @@ import { } from "@mui/material"; import { vars } from "../../../theme/variables"; import React from "react"; -import { RestartAlt, TargetCross} from "../../../Icons"; +import { RestartAlt, TargetCross } from "../../../Icons"; import SingleSearch from "../SingleSearch"; import CustomizedTreeView from "../../common/CustomizedTreeView"; import CustomSingleSelect from "../../common/CustomSingleSelect"; const { gray600, gray800 } = vars; -const options = [ - { label: 'Oliver Hansen', handler: 'Oliver'}, - { label: 'April Tucker', handler: 'April'}, - { label: 'Van Henry', handler: 'Van'}, - { label: 'Omar Alexander', handler: 'Omar'} -]; -const Hierarchy = () => { - const [type, setType] = React.useState('children'); - const [selectedValue, setSelectedValue] = React.useState(null); - - const handleSelectChange = (value) => { - setSelectedValue(value); + +// ---- Build maps from triples ---- +function mapsFromTriples(triples) { + const idLabelMap = {}; + const parentToChildren = {}; + + for (const t of triples) { + const pred = (t.predicate?.label || t.predicate?.id || "").toLowerCase(); + + // labels + 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; + continue; + } + + // part-of edges: subject --partOf--> object + if (pred.endsWith("ilx.partof:") || pred.includes("partof") || pred.includes("is part of")) { + 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); + } + } } + return { idLabelMap, parentToChildren }; +} + +// ---- Build a safe tree (avoid cycles) ---- +function buildTree(rootId, mapping, idLabelMap) { + const visited = new Set(); + + const build = (id) => { + if (visited.has(id)) { + return { id: `${id} (cycle)`, label: idLabelMap[id] || id, children: [] }; + } + visited.add(id); + const kids = (mapping[id] || []).map(build); + return { id, label: idLabelMap[id] || id, children: kids }; + }; + + return [build(rootId)]; +} + +const Hierarchy = ({ + options = [], + selectedValue, + onSelect, + triplesChildren = [], + triplesSuperclasses = [], +}) => { + const [type, setType] = React.useState("children"); // 'children' | 'superclasses' + const [treeData, setTreeData] = React.useState([]); + const [loading, setLoading] = React.useState(false); + + // Recompute tree whenever inputs change + React.useEffect(() => { + const triples = type === "children" ? triplesChildren : triplesSuperclasses; + if (!selectedValue?.handler || !Array.isArray(triples)) { + setTreeData([]); + return; + } + setLoading(true); + try { + const { idLabelMap, parentToChildren } = mapsFromTriples(triples); + + let mapping = parentToChildren; + if (type === "superclasses") { + // invert to show ancestors as children + const childToParents = {}; + for (const [parent, kids] of Object.entries(parentToChildren)) { + for (const kid of kids) { + if (!childToParents[kid]) childToParents[kid] = []; + if (!childToParents[kid].includes(parent)) childToParents[kid].push(parent); + } + } + mapping = childToParents; + } + + const tree = buildTree(selectedValue.handler, mapping, idLabelMap); + setTreeData(tree); + } catch (e) { + console.error(e); + setTreeData([]); + } finally { + setLoading(false); + } + }, [selectedValue, type, triplesChildren, triplesSuperclasses]); + + const childCount = treeData?.[0]?.children?.length || 0; + return ( - - + + Hierarchy - - Type: - setType(v)} options={[ - { - value: 'children', - label: 'Children', - }, - { - value: 'superclasses', - label: 'Superclasses', - }, - ]} /> + + + Type: + + setType(v)} + options={[ + { value: "children", label: "Children" }, + { value: "superclasses", label: "Superclasses" }, + ]} + /> - - - - - Total number of first generation children: 3 + + + + + Total number of first generation {type === "children" ? "children" : "superclasses"}: {childCount} + - )} + ); +}; -export default Hierarchy \ No newline at end of file +export default Hierarchy; diff --git a/src/components/SingleTermView/OverView/OverView.jsx b/src/components/SingleTermView/OverView/OverView.jsx index 41d4078d..75ed56ef 100644 --- a/src/components/SingleTermView/OverView/OverView.jsx +++ b/src/components/SingleTermView/OverView/OverView.jsx @@ -10,33 +10,146 @@ import Hierarchy from "./Hierarchy"; import Predicates from "./Predicates"; import RawDataViewer from "./RawDataViewer"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { getMatchTerms, getRawData } from "../../../api/endpoints/apiService"; +import { getMatchTerms, getRawData, getTermHierarchies } from "../../../api/endpoints/apiService"; + +// ---- HTML table -> triples (subject, predicate, object) ---- +function parseTransitiveHtml(html) { + try { + const doc = new DOMParser().parseFromString(html, "text/html"); + const rows = Array.from(doc.querySelectorAll("table tr")).slice(1); + const triples = []; + for (const tr of rows) { + const tds = tr.querySelectorAll("td"); + if (tds.length < 3) continue; + const subjLink = tds[0]?.querySelector("a"); + const predLink = tds[1]?.querySelector("a"); + const objLink = tds[2]?.querySelector("a"); + const subject = { + id: subjLink?.getAttribute("href") || (subjLink?.textContent || "").trim(), + label: (subjLink?.textContent || "").trim(), + }; + const predicate = { + id: predLink?.getAttribute("href") || (predLink?.textContent || "").trim(), + label: (predLink?.textContent || "").trim(), + }; + const object = objLink + ? { id: objLink.getAttribute("href") || "", label: (objLink.textContent || "").trim() } + : { id: (tds[2]?.textContent || "").trim(), label: (tds[2]?.textContent || "").trim() }; + triples.push({ subject, predicate, object }); + } + return triples; + } catch { + return []; + } +} + +// ---- Normalize any backend shape -> triples ---- +function extractTriples(result) { + if (result?.results?.bindings) { + return result.results.bindings.map((b) => ({ + subject: { id: b.subject?.value ?? "", label: b.subject?.value ?? "" }, + predicate: { id: b.predicate?.value ?? "", label: b.predicate?.value ?? "" }, + object: { id: b.object?.value ?? "", label: b.object?.value ?? "" }, + })); + } + if (Array.isArray(result?.triples)) return result.triples; + if (typeof result === "string") return parseTransitiveHtml(result); + if (Array.isArray(result)) return result; + return []; +} const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = "base" }) => { const [data, setData] = useState(null); const [loading, setLoading] = 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 + const [triplesChildren, setTriplesChildren] = useState([]); + const [triplesSuperclasses, setTriplesSuperclasses] = useState([]); + // eslint-disable-next-line react-hooks/exhaustive-deps const fetchTerms = useCallback( - debounce((searchTerm) => { - if (searchTerm) { - getMatchTerms(group, searchTerm).then(data => { - console.log("data from api call: ", data) - setData(data?.results?.[0]); + 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 { + setData(null); + setHierarchyOptions([]); + setSelectedValue(null); + setLoading(false); } }, 300), [group] ); const fetchJSONFile = useCallback(() => { + if (!searchTerm) { + setJsonData(null); + return; + } getRawData(group, searchTerm, 'jsonld').then(rawResponse => { setJsonData(rawResponse); - }) + }); }, [searchTerm, group]); + // Fetch hierarchies for selectedValue + const fetchHierarchies = useCallback(async (curieLike, groupname) => { + if (!curieLike) { + setTriplesChildren([]); + setTriplesSuperclasses([]); + return; + } + try { + const [resChildren, resSupers] = await Promise.all([ + getTermHierarchies({ groupname, termId: curieLike, objToSub: true }), + getTermHierarchies({ groupname, termId: curieLike, objToSub: false }), + ]); + setTriplesChildren(extractTriples(resChildren)); + setTriplesSuperclasses(extractTriples(resSupers)); + } catch (e) { + console.error("fetchHierarchies error:", e); + setTriplesChildren([]); + setTriplesSuperclasses([]); + } + }, []); + useEffect(() => { setLoading(true); fetchTerms(searchTerm); @@ -46,30 +159,52 @@ 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); + } else { + setTriplesChildren([]); + setTriplesSuperclasses([]); + } + }, [selectedValue, group, fetchHierarchies]); + const memoData = useMemo(() => data, [data]); return ( - - {isCodeViewVisible ? : + + {isCodeViewVisible ? ( + + ) : ( <>
- + - + - } + )} - ) + ); } OverView.propTypes = { diff --git a/src/components/SingleTermView/OverView/Predicates.jsx b/src/components/SingleTermView/OverView/Predicates.jsx index 4f8720a6..1633e91e 100644 --- a/src/components/SingleTermView/OverView/Predicates.jsx +++ b/src/components/SingleTermView/OverView/Predicates.jsx @@ -3,56 +3,121 @@ 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 { Box, Typography, ToggleButton, ToggleButtonGroup } from "@mui/material"; import { vars } from "../../../theme/variables"; + const { gray800 } = vars; -const Predicates = ({ data, isGraphVisible }) => { +function groupsFromTriples(triples) { + const byPred = new Map(); + for (const t of triples || []) { + const key = (t?.predicate?.label || t?.predicate?.id || "").trim() || "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 = ({ basePredicates = [], triplesChildren = [], triplesSuperclasses = [], isGraphVisible }) => { const [predicates, setPredicates] = React.useState([]); - const [toggleButtonValue, setToggleButtonValue] = React.useState('expand') + const [toggleButtonValue, setToggleButtonValue] = React.useState('expand'); - const onToggleButtonChange = (event, newValue) => { - if (newValue) { - setToggleButtonValue(newValue) - } - } + const onToggleButtonChange = (_event, newValue) => { + if (newValue) setToggleButtonValue(newValue); + }; React.useEffect(() => { - data?.predicates && setPredicates(data?.predicates) - }, [data]); - - return - - Predicates - - - - - - - - - + // 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]); + + return ( + + + Predicates + + + + + + + + + + + - - -} + ); +}; Predicates.propTypes = { - data: PropTypes.object, + basePredicates: PropTypes.array, + triplesChildren: PropTypes.array, + triplesSuperclasses: PropTypes.array, isGraphVisible: PropTypes.bool -} +}; export default Predicates; diff --git a/src/components/SingleTermView/OverView/PredicatesAccordion.jsx b/src/components/SingleTermView/OverView/PredicatesAccordion.jsx index 4a89081c..ed38e43b 100644 --- a/src/components/SingleTermView/OverView/PredicatesAccordion.jsx +++ b/src/components/SingleTermView/OverView/PredicatesAccordion.jsx @@ -26,14 +26,11 @@ const { gray600 } = vars; const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { const [toggleButtonValues, setToggleButtonValues] = useState(data?.map(() => 'tableView') || []); const [openViewDiagram, setOpenViewDiagram] = React.useState(false); - const [selectedItem, setSelectedItem] = useState(null) + const [selectedItem, setSelectedItem] = useState(null); const [expandedItems, setExpandedItems] = useState(data?.map(() => false) || []); const query = useQuery(); const term = query.get('searchTerm'); - const imgStyle = { width: '100%' }; - const imgPath = '/success.png'; - const onToggleButtonChange = (index) => (event, newValue) => { if (newValue) { event.preventDefault(); @@ -43,30 +40,31 @@ const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { setToggleButtonValues(newTabValues); } }; - const handleClickViewDiagram = (e, item) => { - setSelectedItem(item) + + const handleClickViewDiagram = (_e, item) => { + setSelectedItem(item); setOpenViewDiagram(true); }; - const handleCloseViewDiagram = () => { - setOpenViewDiagram(false); - }; + const handleCloseViewDiagram = () => setOpenViewDiagram(false); - const handleAccordionChange = (index) => (event, isExpanded) => { + const handleAccordionChange = (index) => (_event, isExpanded) => { const newExpandedItems = [...expandedItems]; newExpandedItems[index] = isExpanded; setExpandedItems(newExpandedItems); }; useEffect(() => { - const newToggleButtonValues = data?.map(() => "tableView") || []; + const newToggleButtonValues = data?.map((d) => (d.forceGraph ? "graphView" : "tableView")) || []; const newExpandedItems = data?.map(() => expandAllPredicates) || []; - setToggleButtonValues(newToggleButtonValues); setExpandedItems(newExpandedItems); }, [data, expandAllPredicates]); + // lightweight image for the dialog (existing behavior) + const imgStyle = { width: '100%' }; + const imgPath = '/success.png'; const image = new Image(); - image.onload = () => preview + image.onload = () => preview; image.src = imgPath; return ( @@ -79,11 +77,7 @@ const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { expanded={expandedItems[index] ?? false} onChange={handleAccordionChange(index)} square - sx={{ - "&.MuiPaper-root": { - backgroundColor: "transparent" - } - }} + sx={{ "&.MuiPaper-root": { backgroundColor: "transparent" } }} > } @@ -91,9 +85,7 @@ const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { id={`panel${index + 1}-header`} > - - {pred.title} - + {pred.title} @@ -116,12 +108,16 @@ const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { - ) : <>} + ) : null} {toggleButtonValues[index] === 'tableView' ? ( - + ) : ( @@ -129,10 +125,8 @@ const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { variant='outlined' onClick={(e) => handleClickViewDiagram(e, pred)} disableRipple - sx={{ - minWidth: 'auto', - alignSelf: 'flex-end' - }}> + sx={{ minWidth: 'auto', alignSelf: 'flex-end' }} + > @@ -140,16 +134,15 @@ const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { ))} - { - openViewDiagram && - } - + )} ); }; diff --git a/src/components/common/CustomizedTreeView.jsx b/src/components/common/CustomizedTreeView.jsx index cb692b54..86607116 100644 --- a/src/components/common/CustomizedTreeView.jsx +++ b/src/components/common/CustomizedTreeView.jsx @@ -1,89 +1,38 @@ -import {Box, Chip} from "@mui/material"; +import PropTypes from 'prop-types'; +import { Box, Chip, CircularProgress } from "@mui/material"; import { styled } from '@mui/material/styles'; 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 { vars } from "../../theme/variables"; -import {vars} from "../../theme/variables"; -const {gray500, brand200, brand50} = vars +const { gray500, brand200, brand50 } = vars; -const ITEMS = [ - { - id: '1', - label: 'Central nervous system', - children: [ - { id: '2', label: 'Hello' }, - { - id: '3', - label: 'Subtree with children', - children: [ - { id: '6', label: 'Hello' }, - { - id: '7', - label: 'Sub-subtree with children', - children: [ - { id: '9', label: 'Child 1' }, - { id: '10', label: 'Child 2' }, - { id: '11', label: 'Child 3' }, - ], - }, - { id: '8', label: 'Hello' }, - ], - }, - { id: '4', label: 'World' }, - { id: '5', label: 'Something something' }, - ], - }, - { - id: '12', - label: 'Main', - children: [ - { id: '22', label: 'Hello' }, - { - id: '32', - label: 'Subtree with children', - children: [ - { id: '62', label: 'Hello' }, - { - id: '72', - label: 'Sub-subtree with children', - children: [ - { id: '92', label: 'Child 1' }, - { id: '102', label: 'Child 2' }, - { id: '112', label: 'Child 3' }, - ], - }, - { id: '82', label: 'Hello' }, - ], - }, - { id: '42', label: 'World' }, - { id: '52', label: 'Something something' }, - ], - }, -]; - -const StyledTreeItem = styled((props) => ( +const StyledTreeItemBase = (props) => ( - {props.label} - { - props.itemId === '1' && - } - } + label={ + + {props.label} + {props.currentId && props.itemId === props.currentId && ( + + )} + + } /> -))(() => ({ - color: gray500, +); +const StyledTreeItem = styled(StyledTreeItemBase)(() => ({ + color: gray500, [`& .${treeItemClasses.content}`]: { [`& .${treeItemClasses.label}`]: { fontSize: '0.875rem', @@ -95,19 +44,36 @@ const StyledTreeItem = styled((props) => ( }, })); -const CustomizedTreeView = () => { +const CustomizedTreeView = ({ items = [], loading = false, currentId = null }) => { + if (loading) { + return ( + + + + ); + } + return ( ); -} +}; + +CustomizedTreeView.propTypes = { + items: PropTypes.array, + loading: PropTypes.bool, + currentId: PropTypes.string, +}; -export default CustomizedTreeView +export default CustomizedTreeView; diff --git a/vite.config.js b/vite.config.js index d8ee02cf..b3b39372 100644 --- a/vite.config.js +++ b/vite.config.js @@ -159,7 +159,27 @@ export default defineConfig({ res.setHeader('Access-Control-Allow-Credentials', 'true'); }); }, - } + }, + '^/[^/]+/query/transitive/.*': { + target: 'https://uri.olympiangods.org', + changeOrigin: true, + secure: false, + rewrite: (path) => path, // keep full path + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq, req) => { + // pass through auth/cookies if present + if (req.headers.authorization) proxyReq.setHeader('Authorization', req.headers.authorization); + if (req.headers.cookie) proxyReq.setHeader('Cookie', req.headers.cookie); + }); + proxy.on('proxyRes', (proxyRes, req, res) => { + // helpful CORS for credentialed requests in dev + 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'); + }); + }, + }, }, }, }); From 53fd732c28b04a08bf2acae7a8c06236ac939b9e Mon Sep 17 00:00:00 2001 From: jrmartin Date: Mon, 11 Aug 2025 20:31:38 -0700 Subject: [PATCH 02/14] feat: add new test ontologies and tasks for walking tests --- src/components/GraphViewer/Graph.jsx | 91 +++++-------- src/components/GraphViewer/GraphStructure.jsx | 127 ++++++++++-------- test/Task-1750186415576.json | 1 + test/Task-1750186591333.json | 1 + test/Task-1750270923445.json | 1 + test/ontologies.json | 8 ++ 6 files changed, 116 insertions(+), 113 deletions(-) create mode 100644 test/Task-1750186415576.json create mode 100644 test/Task-1750186591333.json create mode 100644 test/Task-1750270923445.json create mode 100644 test/ontologies.json diff --git a/src/components/GraphViewer/Graph.jsx b/src/components/GraphViewer/Graph.jsx index 23231752..4d20d7c5 100644 --- a/src/components/GraphViewer/Graph.jsx +++ b/src/components/GraphViewer/Graph.jsx @@ -27,11 +27,19 @@ const Graph = ({ width, height, predicate }) => { d3.select("#tooltip").style("opacity", 0); }, []); - // Attach hover handlers each time the visualization updates + const hierarchy = useMemo(() => { + const data = getGraphStructure(predicate); + return d3.hierarchy(data).sum((d) => d.value || 1); + }, [predicate]); + + const dendrogram = useMemo(() => { + const gen = d3.cluster().size([boundsHeight, boundsWidth]); + return gen(hierarchy); + }, [hierarchy, boundsHeight, boundsWidth]); + useEffect(() => { const nodes = d3.selectAll(".node--leaf-g"); nodes.on("mouseover", mouseover).on("mousemove", mousemove).on("mouseleave", mouseleave); - window.addEventListener("scroll", onScroll); return () => { nodes.on("mouseover", null).on("mousemove", null).on("mouseleave", null); @@ -39,68 +47,41 @@ const Graph = ({ width, height, predicate }) => { }; }, [predicate, mouseover, mousemove, mouseleave, onScroll]); - const hierarchy = useMemo(() => { - const data = getGraphStructure(predicate); - return d3.hierarchy(data).sum((d) => d.value || 1); - }, [predicate]); - - const dendrogram = useMemo(() => { - const dendrogramGenerator = d3.cluster().size([boundsHeight, boundsWidth]); - return dendrogramGenerator(hierarchy); - }, [hierarchy, boundsHeight, boundsWidth]); - const allNodes = dendrogram.descendants().map((node) => { const isGroup = node.data.type === PREDICATE || node.data.type === ROOT; const textOffset = isGroup ? -40 : 5; - const rawName = getSafeString(node.data.name); - const truncatedName = rawName.length > 25 ? `${rawName.substring(0, 25)}...` : rawName; + const label = String(node.data.name ?? "unknown"); + const truncated = label.length > 25 ? `${label.slice(0, 25)}...` : label; return ( - + - {truncatedName} + {truncated} ); }); - const allEdges = dendrogram.descendants().map((node, index) => { + const allEdges = dendrogram.descendants().map((node, i) => { if (!node.parent) { - return ( - - ); + return ; } - - const line = d3 - .line() - .x((d) => d[0]) - .y((d) => d[1]) - .curve(d3.curveBundle.beta(0.75)); - + const line = d3.line().x((d) => d[0]).y((d) => d[1]).curve(d3.curveBundle.beta(0.75)); const start = [node.parent.y, node.parent.x]; const end = [node.y, node.x]; - const radius = 5; - - const points = [start, [start[0] - radius, end[1]], end]; - + const points = [start, [start[0] - 5, end[1]], end]; return ( { ); }); + const hasChildren = + hierarchy && hierarchy.data && Array.isArray(hierarchy.data.children) && hierarchy.data.children.length > 0; + return ( - + { height={boundsHeight} transform={`translate(${[MARGIN.left, MARGIN.top].join(",")})`} > - {allNodes} - {allEdges} + {hasChildren ? ( + <> + {allNodes} + {allEdges} + + ) : ( + // fallback: show root only with a hint + + No graph data + + )} - ); }; -// defensive string conversion -function getSafeString(v) { - if (v == null) return "unknown"; - try { - const s = String(v); - return s.length ? s : "unknown"; - } catch { - return "unknown"; - } -} - Graph.propTypes = { width: PropTypes.number.isRequired, height: PropTypes.number.isRequired, - // now expects the full predicate group object (with rows/values/edges) + // IMPORTANT: now expects the predicate GROUP object (with rows/values/edges or legacy tableData) predicate: PropTypes.object.isRequired, }; diff --git a/src/components/GraphViewer/GraphStructure.jsx b/src/components/GraphViewer/GraphStructure.jsx index ed5c003b..99eb007e 100644 --- a/src/components/GraphViewer/GraphStructure.jsx +++ b/src/components/GraphViewer/GraphStructure.jsx @@ -3,30 +3,35 @@ export const PREDICATE = "predicate"; export const SUBJECT = "subject"; export const ROOT = "root"; -// fallback label for empty strings/undefined -const getName = (name) => (name && String(name).trim()) || "unknown"; +// tiny helpers +const safe = (v, fallback = "unknown") => { + if (v == null) return fallback; + const s = String(v).trim(); + return s.length ? s : fallback; +}; -// Build a single-root tree for a predicate group. -// Accepts groups shaped like: -// { -// title: "", -// count: number, -// rows: [{subject, subjectId, object, objectId}], -// values: same as rows (alias), -// edges: [{from:{id,label}, to:{id,label}, predicate:{id,label}}] -// } -export const getGraphStructure = (pred) => { - if (!pred) { - return { name: "unknown", id: "unknown", type: ROOT, value: 0, children: [] }; - } +// Normalize predicate group into a rows array: +// [{ subject, subjectId, object, objectId }] +function normalizeRows(pred) { + if (!pred) return []; - // Prefer rows/values; fall back to edges. - let rows = Array.isArray(pred.rows) && pred.rows.length ? pred.rows - : Array.isArray(pred.values) && pred.values.length ? pred.values - : []; + // 1) new shape from getTermHierarchies grouping + 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 + if (Array.isArray(pred.tableData) && pred.tableData.length) { + return pred.tableData.map(r => ({ + subject: r.subject, + subjectId: r.subject, // no id in legacy, use label + object: r.object, + objectId: r.object, + })); + } - if ((!rows || rows.length === 0) && Array.isArray(pred.edges)) { - rows = pred.edges.map((e) => ({ + // 3) 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, @@ -34,66 +39,72 @@ export const getGraphStructure = (pred) => { })); } - if (!rows || rows.length === 0) { - return { name: "unknown", id: "unknown", type: ROOT, value: 0, children: [] }; - } + return []; +} - // Choose a single root subject: most frequent subject (by subjectId) +// 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 key = r.subjectId || r.subject; - if (!key) continue; - counts.set(key, (counts.get(key) || 0) + 1); - if (!firstLabelById.has(key)) firstLabelById.set(key, r.subject || r.subjectId); + 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; - let max = -1; + let rootKey = null, max = -1; for (const [k, v] of counts.entries()) { if (v > max) { max = v; rootKey = k; } } - const rootLabel = getName(firstLabelById.get(rootKey) || rootKey); + 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 +export const getGraphStructure = (pred) => { + const rows = normalizeRows(pred); + if (!rows.length) { + return { name: "No data", id: "no-data", type: ROOT, value: 0, children: [] }; + } - // Keep only rows for the chosen root - const rowsForRoot = rows.filter( - (r) => (r.subjectId || r.subject) === rootKey - ); + const { key: rootKey, label: rootLabel } = pickRoot(rows); + const forRoot = rows.filter(r => (r.subjectId || r.subject) === rootKey); - // Predicate node (we’re already grouped by predicate) - const predicateLabel = getName(pred.title || "predicate"); - const predicateId = pred.title || "predicate"; + const predicateLabel = safe(pred?.title, "predicate"); + const predicateId = predicateLabel; - // Unique objects under predicate - const seenObjects = new Set(); - const objectChildren = []; - for (const r of rowsForRoot) { - const objKey = r.objectId || r.object; - if (!objKey) continue; - if (seenObjects.has(objKey)) continue; - seenObjects.add(objKey); - objectChildren.push({ - name: getName(r.object), - id: objKey, + 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: [], }); } - // Root -> Predicate -> Objects - const data = { - name: rootLabel, - id: rootKey, + return { + name: safe(rootLabel), + id: safe(rootKey), type: ROOT, - value: rowsForRoot.length || pred.count || 0, + value: forRoot.length || pred?.count || 0, children: [ { name: predicateLabel, id: predicateId, type: PREDICATE, - children: objectChildren, + children: objects, }, ], }; - - return data; }; diff --git a/test/Task-1750186415576.json b/test/Task-1750186415576.json new file mode 100644 index 00000000..af711ddb --- /dev/null +++ b/test/Task-1750186415576.json @@ -0,0 +1 @@ +{"identifier":"Task-1750186415576","type":"task","title":"Walk Test","devices":["2B6EE6","42A874","8B32E6","8BE4A0","9BB7A0"],"text":"Walk Test","steps":[{"identifier":"instruction","type":"instruction","title":"Walk Test","text":"Please take 10 steps at your fast pace once you hear the beep.","beepAfter":6}],"creationDate":"2025-06-17T18:53:35.576Z","difficulty":0,"captureAccelerometerData":"iWatch","daysSelected":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],"notificationScheduledTime":"2025-06-17T19:10:00Z"} \ No newline at end of file diff --git a/test/Task-1750186591333.json b/test/Task-1750186591333.json new file mode 100644 index 00000000..6fcb597c --- /dev/null +++ b/test/Task-1750186591333.json @@ -0,0 +1 @@ +{"identifier":"Task-1750186591333","type":"task","title":"Walk Test - Slow","devices":["2B6EE6","42A874","8B32E6","8BE4A0","9BB7A0"],"text":"Walk Test - Slow","steps":[{"identifier":"instruction","type":"instruction","title":"Walk Test - Slow","text":"Please take 10 steps at a slow pace once you hear the beep.","beepAfter":10}],"creationDate":"2025-06-17T18:56:31.333Z","difficulty":0,"captureAccelerometerData":"iWatch","daysSelected":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],"notificationScheduledTime":"2025-06-17T20:10:00Z"} \ No newline at end of file diff --git a/test/Task-1750270923445.json b/test/Task-1750270923445.json new file mode 100644 index 00000000..19c9396d --- /dev/null +++ b/test/Task-1750270923445.json @@ -0,0 +1 @@ +{"identifier":"Task-1750270923445","type":"task","title":"stay still for multiple tasks","devices":["088CB8"],"text":"s","steps":[{"identifier":"instruction","type":"instruction","title":"sdikn","text":"asvdf","beepAfter":30},{"identifier":"instruction","type":"instruction","title":"afd","text":"af","beepAfter":30},{"identifier":"exercise","type":"exercise","beepAfter":30,"exercises":["Exercises/ExerciseLibrary/Arm Strength/Arm Series 1/1.Bicep Curl_difficulty 1.MOV"]},{"identifier":"instruction","type":"instruction","title":"if","text":"dc","beepAfter":30}],"creationDate":"2025-06-18T18:22:03.445Z","difficulty":2,"captureAccelerometerData":"iPhone","daysSelected":[1],"notificationScheduledTime":null} \ No newline at end of file diff --git a/test/ontologies.json b/test/ontologies.json new file mode 100644 index 00000000..e12931f4 --- /dev/null +++ b/test/ontologies.json @@ -0,0 +1,8 @@ +{ + "title": "test ontology", + "subjects": [ + "http://purl.obolibrary.org/obo/BFO_0000001", + "http://purl.obolibrary.org/obo/IAO_0000001" + ] + } + \ No newline at end of file From 6827eb3293cedbe0f61b61cacf1a2029e9b2568c Mon Sep 17 00:00:00 2001 From: jrmartin Date: Mon, 11 Aug 2025 20:40:19 -0700 Subject: [PATCH 03/14] refactor: streamline style definitions and improve code readability --- .../OverView/CustomizedTable.jsx | 110 ++++-------------- .../SingleTermView/OverView/Hierarchy.jsx | 34 +++--- src/components/common/CustomizedTreeView.jsx | 20 ++-- 3 files changed, 53 insertions(+), 111 deletions(-) diff --git a/src/components/SingleTermView/OverView/CustomizedTable.jsx b/src/components/SingleTermView/OverView/CustomizedTable.jsx index 63d0bdf5..2a810c26 100644 --- a/src/components/SingleTermView/OverView/CustomizedTable.jsx +++ b/src/components/SingleTermView/OverView/CustomizedTable.jsx @@ -21,10 +21,7 @@ const tableStyles = { borderBottom: `1px solid ${gray100}`, '& > .MuiBox-root': { paddingRight: '0.75rem', paddingLeft: 0 }, '& .MuiTypography-root': { - color: gray600, - fontWeight: 500, - fontSize: '.75rem', - lineHeight: '1.125rem' + color: gray600, fontWeight: 500, fontSize: '.75rem', lineHeight: '1.125rem' } }, root: { @@ -36,91 +33,48 @@ const tableStyles = { borderBottom: `1px solid ${gray100}`, marginTop: '.25rem', '& .MuiLink-root': { - color: 'red', - gap: '0.5rem', - fontSize: '0.875rem', - lineHeight: '1.25rem', - fontWeight: 600, - textDecoration: 'none' + color: 'red', gap: '0.5rem', fontSize: '0.875rem', lineHeight: '1.25rem', fontWeight: 600, textDecoration: 'none' }, '& .MuiIconButton-root': { - padding: '0', - backgroundColor: 'transparent', + padding: '0', backgroundColor: 'transparent', '& .MuiSvgIcon-root': { fontSize: '1rem', color: gray500 }, }, '& .MuiTypography-root': { - color: gray700, - fontSize: '0.875rem', - fontWeight: 400, - lineHeight: '1.25rem', - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap" + color: gray700, fontSize: '0.875rem', fontWeight: 400, lineHeight: '1.25rem', + overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, '& > .MuiBox-root': { - display: 'flex', - alignItems: 'center', - minWidth: 0, - gap: '0.5rem', - paddingRight: '0.75rem', - paddingLeft: 0 + display: 'flex', alignItems: 'center', minWidth: 0, gap: '0.5rem', paddingRight: '0.75rem', paddingLeft: 0 }, '&:not(.secondary)': { '&:hover': { - background: gray50, - borderColor: gray100, - borderRadius: '0.5rem', + background: gray50, borderColor: gray100, borderRadius: '0.5rem', '&:before': { - content: '""', - height: '1.5rem', - width: '0.125rem', - background: brand600, - position: 'absolute', - left: '0rem', - top: '50%', - transform: 'translateY(-50%)', - margin: 'auto 0', - borderRadius: '0.1875rem' + content: '""', height: '1.5rem', width: '0.125rem', background: brand600, + position: 'absolute', left: '0rem', top: '50%', transform: 'translateY(-50%)', + margin: 'auto 0', borderRadius: '0.1875rem' }, } }, }, inputParentBox: { - width: '100%', - borderRadius: '0.5rem', - background: '#F0F2F2', + width: '100%', borderRadius: '0.5rem', background: '#F0F2F2', '&:before': { - content: '""', - height: '1.5rem', - width: '0.125rem', - background: brand600, - position: 'absolute', - left: '0rem', - top: '50%', - transform: 'translateY(-50%)', - margin: 'auto 0', - borderRadius: '0.1875rem' + content: '""', height: '1.5rem', width: '0.125rem', background: brand600, + position: 'absolute', left: '0rem', top: '50%', transform: 'translateY(-50%)', + margin: 'auto 0', borderRadius: '0.1875rem' } }, input: { '& .MuiOutlinedInput-root': { - borderRadius: '0.5rem', - fontSize: '0.875rem', - color: '#313534', - background: '#fff', - boxShadow: '0px 1px 2px 0px rgba(16, 24, 40, 0.05)' + borderRadius: '0.5rem', fontSize: '0.875rem', color: '#313534', + background: '#fff', boxShadow: '0px 1px 2px 0px rgba(16, 24, 40, 0.05)' }, '& input': { padding: '0.5rem 0.75rem', height: '2.25rem' }, - '& .Mui-focused': { - border: '2px solid #1C5F54', - background: '#F0F2F2', - color: '#313534' - } + '& .Mui-focused': { border: '2px solid #1C5F54', background: '#F0F2F2', color: '#313534' } }, confirmButton: { - p: '0.5rem 0.75rem', - background: 'transparent', - color: brand700, + p: '0.5rem 0.75rem', background: 'transparent', color: brand700, '&:hover': { background: brand50, color: brand700 } } }; @@ -128,20 +82,9 @@ const tableStyles = { // ---------- helpers ---------- const safe = (v) => (v == null ? "" : String(v)); -/** - * Normalize incoming `data` to the legacy `tableData` shape the row renderer expects: - * [{ id, subject, predicate, object }] - * Supports: - * - data.tableData (legacy) - * - data.rows / data.values (groups built from getTermHierarchies) - * - data.edges (fallback) - */ function normalizeTableData(data) { if (!data) return []; - - // 1) legacy straight-through if (Array.isArray(data.tableData) && data.tableData.length) { - // ensure id return data.tableData.map((r, i) => ({ id: r.id ?? `${safe(r.subject)}|${safe(r.predicate)}|${safe(r.object)}|${i}`, subject: safe(r.subject), @@ -149,12 +92,9 @@ function normalizeTableData(data) { object: safe(r.object), })); } - - // 2) rows / values from grouped predicates (we inject the group title as predicate) const rows = Array.isArray(data.rows) && data.rows.length ? data.rows : (Array.isArray(data.values) ? data.values : []); - if (rows && rows.length) { return rows.map((r, i) => ({ id: `${safe(r.subjectId || r.subject)}|${safe(data.title)}|${safe(r.objectId || r.object)}|${i}`, @@ -163,8 +103,6 @@ function normalizeTableData(data) { object: safe(r.object || r.objectId), })); } - - // 3) edges fallback if (Array.isArray(data.edges) && data.edges.length) { return data.edges.map((e, i) => { const subj = e?.from?.label || e?.from?.id; @@ -178,7 +116,6 @@ function normalizeTableData(data) { }; }); } - return []; } @@ -264,16 +201,17 @@ const CustomizedTable = ({ data, term, isAddButtonVisible }) => { const handleOpenEditTermDialog = () => setEditTermDialogOpen(true); const handleCloseEditTermDialog = () => setEditTermDialogOpen(false); - const handleUndoDelete = () => { /* no-op for now */ }; + const handleUndoDelete = () => {}; const handleSnackbarClose = (event, reason) => { if (reason === "clickaway") return; setSnackbarOpen(false); }; + // NOTE: rename inner var to avoid shadowing prop `data` (fixes react/prop-types lint) // eslint-disable-next-line react-hooks/exhaustive-deps const fetchTerms = useCallback(debounce(async (searchTerm) => { - const data = await getMatchTerms(user?.groupname, searchTerm); - setTerms(data?.results?.[0]); + const resp = await getMatchTerms(user?.groupname, searchTerm); + setTerms(resp?.results?.[0]); }, 500), [user?.groupname]); useEffect(() => { @@ -281,7 +219,7 @@ const CustomizedTable = ({ data, term, isAddButtonVisible }) => { }, [objectSearchTerm, fetchTerms]); const tableWidth = 800; - const columnWidth = "100%"; // keeping your layout; adjust if needed + const columnWidth = "100%"; return ( <> @@ -338,7 +276,7 @@ const CustomizedTable = ({ data, term, isAddButtonVisible }) => { open={snackbarOpen} handleClose={handleSnackbarClose} onUndoDelete={handleUndoDelete} - data={deletedObj} + data={{}} /> ); diff --git a/src/components/SingleTermView/OverView/Hierarchy.jsx b/src/components/SingleTermView/OverView/Hierarchy.jsx index aa6f248f..a596322a 100644 --- a/src/components/SingleTermView/OverView/Hierarchy.jsx +++ b/src/components/SingleTermView/OverView/Hierarchy.jsx @@ -7,6 +7,7 @@ import { } 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"; @@ -19,16 +20,14 @@ function mapsFromTriples(triples) { const idLabelMap = {}; const parentToChildren = {}; - for (const t of triples) { + for (const t of triples || []) { const pred = (t.predicate?.label || t.predicate?.id || "").toLowerCase(); - // labels 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; continue; } - // part-of edges: subject --partOf--> object if (pred.endsWith("ilx.partof:") || pred.includes("partof") || pred.includes("is part of")) { const child = t.subject?.id; const parent = t.object?.id; @@ -42,19 +41,14 @@ function mapsFromTriples(triples) { return { idLabelMap, parentToChildren }; } -// ---- Build a safe tree (avoid cycles) ---- function buildTree(rootId, mapping, idLabelMap) { const visited = new Set(); - const build = (id) => { - if (visited.has(id)) { - return { id: `${id} (cycle)`, label: idLabelMap[id] || id, children: [] }; - } + if (visited.has(id)) return { id: `${id} (cycle)`, label: idLabelMap[id] || id, children: [] }; visited.add(id); const kids = (mapping[id] || []).map(build); return { id, label: idLabelMap[id] || id, children: kids }; }; - return [build(rootId)]; } @@ -65,11 +59,10 @@ const Hierarchy = ({ triplesChildren = [], triplesSuperclasses = [], }) => { - const [type, setType] = React.useState("children"); // 'children' | 'superclasses' + const [type, setType] = React.useState("children"); const [treeData, setTreeData] = React.useState([]); const [loading, setLoading] = React.useState(false); - // Recompute tree whenever inputs change React.useEffect(() => { const triples = type === "children" ? triplesChildren : triplesSuperclasses; if (!selectedValue?.handler || !Array.isArray(triples)) { @@ -79,10 +72,8 @@ const Hierarchy = ({ setLoading(true); try { const { idLabelMap, parentToChildren } = mapsFromTriples(triples); - let mapping = parentToChildren; if (type === "superclasses") { - // invert to show ancestors as children const childToParents = {}; for (const [parent, kids] of Object.entries(parentToChildren)) { for (const kid of kids) { @@ -92,7 +83,6 @@ const Hierarchy = ({ } mapping = childToParents; } - const tree = buildTree(selectedValue.handler, mapping, idLabelMap); setTreeData(tree); } catch (e) { @@ -153,4 +143,20 @@ const Hierarchy = ({ ); }; +Hierarchy.propTypes = { + options: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + handler: PropTypes.string.isRequired, + }) + ), + selectedValue: PropTypes.shape({ + label: PropTypes.string, + handler: PropTypes.string, + }), + onSelect: PropTypes.func, + triplesChildren: PropTypes.array, + triplesSuperclasses: PropTypes.array, +}; + export default Hierarchy; diff --git a/src/components/common/CustomizedTreeView.jsx b/src/components/common/CustomizedTreeView.jsx index 86607116..0648b07f 100644 --- a/src/components/common/CustomizedTreeView.jsx +++ b/src/components/common/CustomizedTreeView.jsx @@ -20,10 +20,7 @@ const StyledTreeItemBase = (props) => ( className="rounded" variant="outlined" label={'Current Item'} - sx={{ - borderColor: brand200, - backgroundColor: brand50 - }} + sx={{ borderColor: brand200, backgroundColor: brand50 }} /> )} @@ -31,17 +28,18 @@ const StyledTreeItemBase = (props) => ( /> ); +StyledTreeItemBase.propTypes = { + label: PropTypes.node, + currentId: PropTypes.string, + itemId: PropTypes.string, // provided by MUI TreeItem +}; + const StyledTreeItem = styled(StyledTreeItemBase)(() => ({ color: gray500, [`& .${treeItemClasses.content}`]: { - [`& .${treeItemClasses.label}`]: { - fontSize: '0.875rem', - fontWeight: 400, - }, - }, - [`& .${treeItemClasses.groupTransition}`]: { - marginLeft: 14, + [`& .${treeItemClasses.label}`]: { fontSize: '0.875rem', fontWeight: 400 }, }, + [`& .${treeItemClasses.groupTransition}`]: { marginLeft: 14 }, })); const CustomizedTreeView = ({ items = [], loading = false, currentId = null }) => { From 9317de243c787a3c984a98bf667d810d1c144271 Mon Sep 17 00:00:00 2001 From: Jesus M Date: Mon, 11 Aug 2025 21:03:47 -0700 Subject: [PATCH 04/14] Update src/components/SingleTermView/OverView/Predicates.jsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/SingleTermView/OverView/Predicates.jsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/SingleTermView/OverView/Predicates.jsx b/src/components/SingleTermView/OverView/Predicates.jsx index 1633e91e..d0436f27 100644 --- a/src/components/SingleTermView/OverView/Predicates.jsx +++ b/src/components/SingleTermView/OverView/Predicates.jsx @@ -11,7 +11,12 @@ const { gray800 } = vars; function groupsFromTriples(triples) { const byPred = new Map(); for (const t of triples || []) { - const key = (t?.predicate?.label || t?.predicate?.id || "").trim() || "predicate"; +const FALLBACK_PREDICATE = "predicate"; + +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); } From 040e9a749c4559ffcb22c0d91cb7584128ebefdb Mon Sep 17 00:00:00 2001 From: Jesus M Date: Mon, 11 Aug 2025 21:04:04 -0700 Subject: [PATCH 05/14] Update src/components/SingleTermView/OverView/CustomizedTable.jsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/SingleTermView/OverView/CustomizedTable.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SingleTermView/OverView/CustomizedTable.jsx b/src/components/SingleTermView/OverView/CustomizedTable.jsx index 2a810c26..2b021410 100644 --- a/src/components/SingleTermView/OverView/CustomizedTable.jsx +++ b/src/components/SingleTermView/OverView/CustomizedTable.jsx @@ -276,7 +276,7 @@ const CustomizedTable = ({ data, term, isAddButtonVisible }) => { open={snackbarOpen} handleClose={handleSnackbarClose} onUndoDelete={handleUndoDelete} - data={{}} + data={deletedObj} /> ); From 76f2584c45ba73f06936e260684f07c179e505f8 Mon Sep 17 00:00:00 2001 From: Jesus M Date: Mon, 11 Aug 2025 21:04:22 -0700 Subject: [PATCH 06/14] Update src/components/GraphViewer/Graph.jsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/GraphViewer/Graph.jsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/GraphViewer/Graph.jsx b/src/components/GraphViewer/Graph.jsx index 4d20d7c5..aa5472f6 100644 --- a/src/components/GraphViewer/Graph.jsx +++ b/src/components/GraphViewer/Graph.jsx @@ -149,7 +149,13 @@ Graph.propTypes = { width: PropTypes.number.isRequired, height: PropTypes.number.isRequired, // IMPORTANT: now expects the predicate GROUP object (with rows/values/edges or legacy tableData) - predicate: PropTypes.object.isRequired, + predicate: PropTypes.shape({ + title: PropTypes.string, + rows: PropTypes.array, + values: PropTypes.array, + edges: PropTypes.array, + tableData: PropTypes.array, + }).isRequired, }; export default Graph; From ceb5b2c8f252be4c8f4c44368b15dfc358df817f Mon Sep 17 00:00:00 2001 From: Jesus M Date: Mon, 11 Aug 2025 21:04:48 -0700 Subject: [PATCH 07/14] Update src/api/endpoints/apiService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/api/endpoints/apiService.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/endpoints/apiService.ts b/src/api/endpoints/apiService.ts index 4534fd07..3390fdaf 100644 --- a/src/api/endpoints/apiService.ts +++ b/src/api/endpoints/apiService.ts @@ -338,7 +338,11 @@ function parseTransitiveHtml(html: string) { // Optional: filter to just partOf edges const edges = triples.filter(t => t.predicate.label.toLowerCase().includes('part of')) - .map(t => ({ from: t.subject, to: t.object })); + const edges = triples.filter(t => + PART_OF_LABELS.some(label => + t.predicate.label.toLowerCase().includes(label.toLowerCase()) + ) + ).map(t => ({ from: t.subject, to: t.object })); return { triples, edges }; } From 1f7a09630e1f7293ca9f6390c0510168185d86a8 Mon Sep 17 00:00:00 2001 From: Jesus M Date: Mon, 11 Aug 2025 21:05:06 -0700 Subject: [PATCH 08/14] Update src/components/SingleTermView/OverView/Hierarchy.jsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/SingleTermView/OverView/Hierarchy.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SingleTermView/OverView/Hierarchy.jsx b/src/components/SingleTermView/OverView/Hierarchy.jsx index a596322a..43fa1070 100644 --- a/src/components/SingleTermView/OverView/Hierarchy.jsx +++ b/src/components/SingleTermView/OverView/Hierarchy.jsx @@ -44,7 +44,7 @@ function mapsFromTriples(triples) { function buildTree(rootId, mapping, idLabelMap) { const visited = new Set(); const build = (id) => { - if (visited.has(id)) return { id: `${id} (cycle)`, label: idLabelMap[id] || id, children: [] }; + 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 }; From fcb5e61153abdcd849a43766a17c2a1ede4a1fcc Mon Sep 17 00:00:00 2001 From: jrmartin Date: Mon, 11 Aug 2025 21:17:09 -0700 Subject: [PATCH 09/14] refactor: enhance filtering logic for triples in apiService and clean up Predicates component --- src/api/endpoints/apiService.ts | 3 ++- src/components/SingleTermView/OverView/Predicates.jsx | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/api/endpoints/apiService.ts b/src/api/endpoints/apiService.ts index 3390fdaf..fbff2655 100644 --- a/src/api/endpoints/apiService.ts +++ b/src/api/endpoints/apiService.ts @@ -336,8 +336,9 @@ function parseTransitiveHtml(html: string) { object: { id: string; label: string }; }>; + const PART_OF_LABELS = ['part of'] + // Optional: filter to just partOf edges - const edges = triples.filter(t => t.predicate.label.toLowerCase().includes('part of')) const edges = triples.filter(t => PART_OF_LABELS.some(label => t.predicate.label.toLowerCase().includes(label.toLowerCase()) diff --git a/src/components/SingleTermView/OverView/Predicates.jsx b/src/components/SingleTermView/OverView/Predicates.jsx index d0436f27..9af95822 100644 --- a/src/components/SingleTermView/OverView/Predicates.jsx +++ b/src/components/SingleTermView/OverView/Predicates.jsx @@ -8,9 +8,6 @@ import { vars } from "../../../theme/variables"; const { gray800 } = vars; -function groupsFromTriples(triples) { - const byPred = new Map(); - for (const t of triples || []) { const FALLBACK_PREDICATE = "predicate"; function groupsFromTriples(triples) { From f0c2f8f0a051716e617ed734ff55a68e9dc9431b Mon Sep 17 00:00:00 2001 From: jrmartin Date: Mon, 11 Aug 2025 21:25:02 -0700 Subject: [PATCH 10/14] chore: remove obsolete task and ontology JSON files --- test/Task-1750186415576.json | 1 - test/Task-1750186591333.json | 1 - test/Task-1750270923445.json | 1 - test/ontologies.json | 8 -------- 4 files changed, 11 deletions(-) delete mode 100644 test/Task-1750186415576.json delete mode 100644 test/Task-1750186591333.json delete mode 100644 test/Task-1750270923445.json delete mode 100644 test/ontologies.json diff --git a/test/Task-1750186415576.json b/test/Task-1750186415576.json deleted file mode 100644 index af711ddb..00000000 --- a/test/Task-1750186415576.json +++ /dev/null @@ -1 +0,0 @@ -{"identifier":"Task-1750186415576","type":"task","title":"Walk Test","devices":["2B6EE6","42A874","8B32E6","8BE4A0","9BB7A0"],"text":"Walk Test","steps":[{"identifier":"instruction","type":"instruction","title":"Walk Test","text":"Please take 10 steps at your fast pace once you hear the beep.","beepAfter":6}],"creationDate":"2025-06-17T18:53:35.576Z","difficulty":0,"captureAccelerometerData":"iWatch","daysSelected":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],"notificationScheduledTime":"2025-06-17T19:10:00Z"} \ No newline at end of file diff --git a/test/Task-1750186591333.json b/test/Task-1750186591333.json deleted file mode 100644 index 6fcb597c..00000000 --- a/test/Task-1750186591333.json +++ /dev/null @@ -1 +0,0 @@ -{"identifier":"Task-1750186591333","type":"task","title":"Walk Test - Slow","devices":["2B6EE6","42A874","8B32E6","8BE4A0","9BB7A0"],"text":"Walk Test - Slow","steps":[{"identifier":"instruction","type":"instruction","title":"Walk Test - Slow","text":"Please take 10 steps at a slow pace once you hear the beep.","beepAfter":10}],"creationDate":"2025-06-17T18:56:31.333Z","difficulty":0,"captureAccelerometerData":"iWatch","daysSelected":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],"notificationScheduledTime":"2025-06-17T20:10:00Z"} \ No newline at end of file diff --git a/test/Task-1750270923445.json b/test/Task-1750270923445.json deleted file mode 100644 index 19c9396d..00000000 --- a/test/Task-1750270923445.json +++ /dev/null @@ -1 +0,0 @@ -{"identifier":"Task-1750270923445","type":"task","title":"stay still for multiple tasks","devices":["088CB8"],"text":"s","steps":[{"identifier":"instruction","type":"instruction","title":"sdikn","text":"asvdf","beepAfter":30},{"identifier":"instruction","type":"instruction","title":"afd","text":"af","beepAfter":30},{"identifier":"exercise","type":"exercise","beepAfter":30,"exercises":["Exercises/ExerciseLibrary/Arm Strength/Arm Series 1/1.Bicep Curl_difficulty 1.MOV"]},{"identifier":"instruction","type":"instruction","title":"if","text":"dc","beepAfter":30}],"creationDate":"2025-06-18T18:22:03.445Z","difficulty":2,"captureAccelerometerData":"iPhone","daysSelected":[1],"notificationScheduledTime":null} \ No newline at end of file diff --git a/test/ontologies.json b/test/ontologies.json deleted file mode 100644 index e12931f4..00000000 --- a/test/ontologies.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "title": "test ontology", - "subjects": [ - "http://purl.obolibrary.org/obo/BFO_0000001", - "http://purl.obolibrary.org/obo/IAO_0000001" - ] - } - \ No newline at end of file From bb4a5f0844878300c177e9e65a5200475a3ac30f Mon Sep 17 00:00:00 2001 From: jrmartin Date: Tue, 12 Aug 2025 10:24:54 -0700 Subject: [PATCH 11/14] feat: add proxy configuration for transitive query endpoint with CORS support --- nginx/default.conf | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/nginx/default.conf b/nginx/default.conf index 52ba2595..a048d664 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -125,4 +125,32 @@ server { autoindex on; alias /usr/share/nginx/html/static/; } + + # Proxy for transitive query endpoint + location ~ ^/[^/]+/query/transitive/.* { + proxy_pass https://uri.olympiangods.org; + proxy_set_header Host uri.olympiangods.org; + 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; + + # Pass through auth/cookies if present (mirrors Vite configure hook) + proxy_set_header Authorization $http_authorization; + proxy_set_header Cookie $http_cookie; + + proxy_ssl_verify off; + + # Helpful CORS for credentialed requests in dev + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Expose-Headers X-Redirect-Location always; + + # (Optional but handy for preflight) + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always; + add_header Access-Control-Allow-Headers $http_access_control_request_headers always; + return 204; + } + } + } From ae98162ecc9296a2e60be017d130d6139c4527a9 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Tue, 12 Aug 2025 10:51:21 -0700 Subject: [PATCH 12/14] feat: implement JSON-LD parsing for triples and edges in getTermHierarchies --- src/api/endpoints/apiService.ts | 61 ++---------------- src/api/endpoints/hiearchies-parser.ts | 85 ++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 57 deletions(-) create mode 100644 src/api/endpoints/hiearchies-parser.ts diff --git a/src/api/endpoints/apiService.ts b/src/api/endpoints/apiService.ts index fbff2655..acbd1280 100644 --- a/src/api/endpoints/apiService.ts +++ b/src/api/endpoints/apiService.ts @@ -1,6 +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' export interface LoginRequest { username: string @@ -301,53 +302,6 @@ export const getVariant = (group: string, term: string) => { return createGetRequest(`/${group}/variant/${term}`, "application/json")(); }; -// Extract [subject, predicate, object] from the HTML table -function parseTransitiveHtml(html: string) { - // Browser-safe: use DOMParser - const doc = new DOMParser().parseFromString(html, 'text/html'); - const rows = Array.from(doc.querySelectorAll('table tr')).slice(1); // skip header - - const triples = rows - .map(tr => { - const tds = tr.querySelectorAll('td'); - if (tds.length < 3) return null; - - const subjLink = tds[0]?.querySelector('a'); - const predLink = tds[1]?.querySelector('a'); - const objLink = tds[2]?.querySelector('a'); - - const subject = { - id: subjLink?.getAttribute('href') || '', - label: (subjLink?.textContent || '').trim(), - }; - const predicate = { - id: predLink?.getAttribute('href') || '', - label: (predLink?.textContent || '').trim(), - }; - const object = objLink - ? { id: objLink.getAttribute('href') || '', label: (objLink.textContent || '').trim() } - : { id: '', label: (tds[2]?.textContent || '').trim() }; - - return { subject, predicate, object }; - }) - .filter(Boolean) as Array<{ - subject: { id: string; label: string }; - predicate: { id: string; label: string }; - object: { id: string; label: string }; - }>; - - const PART_OF_LABELS = ['part of'] - - // Optional: filter to just partOf edges - const edges = triples.filter(t => - PART_OF_LABELS.some(label => - t.predicate.label.toLowerCase().includes(label.toLowerCase()) - ) - ).map(t => ({ from: t.subject, to: t.object })); - - return { triples, edges }; -} - export const getTermHierarchies = async ({ groupname, termId, @@ -362,21 +316,14 @@ export const getTermHierarchies = async ({ const url2 = `${base}.jsonld?obj-to-sub=${objToSub}`; try { - // Try JSON-LD first const res1 = await createGetRequest(url1, 'application/ld+json')(); - if (typeof res1 !== 'string') return res1; // got JSON - - // If server ignored Accept and sent HTML, parse it - return parseTransitiveHtml(res1); + return jsonldToTriplesAndEdges(res1); } catch { - // Try explicit .jsonld try { const res2 = await createGetRequest(url2, 'application/ld+json')(); - if (typeof res2 !== 'string') return res2; - // Still HTML? Parse it. - return parseTransitiveHtml(res2); + return jsonldToTriplesAndEdges(res2); } catch (e2: any) { - console.error('Error in getTermHierarchies:', e2); + console.error('getTermHierarchies failed', e2); return { error: true, message: e2?.message || String(e2) }; } } diff --git a/src/api/endpoints/hiearchies-parser.ts b/src/api/endpoints/hiearchies-parser.ts new file mode 100644 index 00000000..4ae132dd --- /dev/null +++ b/src/api/endpoints/hiearchies-parser.ts @@ -0,0 +1,85 @@ +// 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 PART_OF_IRI = 'http://uri.interlex.org/base/ilx_0112785'; + +type JsonLdNode = { + '@id': string; + '@type'?: string[] | string; + [k: string]: any; +}; + +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 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); +} + +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[] = + Array.isArray(jsonld) ? jsonld : + Array.isArray(jsonld?.['@graph']) ? jsonld['@graph'] : + jsonld?.['@id'] ? [jsonld] : []; + + // id → label map (prefer rdfs: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]); + if (lbl) labelById.set(id, lbl); + } + + const triples: Triple[] = []; + const edges: Edge[] = []; + + const addTriple = (s: string, p: string, oId?: string, oLabel?: string) => { + const subj = { id: s, label: labelById.get(s) ?? s }; + const pred = { id: p, label: p }; + const obj = oId ? { id: oId, label: labelById.get(oId) ?? oId } : { id: '', label: oLabel ?? '' }; + triples.push({ subject: subj, predicate: pred, object: obj }); + }; + + for (const n of graph) { + const s = n['@id']; if (!s) continue; + + // rdf:type + 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]); + 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])]) { + addTriple(s, PART_OF_IRI, o); + edges.push({ + from: { id: s, label: labelById.get(s) ?? s }, + to: { id: o, label: labelById.get(o) ?? o }, + }); + } + } + + return { triples, edges }; +} From 6cf659cef8b5640929db7c14ea4d0c05ef5e360e Mon Sep 17 00:00:00 2001 From: jrmartin Date: Tue, 19 Aug 2025 17:29:56 -0700 Subject: [PATCH 13/14] refactor: remove unused parseTransitiveHtml function and clean up hierarchy component --- .../SingleTermView/OverView/Hierarchy.jsx | 1 - .../SingleTermView/OverView/OverView.jsx | 38 ------------------- 2 files changed, 39 deletions(-) diff --git a/src/components/SingleTermView/OverView/Hierarchy.jsx b/src/components/SingleTermView/OverView/Hierarchy.jsx index 43fa1070..7b85a47b 100644 --- a/src/components/SingleTermView/OverView/Hierarchy.jsx +++ b/src/components/SingleTermView/OverView/Hierarchy.jsx @@ -15,7 +15,6 @@ import CustomSingleSelect from "../../common/CustomSingleSelect"; const { gray600, gray800 } = vars; -// ---- Build maps from triples ---- function mapsFromTriples(triples) { const idLabelMap = {}; const parentToChildren = {}; diff --git a/src/components/SingleTermView/OverView/OverView.jsx b/src/components/SingleTermView/OverView/OverView.jsx index 75ed56ef..a9cafdf5 100644 --- a/src/components/SingleTermView/OverView/OverView.jsx +++ b/src/components/SingleTermView/OverView/OverView.jsx @@ -12,36 +12,6 @@ import RawDataViewer from "./RawDataViewer"; import { useCallback, useEffect, useMemo, useState } from "react"; import { getMatchTerms, getRawData, getTermHierarchies } from "../../../api/endpoints/apiService"; -// ---- HTML table -> triples (subject, predicate, object) ---- -function parseTransitiveHtml(html) { - try { - const doc = new DOMParser().parseFromString(html, "text/html"); - const rows = Array.from(doc.querySelectorAll("table tr")).slice(1); - const triples = []; - for (const tr of rows) { - const tds = tr.querySelectorAll("td"); - if (tds.length < 3) continue; - const subjLink = tds[0]?.querySelector("a"); - const predLink = tds[1]?.querySelector("a"); - const objLink = tds[2]?.querySelector("a"); - const subject = { - id: subjLink?.getAttribute("href") || (subjLink?.textContent || "").trim(), - label: (subjLink?.textContent || "").trim(), - }; - const predicate = { - id: predLink?.getAttribute("href") || (predLink?.textContent || "").trim(), - label: (predLink?.textContent || "").trim(), - }; - const object = objLink - ? { id: objLink.getAttribute("href") || "", label: (objLink.textContent || "").trim() } - : { id: (tds[2]?.textContent || "").trim(), label: (tds[2]?.textContent || "").trim() }; - triples.push({ subject, predicate, object }); - } - return triples; - } catch { - return []; - } -} // ---- Normalize any backend shape -> triples ---- function extractTriples(result) { @@ -53,7 +23,6 @@ function extractTriples(result) { })); } if (Array.isArray(result?.triples)) return result.triples; - if (typeof result === "string") return parseTransitiveHtml(result); if (Array.isArray(result)) return result; return []; } @@ -131,18 +100,11 @@ const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = " // Fetch hierarchies for selectedValue const fetchHierarchies = useCallback(async (curieLike, groupname) => { - if (!curieLike) { - setTriplesChildren([]); - setTriplesSuperclasses([]); - return; - } try { const [resChildren, resSupers] = await Promise.all([ getTermHierarchies({ groupname, termId: curieLike, objToSub: true }), getTermHierarchies({ groupname, termId: curieLike, objToSub: false }), ]); - setTriplesChildren(extractTriples(resChildren)); - setTriplesSuperclasses(extractTriples(resSupers)); } catch (e) { console.error("fetchHierarchies error:", e); setTriplesChildren([]); From 14dc4455ab279726098ff7ff5c278db45c3ad630 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Tue, 19 Aug 2025 17:41:48 -0700 Subject: [PATCH 14/14] #89 - Fix lint --- .../SingleTermView/OverView/OverView.jsx | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/components/SingleTermView/OverView/OverView.jsx b/src/components/SingleTermView/OverView/OverView.jsx index a9cafdf5..c35bdc77 100644 --- a/src/components/SingleTermView/OverView/OverView.jsx +++ b/src/components/SingleTermView/OverView/OverView.jsx @@ -12,21 +12,6 @@ import RawDataViewer from "./RawDataViewer"; import { useCallback, useEffect, useMemo, useState } from "react"; import { getMatchTerms, getRawData, getTermHierarchies } from "../../../api/endpoints/apiService"; - -// ---- Normalize any backend shape -> triples ---- -function extractTriples(result) { - if (result?.results?.bindings) { - return result.results.bindings.map((b) => ({ - subject: { id: b.subject?.value ?? "", label: b.subject?.value ?? "" }, - predicate: { id: b.predicate?.value ?? "", label: b.predicate?.value ?? "" }, - object: { id: b.object?.value ?? "", label: b.object?.value ?? "" }, - })); - } - if (Array.isArray(result?.triples)) return result.triples; - if (Array.isArray(result)) return result; - return []; -} - const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = "base" }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -101,7 +86,7 @@ const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = " // Fetch hierarchies for selectedValue const fetchHierarchies = useCallback(async (curieLike, groupname) => { try { - const [resChildren, resSupers] = await Promise.all([ + await Promise.all([ getTermHierarchies({ groupname, termId: curieLike, objToSub: true }), getTermHierarchies({ groupname, termId: curieLike, objToSub: false }), ]);