diff --git a/src/components/SingleTermView/OverView/Hierarchy.jsx b/src/components/SingleTermView/OverView/Hierarchy.jsx index c3e9706e..5ac4be5e 100644 --- a/src/components/SingleTermView/OverView/Hierarchy.jsx +++ b/src/components/SingleTermView/OverView/Hierarchy.jsx @@ -39,35 +39,99 @@ const toIri = (idLike) => { return idLike; }; +// Collect all item ids that match a filter (id | label | iri) +const collectMatchingIds = (items = [], term = "") => { + if (!term) return []; + const q = String(term).toLowerCase(); + const out = new Set(); + const stack = [...items]; + while (stack.length) { + const node = stack.pop(); + const id = String(node?.id ?? ""); + const label = String(node?.label ?? ""); + const iri = String(node?.iri ?? ""); + if ( + id.toLowerCase().includes(q) || + label.toLowerCase().includes(q) || + iri.toLowerCase().includes(q) + ) out.add(id); + if (Array.isArray(node?.children)) stack.push(...node.children); + } + return [...out]; +}; + const Hierarchy = ({ - options = { children: [], superclasses: [] }, // {children:[{id,label}], superclasses:[...]} - selectedValue, // { id, label } + options = { children: [], superclasses: [] }, + selectedValue, onSelect, - // prebuilt trees (arrays of {id,label,iri,children}) treeChildren = [], treeSuperclasses = [], loading = false, }) => { - const [type, setType] = React.useState(SUPERCLASSES); // default to superclasses + const [type, setType] = React.useState(SUPERCLASSES); const [currentId, setCurrentId] = React.useState(null); + const [manualHighlightedIds, setManualHighlightedIds] = React.useState([]); + const hasSearchedRef = React.useRef(false); + const initialSelectedRef = React.useRef(selectedValue?.id || null); - // when selection or type changes, recompute which tree + currentId to show - React.useEffect(() => { - const focusIri = toIri(selectedValue?.id); - const items = type === CHILDREN ? treeChildren : treeSuperclasses; - setCurrentId(findFirstRenderedId(items, focusIri)); - }, [selectedValue, type, treeChildren, treeSuperclasses]); + const preSearchSelectionRef = React.useRef(null); + const [searchTerm, setSearchTerm] = React.useState(""); 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]); + React.useEffect(() => { + const focusIri = toIri(selectedValue?.id); + const renderedId = findFirstRenderedId(items, focusIri); + setCurrentId(renderedId); + }, [selectedValue, type, treeChildren, treeSuperclasses, items]); + + const handleSelectChange = (value) => { + if (!preSearchSelectionRef.current && selectedValue) { + preSearchSelectionRef.current = selectedValue; + } + onSelect?.(value); + hasSearchedRef.current = true; + + setSearchTerm(""); + setManualHighlightedIds([]); + + const focusIri = toIri(value?.id); + const idInTree = findFirstRenderedId(items, focusIri); + if (idInTree) setCurrentId(idInTree); }; + + const handleRefresh = () => { + const toRestore = preSearchSelectionRef.current || initialSelectedRef.current || selectedValue; + if (toRestore) { + onSelect?.(toRestore); + const iri = toIri(toRestore?.id); + const idInTree = findFirstRenderedId(items, iri); + setCurrentId(idInTree); + } + setSearchTerm(""); + setManualHighlightedIds([]); + hasSearchedRef.current = false; + preSearchSelectionRef.current = null; + }; + + const handleAimFocus = () => { + const base = hasSearchedRef.current + ? (selectedValue || preSearchSelectionRef.current || { id: initialSelectedRef.current }) + : (preSearchSelectionRef.current || selectedValue || { id: initialSelectedRef.current }); + + const focusIri = toIri(base?.id); + const id = findFirstRenderedId(items, focusIri); + + setCurrentId(id); + setManualHighlightedIds(id ? [id] : []); // <-- pass to tree as highlight + }; const singleSearchOptions = type === CHILDREN ? options.children : options.superclasses; + const highlightedIdsFromSearch = React.useMemo( + () => collectMatchingIds(items, searchTerm), + [items, searchTerm] + ); if (loading) { return @@ -75,6 +139,7 @@ const Hierarchy = ({ } + return ( @@ -83,17 +148,32 @@ const Hierarchy = ({ Type: setType(v)} + onChange={(v) => { + setType(v); + // reset only the visual search highlight when switching trees + setSearchTerm(""); + }} options={[ { value: CHILDREN, label: 'Children' }, { value: SUPERCLASSES, label: 'Superclasses' }, ]} /> - - @@ -105,11 +185,17 @@ const Hierarchy = ({ options={singleSearchOptions} /> + @@ -126,8 +212,8 @@ Hierarchy.propTypes = { }), selectedValue: PropTypes.shape({ id: PropTypes.string, label: PropTypes.string }), onSelect: PropTypes.func, - treeChildren: PropTypes.array, // array of TreeItem - treeSuperclasses: PropTypes.array, // array of TreeItem + treeChildren: PropTypes.array, + treeSuperclasses: PropTypes.array, loading: PropTypes.bool, }; diff --git a/src/components/SingleTermView/OverView/OverView.jsx b/src/components/SingleTermView/OverView/OverView.jsx index 4ff4c96e..e56e60fa 100644 --- a/src/components/SingleTermView/OverView/OverView.jsx +++ b/src/components/SingleTermView/OverView/OverView.jsx @@ -141,7 +141,7 @@ const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, g setPredicateGroups(groups || []); } catch (e) { console.error("fetchPredicates error:", e); - setPredicateGroups([]); + setLoadingPredicates(false); } finally { setLoadingPredicates(false); } diff --git a/src/components/common/CustomizedTreeView.jsx b/src/components/common/CustomizedTreeView.jsx index 1cd75279..bb92f4ca 100644 --- a/src/components/common/CustomizedTreeView.jsx +++ b/src/components/common/CustomizedTreeView.jsx @@ -1,49 +1,114 @@ import PropTypes from 'prop-types'; -import { Box, Chip, CircularProgress } from "@mui/material"; +import { Box, Chip, CircularProgress, IconButton, Tooltip } 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 OpenInNewOutlinedIcon from '@mui/icons-material/OpenInNewOutlined'; import { useEffect, useMemo, useRef, useState } from "react"; import { vars } from "../../theme/variables"; const { gray500, brand200, brand50 } = vars; -const StyledTreeItemBase = (props) => ( - - {props.label} - {props.currentId && props.itemId === props.currentId && ( - - )} - - } - /> -); +const StyledLabel = styled('span')(() => ({ + display: 'flex', + alignItems: 'center', + gap: '.5rem', + position: 'relative' +})); + +const HoverActions = styled('span')(() => ({ + display: 'none', + alignItems: 'center', + marginLeft: '0.25rem' +})); + +const StyledTreeItemBase = (props) => { + const { + label, + currentId, + highlightedIds, + itemId, + idToIri, + idToHasChildren + } = props; + + const isCurrent = currentId && itemId === currentId; + const isHighlighted = Array.isArray(highlightedIds) && highlightedIds.includes(itemId); + const isLeaf = idToHasChildren?.get(itemId) === false; + + const iri = idToIri?.get(itemId) || itemId; + + return ( + + + {label} + + + {isCurrent && ( + + )} + + {isHighlighted && !isCurrent && ( + + )} + + {/* Leaf-only hover action: open in new tab */} + {isLeaf && ( + + + { + e.stopPropagation(); + try { + window.open(iri, "_blank", "noopener,noreferrer"); + } catch { + window.open(String(iri), "_blank"); + } + }} + > + + + + + )} + + } + sx={{ + [`& .${treeItemClasses.content}:hover .hover-actions`]: { display: 'inline-flex' }, + [`& .${treeItemClasses.label}`]: { fontSize: '0.875rem', fontWeight: 400 }, + color: gray500, + }} + /> + ); +}; StyledTreeItemBase.propTypes = { label: PropTypes.string, currentId: PropTypes.string, - itemId: PropTypes.string + highlightedIds: PropTypes.arrayOf(PropTypes.string), + itemId: PropTypes.string, + idToIri: PropTypes.instanceOf(Map), + idToHasChildren: PropTypes.instanceOf(Map) }; - -const StyledTreeItem = styled(StyledTreeItemBase)(() => ({ - color: gray500, - [`& .${treeItemClasses.content}`]: { - [`& .${treeItemClasses.label}`]: { fontSize: '0.875rem', fontWeight: 400 }, - }, - [`& .${treeItemClasses.groupTransition}`]: { marginLeft: 14 }, -})); - const kidsOf = (n, getItemChildren) => { const k = getItemChildren(n); return Array.isArray(k) ? k : []; @@ -64,28 +129,82 @@ const CustomizedTreeView = ({ items = [], loading = false, currentId = null, + highlightedIds = [], getItemId = (i) => i.id, getItemLabel = (i) => i.label, getItemChildren = (i) => i.children || [], + defaultExpanded = false }) => { const [expanded, setExpanded] = useState([]); + // precompute roots for safety (keeps tree visible even if expanded becomes empty) + const rootIds = useMemo(() => (items || []).map(getItemId), [items, getItemId]); + + // index useful metadata for slots (fast lookups in item renderer) + const { idToIri, idToHasChildren } = useMemo(() => { + const iriMap = new Map(); + const hasChildrenMap = new Map(); + const stack = [...items]; + while (stack.length) { + const node = stack.pop(); + const id = getItemId(node); + const kids = kidsOf(node, getItemChildren); + iriMap.set(id, node?.iri ?? null); + hasChildrenMap.set(id, kids.length > 0); + stack.push(...kids); + } + return { idToIri: iriMap, idToHasChildren: hasChildrenMap }; + }, [items, getItemChildren, getItemId]); + // 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(''); + // also expand ancestors of matched nodes so highlights are visible + const matchAncestorIds = useMemo(() => { + if (!highlightedIds?.length) return []; + const set = new Set(); + const dfs = (nodes, ancestors = []) => { + for (const n of nodes || []) { + const id = getItemId(n); + const nextAncestors = [...ancestors, id]; + if (highlightedIds.includes(id)) nextAncestors.forEach(a => set.add(a)); + dfs(getItemChildren(n), nextAncestors); + } + }; + dfs(items, []); + return [...set]; + }, [items, highlightedIds, getItemChildren, getItemId]); + + // desired expansions: keep visibility of current and matches; retain user toggles + const desiredExpanded = useMemo(() => { + const ancestorsOfCurrent = pathToCurrent.slice(0, -1); + if (defaultExpanded === true) { + // superclasses: open roots initially; ensure ancestors for matches/current are open too + return Array.from(new Set([...rootIds, ...ancestorsOfCurrent, ...matchAncestorIds])); + } + // children: collapsed by default, but still ensure current/match ancestors are visible + return Array.from(new Set([...ancestorsOfCurrent, ...matchAncestorIds])); + }, [defaultExpanded, pathToCurrent, matchAncestorIds, rootIds]); + + // only adjust expansions when the derived set changes — merge with existing so tree never "disappears" + const desiredKey = useMemo(() => desiredExpanded.join('|'), [desiredExpanded]); + const lastKey = useRef(''); useEffect(() => { - if (pathKey && pathKey !== lastPathKeyRef.current) { - const ancestors = pathToCurrent.slice(0, -1); - setExpanded(ancestors); // set once per path change - lastPathKeyRef.current = pathKey; + if (desiredKey !== lastKey.current) { + setExpanded(prev => { + const merged = new Set([...(prev || []), ...desiredExpanded]); + // As a final guard: if merged is empty but we want roots visible for defaultExpanded=true + if (merged.size === 0 && defaultExpanded === true && rootIds.length) { + rootIds.forEach(id => merged.add(id)); + } + return [...merged]; + }); + lastKey.current = desiredKey; } - }, [pathKey, pathToCurrent]); + }, [desiredKey, desiredExpanded, defaultExpanded, rootIds]); if (loading) { return ( @@ -103,13 +222,22 @@ const CustomizedTreeView = ({ getItemLabel={getItemLabel} getItemChildren={getItemChildren} expandedItems={expanded} - onExpandedItemsChange={(_e, ids) => setExpanded(ids)} // user can toggle + onExpandedItemsChange={(_e, ids) => setExpanded(ids)} + selectedItems={currentId ? [currentId] : []} + onSelectedItemsChange={() => { /* keep selection controlled by currentId */ }} slots={{ - item: StyledTreeItem, + item: StyledTreeItemBase, expandIcon: ChevronRightOutlinedIcon, collapseIcon: ExpandLessOutlinedIcon, }} - slotProps={{ item: { currentId } }} + slotProps={{ + item: { + currentId, + highlightedIds, + idToIri, + idToHasChildren + } + }} /> ); }; @@ -118,9 +246,11 @@ CustomizedTreeView.propTypes = { items: PropTypes.array, loading: PropTypes.bool, currentId: PropTypes.string, + highlightedIds: PropTypes.arrayOf(PropTypes.string), getItemId: PropTypes.func, getItemLabel: PropTypes.func, getItemChildren: PropTypes.func, + defaultExpanded: PropTypes.bool }; export default CustomizedTreeView; diff --git a/src/parsers/hierarchies-parser.tsx b/src/parsers/hierarchies-parser.tsx index bde2aa2e..c64ab55f 100644 --- a/src/parsers/hierarchies-parser.tsx +++ b/src/parsers/hierarchies-parser.tsx @@ -1,6 +1,6 @@ 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 OWL_OBJECT_PROPERTY = 'owl:'; 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'; @@ -212,8 +212,10 @@ 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)); + if ( t.object?.id !== "" && !t.object?.id?.includes(OWL_OBJECT_PROPERTY) && !t.subject?.id?.includes(OWL_OBJECT_PROPERTY) ) { + 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