From efabd6092787033d8a54a9d2178f1be9652d6a32 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Wed, 3 Sep 2025 21:33:33 -0700 Subject: [PATCH 1/4] #137 - feat: add search functionality and restore selection in Hierarchy component --- .../SingleTermView/OverView/Hierarchy.jsx | 116 +++++++++- .../SingleTermView/OverView/OverView.jsx | 2 +- src/components/common/CustomizedTreeView.jsx | 206 ++++++++++++++---- 3 files changed, 273 insertions(+), 51 deletions(-) diff --git a/src/components/SingleTermView/OverView/Hierarchy.jsx b/src/components/SingleTermView/OverView/Hierarchy.jsx index c3e9706e..c01b52dc 100644 --- a/src/components/SingleTermView/OverView/Hierarchy.jsx +++ b/src/components/SingleTermView/OverView/Hierarchy.jsx @@ -39,6 +39,27 @@ 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 } @@ -51,20 +72,77 @@ const Hierarchy = ({ const [type, setType] = React.useState(SUPERCLASSES); // default to superclasses const [currentId, setCurrentId] = React.useState(null); + // remember the *initial* selected term so the Aim button can always go back to it + const initialSelectedRef = React.useRef(selectedValue?.id || null); + + // keep track of what was selected before a search so Refresh can restore it + const preSearchSelectionRef = React.useRef(null); + const [searchTerm, setSearchTerm] = React.useState(""); + + const items = type === CHILDREN ? treeChildren : treeSuperclasses; + const childCount = items?.[0]?.children?.length || 0; + // 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 renderedId = findFirstRenderedId(items, focusIri); + setCurrentId(renderedId); + }, [selectedValue, type, treeChildren, treeSuperclasses]); // mirrors your original logic:contentReference[oaicite:1]{index=1} - const items = type === CHILDREN ? treeChildren : treeSuperclasses; - const childCount = items?.[0]?.children?.length || 0; + // highlight matches (by id/label/iri) in the rendered tree + const highlightedIds = React.useMemo( + () => collectMatchingIds(items, searchTerm), + [items, searchTerm] + ); + + // ⬇️ IMPORTANT: selecting from the autocomplete should NOT turn it into a "search mode" + // We (1) set upstream selection, (2) clear searchTerm, (3) focus that node in the tree. + const handleSelectChange = (value) => { + if (!preSearchSelectionRef.current && selectedValue) { + preSearchSelectionRef.current = selectedValue; + } + onSelect?.(value); + + // clear search highlight so the tree doesn't jump/contract + setSearchTerm(""); + + // focus the selected item in the *current* tree + const focusIri = toIri(value?.id); + const idInTree = findFirstRenderedId(items, focusIri); + if (idInTree) setCurrentId(idInTree); + }; + + const handleRefresh = () => { + // restore selection prior to search; keep hierarchy data intact + const toRestore = preSearchSelectionRef.current || initialSelectedRef.current || selectedValue; + if (toRestore) { + onSelect?.(toRestore); + const iri = toIri(toRestore?.id); + const idInTree = findFirstRenderedId(items, iri); + setCurrentId(idInTree); + } + setSearchTerm(""); + preSearchSelectionRef.current = null; + }; - const handleSelectChange = (_event, value) => onSelect?.(value); const gotoFirstOption = () => { const opts = type === CHILDREN ? options.children : options.superclasses; - if (opts?.length) onSelect?.(opts[0]); + if (opts?.length) { + onSelect?.(opts[0]); + const iri = toIri(opts[0]?.id); + const idInTree = findFirstRenderedId(items, iri); + setCurrentId(idInTree); + } + preSearchSelectionRef.current = null; + setSearchTerm(""); + }; + + // Aim icon should focus the *original* requested node for the hierarchy (not the last searched) + const handleAimFocus = () => { + const base = initialSelectedRef.current || selectedValue; + const focusIri = toIri(base?.id); + const id = findFirstRenderedId(items, focusIri); + setCurrentId(id); }; const singleSearchOptions = type === CHILDREN ? options.children : options.superclasses; @@ -83,17 +161,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' }, ]} /> - - @@ -109,7 +202,8 @@ const Hierarchy = ({ items={items} loading={false} currentId={currentId} - defaultExpanded={type === CHILDREN ? false : true} + highlightedIds={highlightedIds} + defaultExpanded={type !== CHILDREN} // superclasses open by default, children collapsed /> 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..ef691153 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 ( @@ -105,11 +224,18 @@ const CustomizedTreeView = ({ expandedItems={expanded} onExpandedItemsChange={(_e, ids) => setExpanded(ids)} // user can toggle slots={{ - item: StyledTreeItem, + item: StyledTreeItemBase, expandIcon: ChevronRightOutlinedIcon, collapseIcon: ExpandLessOutlinedIcon, }} - slotProps={{ item: { currentId } }} + slotProps={{ + item: { + currentId, + highlightedIds, + idToIri, + idToHasChildren + } + }} /> ); }; @@ -118,9 +244,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; From 68c4e3343c9c0eb5c173d73c6f83610802f37186 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Wed, 3 Sep 2025 22:33:30 -0700 Subject: [PATCH 2/4] fix: update dependency in Hierarchy component to include items in effect dependencies --- .../SingleTermView/OverView/Hierarchy.jsx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/components/SingleTermView/OverView/Hierarchy.jsx b/src/components/SingleTermView/OverView/Hierarchy.jsx index c01b52dc..d81a8d17 100644 --- a/src/components/SingleTermView/OverView/Hierarchy.jsx +++ b/src/components/SingleTermView/OverView/Hierarchy.jsx @@ -87,7 +87,7 @@ const Hierarchy = ({ const focusIri = toIri(selectedValue?.id); const renderedId = findFirstRenderedId(items, focusIri); setCurrentId(renderedId); - }, [selectedValue, type, treeChildren, treeSuperclasses]); // mirrors your original logic:contentReference[oaicite:1]{index=1} + }, [selectedValue, type, treeChildren, treeSuperclasses, items]); // mirrors your original logic:contentReference[oaicite:1]{index=1} // highlight matches (by id/label/iri) in the rendered tree const highlightedIds = React.useMemo( @@ -125,18 +125,6 @@ const Hierarchy = ({ preSearchSelectionRef.current = null; }; - const gotoFirstOption = () => { - const opts = type === CHILDREN ? options.children : options.superclasses; - if (opts?.length) { - onSelect?.(opts[0]); - const iri = toIri(opts[0]?.id); - const idInTree = findFirstRenderedId(items, iri); - setCurrentId(idInTree); - } - preSearchSelectionRef.current = null; - setSearchTerm(""); - }; - // Aim icon should focus the *original* requested node for the hierarchy (not the last searched) const handleAimFocus = () => { const base = initialSelectedRef.current || selectedValue; From c884eda712b4fc85991da3a556b42019bf2d0962 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Thu, 4 Sep 2025 22:40:46 -0700 Subject: [PATCH 3/4] feat: enhance Hierarchy component with improved selection handling and search highlighting --- .../SingleTermView/OverView/Hierarchy.jsx | 68 ++++++++++--------- src/components/common/CustomizedTreeView.jsx | 4 +- 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/src/components/SingleTermView/OverView/Hierarchy.jsx b/src/components/SingleTermView/OverView/Hierarchy.jsx index d81a8d17..5ac4be5e 100644 --- a/src/components/SingleTermView/OverView/Hierarchy.jsx +++ b/src/components/SingleTermView/OverView/Hierarchy.jsx @@ -61,59 +61,47 @@ const collectMatchingIds = (items = [], term = "") => { }; 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); - - // remember the *initial* selected term so the Aim button can always go back to it + const [manualHighlightedIds, setManualHighlightedIds] = React.useState([]); + const hasSearchedRef = React.useRef(false); const initialSelectedRef = React.useRef(selectedValue?.id || null); - // keep track of what was selected before a search so Refresh can restore it const preSearchSelectionRef = React.useRef(null); const [searchTerm, setSearchTerm] = React.useState(""); const items = type === CHILDREN ? treeChildren : treeSuperclasses; const childCount = items?.[0]?.children?.length || 0; - // when selection or type changes, recompute which tree + currentId to show React.useEffect(() => { const focusIri = toIri(selectedValue?.id); const renderedId = findFirstRenderedId(items, focusIri); setCurrentId(renderedId); - }, [selectedValue, type, treeChildren, treeSuperclasses, items]); // mirrors your original logic:contentReference[oaicite:1]{index=1} + }, [selectedValue, type, treeChildren, treeSuperclasses, items]); - // highlight matches (by id/label/iri) in the rendered tree - const highlightedIds = React.useMemo( - () => collectMatchingIds(items, searchTerm), - [items, searchTerm] - ); - - // ⬇️ IMPORTANT: selecting from the autocomplete should NOT turn it into a "search mode" - // We (1) set upstream selection, (2) clear searchTerm, (3) focus that node in the tree. const handleSelectChange = (value) => { if (!preSearchSelectionRef.current && selectedValue) { preSearchSelectionRef.current = selectedValue; } onSelect?.(value); - - // clear search highlight so the tree doesn't jump/contract - setSearchTerm(""); - - // focus the selected item in the *current* tree + hasSearchedRef.current = true; + + setSearchTerm(""); + setManualHighlightedIds([]); + const focusIri = toIri(value?.id); const idInTree = findFirstRenderedId(items, focusIri); if (idInTree) setCurrentId(idInTree); }; - + const handleRefresh = () => { - // restore selection prior to search; keep hierarchy data intact const toRestore = preSearchSelectionRef.current || initialSelectedRef.current || selectedValue; if (toRestore) { onSelect?.(toRestore); @@ -122,18 +110,28 @@ const Hierarchy = ({ setCurrentId(idInTree); } setSearchTerm(""); + setManualHighlightedIds([]); + hasSearchedRef.current = false; preSearchSelectionRef.current = null; - }; + }; - // Aim icon should focus the *original* requested node for the hierarchy (not the last searched) const handleAimFocus = () => { - const base = initialSelectedRef.current || selectedValue; + 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 @@ -141,6 +139,7 @@ const Hierarchy = ({ } + return ( @@ -186,12 +185,17 @@ const Hierarchy = ({ options={singleSearchOptions} /> + @@ -208,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/common/CustomizedTreeView.jsx b/src/components/common/CustomizedTreeView.jsx index ef691153..bb92f4ca 100644 --- a/src/components/common/CustomizedTreeView.jsx +++ b/src/components/common/CustomizedTreeView.jsx @@ -222,7 +222,9 @@ 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: StyledTreeItemBase, expandIcon: ChevronRightOutlinedIcon, From b1f3fc7d1786e84250e4d34e075cc7fe1692d9da Mon Sep 17 00:00:00 2001 From: jrmartin Date: Fri, 5 Sep 2025 05:38:52 -0700 Subject: [PATCH 4/4] fix: prevent adding empty or OWL object properties to hierarchy IDs --- src/parsers/hierarchies-parser.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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