From cbf8be643b13e87e3eecc10373b2b8d196ed3251 Mon Sep 17 00:00:00 2001 From: Dario Del Piano Date: Fri, 19 Jun 2026 15:32:53 +0200 Subject: [PATCH 1/6] fix csv download, handle single event upload --- package.json | 1 + .../OverView/CustomizedTable.jsx | 28 +++- .../SingleTermView/OverView/OverView.jsx | 22 ++- .../SingleTermView/OverView/TableRow.jsx | 14 +- .../OverView/predicateMutationBus.js | Bin 0 -> 831 bytes src/components/SingleTermView/index.jsx | 53 ++++++- .../TermEditor/newTerm/FirstStepContent.jsx | 46 ++++-- src/hooks/useTermSearch.js | 136 +++++++++++------- yarn.lock | 12 ++ 9 files changed, 232 insertions(+), 80 deletions(-) create mode 100644 src/components/SingleTermView/OverView/predicateMutationBus.js diff --git a/package.json b/package.json index 9415d12c..a37f0c8c 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react-router-dom": "^6.23.1", "react-rte": "^0.16.5", "react-syntax-highlighter": "^15.5.0", + "rxjs": "^7.8.2", "yup": "^1.6.1" }, "devDependencies": { diff --git a/src/components/SingleTermView/OverView/CustomizedTable.jsx b/src/components/SingleTermView/OverView/CustomizedTable.jsx index 2684cad0..020f30f6 100644 --- a/src/components/SingleTermView/OverView/CustomizedTable.jsx +++ b/src/components/SingleTermView/OverView/CustomizedTable.jsx @@ -14,6 +14,7 @@ import { isReadOnlyPredicate, isRowOnFocus, } from "../../../configuration/predicateConfig"; +import { predicateRowUpdates$, makeRowKey } from "./predicateMutationBus"; import { vars } from "../../../theme/variables"; const { gray100, gray50, gray600, gray500, brand600, gray700 } = vars; @@ -120,6 +121,8 @@ const CustomizedTable = ({ data, focusId, group = "base", onMutate }) => { const [adding, setAdding] = useState(false); const [newValue, setNewValue] = useState(""); + // Row currently awaiting a PATCH response (shows an in-row loader). + const [pendingRowKey, setPendingRowKey] = useState(null); const targetRow = useRef(); const sourceRow = useRef(); @@ -129,6 +132,26 @@ const CustomizedTable = ({ data, focusId, group = "base", onMutate }) => { setTableContent(normalizeTableData(data)); }, [data]); + // Terminal PATCH responses for an inline edit. On success, swap in the + // persisted value (only the changed row re-renders); on error, leave the + // original value untouched (revert). Either way the in-row loader clears. + useEffect(() => { + const sub = predicateRowUpdates$.subscribe(({ rowKey, newValue, status }) => { + setPendingRowKey((current) => (current === rowKey ? null : current)); + if (status !== "success") return; + setTableContent((prev) => { + let changed = false; + const next = prev.map((row) => { + if (makeRowKey(row.subject, predicateTitle, row.object) !== rowKey) return row; + changed = true; + return { ...row, object: safe(newValue) }; + }); + return changed ? next : prev; + }); + }); + return () => sub.unsubscribe(); + }, [predicateTitle]); + const move = (arr, fromIndex, toIndex) => { const element = arr[fromIndex]; const copy = [...arr]; @@ -181,8 +204,10 @@ const CustomizedTable = ({ data, focusId, group = "base", onMutate }) => { return ; }; - const handleEditRow = (row, value) => + const handleEditRow = (row, value) => { + setPendingRowKey(makeRowKey(row.subject, predicateTitle, row.object)); onMutate?.({ subject: row.subject, predicate: predicateTitle, op: "edit", kind: objectKind, oldValue: row.object, newValue: value }); + }; const handleDeleteRow = (row) => onMutate?.({ subject: row.subject, predicate: predicateTitle, op: "delete", kind: objectKind, oldValue: row.object }); @@ -229,6 +254,7 @@ const CustomizedTable = ({ data, focusId, group = "base", onMutate }) => { data={row} index={index} editable={!readOnly && !!onMutate && isRowOnFocus(row.subject, focusId)} + pending={pendingRowKey === makeRowKey(row.subject, predicateTitle, row.object)} objectKind={objectKind} group={group} onEdit={handleEditRow} diff --git a/src/components/SingleTermView/OverView/OverView.jsx b/src/components/SingleTermView/OverView/OverView.jsx index 051314e7..2666e7fe 100644 --- a/src/components/SingleTermView/OverView/OverView.jsx +++ b/src/components/SingleTermView/OverView/OverView.jsx @@ -30,6 +30,7 @@ import { resolveStoredObject, } from "../../../parsers/predicateMutations"; import { buildPredicateGroupsForFocus } from "../../../parsers/predicateParser"; +import { emitPredicateRowUpdate, makeRowKey } from "./predicateMutationBus"; import { shortenIri, getObjectInputKind } from "../../../configuration/predicateConfig"; import { @@ -268,19 +269,34 @@ const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, g ? resolveStoredObject(node, mutation.predicate, mutation.oldValue) : null; const payload = buildTripleDiff(subject, { ...mutation, oldObject }, context); + // For an edit, the affected row shows an in-row loader until we emit a + // terminal (success/error) update keyed to it. + const isEdit = mutation.op === "edit"; + const rowKey = isEdit + ? makeRowKey(mutation.subject, mutation.predicate, mutation.oldValue) + : null; try { // patchEndpointsIlx resolves on any 2xx (a 201 returns a bare version // hash, not JSON) and throws an AxiosError on 4xx/5xx. We don't track the // returned version id — a plain GET resolves the current head. await patchEndpointsIlx(group, patchId, { data: payload }); setMutationFeedback({ severity: "success", message: "Change saved" }); - fetchJSONFile(); - debouncedFetchTerms(searchTerm, group); - if (selectedValue?.id) fetchPredicates(selectedValue.id, group); + if (isEdit) { + // Surgical update: refresh only the edited row so the rest of the + // predicates section is left untouched (no spinner / accordion reset). + emitPredicateRowUpdate({ rowKey, newValue: mutation.newValue, status: "success" }); + } else { + // add/delete change the table structure -> full refetch. + fetchJSONFile(); + debouncedFetchTerms(searchTerm, group); + if (selectedValue?.id) fetchPredicates(selectedValue.id, group); + } } catch (e) { console.error("handlePredicateMutation error:", e); const { message } = interpretPatchResult(e); setMutationFeedback({ severity: "error", message: message || "Could not save change" }); + // Clear the row loader and keep the original value (revert). + if (isEdit) emitPredicateRowUpdate({ rowKey, status: "error" }); } }, [jsonData, selectedValue, searchTerm, group, fetchJSONFile, fetchPredicates, debouncedFetchTerms]); diff --git a/src/components/SingleTermView/OverView/TableRow.jsx b/src/components/SingleTermView/OverView/TableRow.jsx index 47562eac..d8d96023 100644 --- a/src/components/SingleTermView/OverView/TableRow.jsx +++ b/src/components/SingleTermView/OverView/TableRow.jsx @@ -1,7 +1,7 @@ import { useState } from "react"; import PropTypes from "prop-types"; import ObjectInput from "./ObjectInput"; -import { Box, IconButton, Tooltip, Typography, Link } from "@mui/material"; +import { Box, IconButton, Tooltip, Typography, Link, CircularProgress } from "@mui/material"; import CheckOutlinedIcon from "@mui/icons-material/CheckOutlined"; import CloseOutlinedIcon from "@mui/icons-material/CloseOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; @@ -20,6 +20,7 @@ const TableRow = ({ index, columnWidth, editable = false, + pending = false, objectKind = "text", group = "base", onEdit, @@ -41,8 +42,8 @@ const TableRow = ({ }; return ( - onDragStart(id, index, e)} onDragEnter={e => onDragEnter(id, index, e)} onDragEnd={onDragEnd} @@ -89,8 +90,10 @@ const TableRow = ({ )} - - {isEditing ? ( + + {pending ? ( + + ) : isEditing ? ( <> @@ -132,6 +135,7 @@ TableRow.propTypes = { index: PropTypes.number.isRequired, columnWidth: PropTypes.number.isRequired, editable: PropTypes.bool, + pending: PropTypes.bool, objectKind: PropTypes.oneOf(["term", "text"]), group: PropTypes.string, onEdit: PropTypes.func, diff --git a/src/components/SingleTermView/OverView/predicateMutationBus.js b/src/components/SingleTermView/OverView/predicateMutationBus.js new file mode 100644 index 0000000000000000000000000000000000000000..e517207050070e955abda6d4d575f7a8a5ee2dc9 GIT binary patch literal 831 zcmZ{i&2G~`5P&)7DMl8DL`|GKs8kgfBoM6#<$`+Hc*pUE^{zEDj#D8d9)c(2Ntm(I zv>EjwqC;~cwAMDO9>;?nSguRg~~4;wi&IgV)O>oBlR2sxGDykC2aCcEx9%H$%ENnnQ2O}^WBEj}JdIz#<(nD6*Z*hSm#ypO^KsXf4I8n&Oh4f23W{Q} zD2;wzyeLf0Um*5wZXX|^5=~MKYI=-g%V)C7ghzRd{oCxk{&OR??L04ce6wYwgRS3I F^#`htBCY@c literal 0 HcmV?d00001 diff --git a/src/components/SingleTermView/index.jsx b/src/components/SingleTermView/index.jsx index b9d30944..f47ab91e 100644 --- a/src/components/SingleTermView/index.jsx +++ b/src/components/SingleTermView/index.jsx @@ -67,6 +67,31 @@ const formatExtensions = { 'CSV': 'csv' }; +// MIME type per export format so the downloaded file opens correctly. +const formatMimeTypes = { + jsonld: 'application/ld+json', + ttl: 'text/turtle', + n3: 'text/n3', + owl: 'application/rdf+xml', + csv: 'text/csv' +}; + +// Build an informative download filename, e.g. "ilx_0101431-Brain.csv". +const buildDownloadFilename = (termId, label, ext) => { + const slugify = (value) => String(value || '') + .trim() + .replace(/[^\w-]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 60); + const id = slugify(termId); + const slug = slugify(label); + const base = id && slug && slug.toLowerCase() !== id.toLowerCase() + ? `${id}-${slug}` + : (id || slug || 'term'); + return `${base}.${ext}`; +}; + const SingleTermView = () => { const { group, term, tab, versionHash } = useParams(); const navigate = useNavigate(); @@ -181,19 +206,37 @@ const SingleTermView = () => { }, []); const downloadFormattedData = useCallback((dataFormat) => { - getRawData(actualGroup, searchTerm, formatExtensions[dataFormat]).then(rawResponse => { - const formattedData = JSON.stringify(rawResponse, null, 2); - const blob = new Blob([formattedData], { type: 'application/json' }); + const ext = formatExtensions[dataFormat]; + getRawData(actualGroup, searchTerm, ext).then(rawResponse => { + if (rawResponse === undefined || rawResponse === null) { + console.error(`No ${dataFormat} data returned for ${searchTerm}`); + return; + } + // Text formats (CSV/Turtle/N3/OWL) come back as strings and must be + // written verbatim; only object responses (JSON-LD) are stringified. + const formattedData = typeof rawResponse === 'string' + ? rawResponse + : JSON.stringify(rawResponse, null, 2); + // Guard against the backend returning the SPA shell instead of data + // (e.g. ids it doesn't serve in the requested format). + if (typeof formattedData === 'string' && /^\s*<(?:!doctype|html)\b/i.test(formattedData)) { + console.error(`No ${dataFormat} representation available for ${searchTerm}`); + return; + } + const mime = formatMimeTypes[ext] || 'text/plain'; + const blob = new Blob([formattedData], { type: `${mime};charset=utf-8` }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `data.${formatExtensions[dataFormat]}`; + a.download = buildDownloadFilename(searchTerm, displayedTermLabel, ext); + document.body.appendChild(a); a.click(); + a.remove(); URL.revokeObjectURL(url); }).catch(error => { console.error('Error downloading data:', error); }); - }, [actualGroup, searchTerm]); + }, [actualGroup, searchTerm, displayedTermLabel]); const handleDataFormatMenuItemClick = useCallback((value) => { setSelectedDataFormat(value); diff --git a/src/components/TermEditor/newTerm/FirstStepContent.jsx b/src/components/TermEditor/newTerm/FirstStepContent.jsx index 49b672ad..a741b9c3 100644 --- a/src/components/TermEditor/newTerm/FirstStepContent.jsx +++ b/src/components/TermEditor/newTerm/FirstStepContent.jsx @@ -20,7 +20,7 @@ import { vars } from "../../../theme/variables"; import { TYPES, DEFAULT_TYPE } from "../../../constants/types"; import { useTermSearch } from "../../../hooks/useTermSearch"; -const { white, gray300, gray400, gray500, gray600 } = vars; +const { white, gray300, gray400, gray500, gray600, error500 } = vars; const styles = { contentBox: { @@ -57,6 +57,14 @@ const styles = { color: `${gray400} !important`, }, }, + synonymExactMatch: { + flexDirection: "row !important", + border: `1px solid ${error500} !important`, + color: `${error500} !important`, + "& .MuiChip-deleteIcon": { + color: `${error500} !important`, + }, + }, id: { flexDirection: "row !important", borderRadius: "1rem !important", @@ -72,7 +80,7 @@ const FirstStepContent = ({ term, type, existingIds, synonyms, isEditing, handle const { user, updateStoredSearchTerm } = useContext(GlobalDataContext); const navigate = useNavigate(); - const { loading, searchResults } = useTermSearch({ + const { loading, searchResults, displayedValue, exactMatchValues } = useTermSearch({ term, type, synonyms, @@ -80,6 +88,8 @@ const FirstStepContent = ({ term, type, existingIds, synonyms, isEditing, handle onExactMatchChange: handleExactMatchChange, }); + const exactMatchSet = new Set((exactMatchValues || []).map((value) => value.toLowerCase())); + const handleSidebarToggle = () => setOpenSidebar(!openSidebar); const handleResultSelect = (searchResult) => { @@ -89,16 +99,22 @@ const FirstStepContent = ({ term, type, existingIds, synonyms, isEditing, handle handleDialogClose(); }; - const renderChips = (values, getTagProps, chipStyles) => { - return values.map((option, index) => ( - } - sx={chipStyles} - {...getTagProps({ index })} - /> - )); + const renderChips = (values, getTagProps, chipStyles, exactMatchStyles) => { + return values.map((option, index) => { + const isExactMatch = + exactMatchStyles && + typeof option === "string" && + exactMatchSet.has(option.trim().toLowerCase()); + return ( + } + sx={isExactMatch ? exactMatchStyles : chipStyles} + {...getTagProps({ index })} + /> + ); + }); }; return ( @@ -146,7 +162,7 @@ const FirstStepContent = ({ term, type, existingIds, synonyms, isEditing, handle popupIcon={} options={[]} freeSolo - renderTags={(values, getTagProps) => renderChips(values, getTagProps, styles.chip.synonym)} + renderTags={(values, getTagProps) => renderChips(values, getTagProps, styles.chip.synonym, styles.chip.synonymExactMatch)} fullWidth renderInput={(params) => } sx={styles.autocomplete} @@ -187,10 +203,10 @@ const FirstStepContent = ({ term, type, existingIds, synonyms, isEditing, handle onToggle={handleSidebarToggle} results={searchResults} isResultsEmpty={searchResults.length === 0} - searchValue={term} + searchValue={displayedValue || term} onResultAction={handleResultSelect} user={user} - selectedTerm={term} + selectedTerm={displayedValue || term} /> ) diff --git a/src/hooks/useTermSearch.js b/src/hooks/useTermSearch.js index 705eb230..a0d1858c 100644 --- a/src/hooks/useTermSearch.js +++ b/src/hooks/useTermSearch.js @@ -4,88 +4,122 @@ import { GlobalDataContext } from "../contexts/DataContext" import { checkPotentialMatches } from "../api/endpoints/apiService" import { elasticSearch } from "../api/endpoints" +// Check a single input value (label or synonym) for an exact match and, +// when none, its elastic potential matches. +const searchInput = async (group, value, type) => { + try { + await checkPotentialMatches(group, { + label: value, + "rdf-type": type, + exact: [], + }) + + // No exact match from the entity check, fall back to elastic potential + // matches. A result whose label equals the input is still an exact match. + const { results } = await elasticSearch(value, 30, 0) + const normalized = value.toLowerCase() + const matches = + results?.results?.map((result) => ({ + ...result, + isExactMatch: result.label?.toLowerCase() === normalized, + })) || [] + + return { isExactMatch: matches.some((match) => match.isExactMatch), results: matches } + } catch (error) { + if (error?.response?.status === 409 && error?.response?.data?.existing) { + const exactMatches = Object.entries(error.response.data.existing).flatMap( + ([termUri, matches]) => { + const matchList = Array.isArray(matches) ? matches : [matches] + return matchList.map((match) => ({ + ilx: termUri.split("/").pop(), + label: match.object ?? termUri.split("/").pop() ?? "Unknown", + object: match.object ?? "", + isExactMatch: true, + })) + }, + ) + return { isExactMatch: true, results: exactMatches } + } + return { isExactMatch: false, results: [] } + } +} + export const useTermSearch = ({ term, type, synonyms, isEditing, onExactMatchChange }) => { const [loading, setLoading] = useState(false) const [searchResults, setSearchResults] = useState([]) - const [lastSearch, setLastSearch] = useState({ term: "", type: "" }) + const [displayedValue, setDisplayedValue] = useState("") + const [exactMatchValues, setExactMatchValues] = useState([]) const { user } = useContext(GlobalDataContext) + const reset = useCallback(() => { + setSearchResults([]) + setExactMatchValues([]) + setDisplayedValue("") + onExactMatchChange(false) + }, [onExactMatchChange]) + const searchFunction = useCallback( async (searchTerm, searchType, synonymList) => { - if (!searchTerm || !searchType || isEditing) { - setSearchResults([]) - return; + if (isEditing || !searchType) { + reset() + return } - // Skip duplicate searches - if (lastSearch.term === searchTerm && lastSearch.type === searchType) { - return; + // Inputs in input order: label first, then synonyms as given + const inputs = [searchTerm, ...(synonymList || [])] + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter((value) => value.length > 0) + + if (inputs.length === 0) { + reset() + return } - setLoading(true); + setLoading(true) + const group = user?.groupname || "base" try { - await checkPotentialMatches(user?.groupname || "base", { - label: searchTerm, - "rdf-type": searchType, - exact: synonymList, - }) + const outcomes = await Promise.all( + inputs.map((value) => searchInput(group, value, searchType)), + ) + const perInput = inputs.map((value, index) => ({ value, ...outcomes[index] })) - // No exact matches found, search in elastic - onExactMatchChange(false) - const { results } = await elasticSearch(searchTerm, 30, 0) - - const searchResults = - results?.results?.map((result) => ({ - ...result, - isExactMatch: false, - })) || [] - - setSearchResults(searchResults) - } catch (error) { - if (error?.response?.status === 409 && error?.response?.data?.existing) { - // Handle exact matches + const exactInputs = perInput.filter((input) => input.isExactMatch) + setExactMatchValues(exactInputs.map((input) => input.value)) + + if (exactInputs.length > 0) { + // An exact match takes precedence (label wins, then first synonym) + const chosen = exactInputs[0] onExactMatchChange(true) - const exactMatches = Object.entries(error.response.data.existing).flatMap( - ([termUri, matches]) => { - const matchList = Array.isArray(matches) ? matches : [matches] - return matchList.map((match) => ({ - ilx: termUri.split("/").pop(), - label: match.object ?? termUri.split("/").pop() ?? "Unknown", - object: match.object ?? "", - isExactMatch: true, - })) - }, - ) - setSearchResults(exactMatches) - } else { - onExactMatchChange(false) - setSearchResults([]) + setSearchResults(chosen.results) + setDisplayedValue(chosen.value) + return } + + // No exact match: show the last input that returned results + onExactMatchChange(false) + const chosen = [...perInput].reverse().find((input) => input.results.length > 0) + setSearchResults(chosen ? chosen.results : []) + setDisplayedValue(chosen ? chosen.value : inputs[inputs.length - 1]) } finally { setLoading(false) - setLastSearch({ term: searchTerm, type: searchType }) } }, - [user, onExactMatchChange, isEditing, lastSearch], + [user, onExactMatchChange, isEditing, reset], ) const debouncedSearch = useMemo(() => debounce(searchFunction, 500), [searchFunction]) useEffect(() => { - if (!term) { - setSearchResults([]) - onExactMatchChange(false) - return - } - debouncedSearch(term, type, synonyms) return () => debouncedSearch.cancel() - }, [term, type, synonyms, debouncedSearch, onExactMatchChange]) + }, [term, type, synonyms, debouncedSearch]) return { loading, searchResults, + displayedValue, + exactMatchValues, clearResults: () => setSearchResults([]), } } diff --git a/yarn.lock b/yarn.lock index 79bdfc82..076f9d92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4719,6 +4719,13 @@ rw@1: resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4" integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== +rxjs@^7.8.2: + version "7.8.2" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b" + integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== + dependencies: + tslib "^2.1.0" + safe-array-concat@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.2.tgz#81d77ee0c4e8b863635227c721278dd524c20edb" @@ -5114,6 +5121,11 @@ tslib@^1.14.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.1.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.6.0: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" From cd58025c1d16b66596a970de4af8a2d6a31514f5 Mon Sep 17 00:00:00 2001 From: Dario Del Piano Date: Fri, 19 Jun 2026 16:22:52 +0200 Subject: [PATCH 2/6] beautify graph --- src/components/GraphViewer/Graph.jsx | 64 +++++++++++++++++++--------- 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/src/components/GraphViewer/Graph.jsx b/src/components/GraphViewer/Graph.jsx index e2e224ef..e343455d 100644 --- a/src/components/GraphViewer/Graph.jsx +++ b/src/components/GraphViewer/Graph.jsx @@ -6,7 +6,19 @@ 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 MARGIN = { top: 40, right: 40, bottom: 40, left: 40 }; +// The graph is laid out in fixed "natural" coordinates and then scaled to the +// container width via the SVG viewBox. Because gaps and font size scale +// together, labels never overlap regardless of how many objects a predicate +// has, and there is never any horizontal scroll. +const ROW_HEIGHT = 26; // vertical room per object in natural units +const COL_WIDTH = 460; // horizontal spread between hierarchy depths +const LABEL_ALLOWANCE = 380; // room reserved for (truncated) leaf labels +const MIN_LAYOUT_HEIGHT = 180; // keep small graphs from looking cramped +const MAX_LABEL_CHARS = 48; // truncate long IRIs; full text shows on hover + +const truncateLabel = (label) => + label.length > MAX_LABEL_CHARS ? `${label.slice(0, MAX_LABEL_CHARS - 1)}…` : label; const Graph = ({ width, height, predicate }) => { const containerRef = useRef(null); @@ -25,13 +37,9 @@ const Graph = ({ width, height, predicate }) => { return () => observer.disconnect(); }, []); - const effectiveWidth = Math.max(measuredWidth || width, width); - const boundsWidth = Math.max(0, effectiveWidth - (MARGIN.right + MARGIN.left * 4)); - const boundsHeight = Math.max(0, height - MARGIN.top - MARGIN.bottom); - // Extra horizontal room so full (untruncated) IRI labels are visible; the - // container scrolls horizontally when labels run past the container width. - const LABEL_SPACE = 900; - const svgWidth = effectiveWidth + LABEL_SPACE; + const effectiveWidth = measuredWidth || width; + // Natural (pre-scale) drawing area for the dendrogram itself. + const boundsWidth = COL_WIDTH; const mouseover = useCallback((event) => { d3.select("#tooltip").html(event.currentTarget.id).style("opacity", 1); @@ -53,10 +61,28 @@ const Graph = ({ width, height, predicate }) => { return d3.hierarchy(data).sum((d) => d.value || 1); }, [predicate]); + // Grow the vertical layout with the number of leaves so each object gets at + // least ROW_HEIGHT of space; small graphs keep a sensible minimum. + const leafCount = useMemo(() => hierarchy.leaves().length, [hierarchy]); + const layoutHeight = Math.max(MIN_LAYOUT_HEIGHT, leafCount * ROW_HEIGHT); + + // Natural canvas size, then scale to fill the container width. Height follows + // the same scale so the whole graph is visible with no scrollbars. + const naturalWidth = MARGIN.left + boundsWidth + LABEL_ALLOWANCE + MARGIN.right; + const naturalHeight = MARGIN.top + layoutHeight + MARGIN.bottom; + const scale = effectiveWidth ? effectiveWidth / naturalWidth : 1; + // `height` acts as a minimum so small graphs still fill the area; tall graphs + // grow with their content (full width, no scrollbars). + const displayHeight = Math.max(height, naturalHeight * scale); + const dendrogram = useMemo(() => { - const gen = d3.cluster().size([boundsHeight, boundsWidth]); + const gen = d3 + .cluster() + .size([layoutHeight, boundsWidth]) + // Give a little extra gap between objects of different parents. + .separation((a, b) => (a.parent === b.parent ? 1 : 1.4)); return gen(hierarchy); - }, [hierarchy, boundsHeight, boundsWidth]); + }, [hierarchy, layoutHeight, boundsWidth]); useEffect(() => { const nodes = d3.selectAll(".node--leaf-g"); @@ -72,8 +98,7 @@ const Graph = ({ width, height, predicate }) => { const isGroup = node.data.type === PREDICATE || node.data.type === ROOT; const textOffset = isGroup ? -40 : 5; const label = String(node.data.name ?? "unknown"); - // Show the full IRI for now (most will become curies / be hidden later) - const truncated = label; + const truncated = truncateLabel(label); return ( @@ -116,7 +141,7 @@ const Graph = ({ width, height, predicate }) => { hierarchy && hierarchy.data && Array.isArray(hierarchy.data.children) && hierarchy.data.children.length > 0; return ( - + { borderRadius: "0.5rem", }} /> - + { - + {hasChildren ? ( <> {allNodes} From 9594769442beb8ed676771e883c4f832231b3b90 Mon Sep 17 00:00:00 2001 From: Dario Del Piano Date: Fri, 19 Jun 2026 16:36:58 +0200 Subject: [PATCH 3/6] improved search term handler and term title on fetch --- src/components/Header/Search.jsx | 4 ++++ src/components/SearchResults/ListView.jsx | 3 +++ src/components/SingleTermView/index.jsx | 5 ++++- src/hooks/useTermData.js | 18 ++++++++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/components/Header/Search.jsx b/src/components/Header/Search.jsx index d01c6c26..6084872f 100644 --- a/src/components/Header/Search.jsx +++ b/src/components/Header/Search.jsx @@ -16,6 +16,7 @@ import PropTypes from 'prop-types'; import BasicTabs from "../common/CustomTabs"; import { useNavigate } from "react-router-dom"; import { GlobalDataContext } from "../../contexts/DataContext"; +import { primeTermDataCache } from "../../hooks/useTermData"; import { SEARCH_TYPES } from "../../constants/types"; import { searchAll, elasticSearch } from "../../api/endpoints"; import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined'; @@ -106,6 +107,9 @@ const Search = () => { handleCloseList(); const groupName = getGroupName(); + // We already know this term's label — prime the cache so the term page can + // show the title immediately, without waiting on its .jsonld download. + primeTermDataCache(groupName, newInputValue?.ilx, newInputValue?.label); navigate(`/${groupName}/${newInputValue?.ilx}/overview`); updateStoredSearchTerm(newInputValue?.label) }; diff --git a/src/components/SearchResults/ListView.jsx b/src/components/SearchResults/ListView.jsx index 8d0511ae..c2100130 100644 --- a/src/components/SearchResults/ListView.jsx +++ b/src/components/SearchResults/ListView.jsx @@ -6,6 +6,7 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import CreateNewFolderOutlinedIcon from '@mui/icons-material/CreateNewFolderOutlined'; import { Box, Typography, Grid, Stack, Chip, CircularProgress, Snackbar, Alert } from '@mui/material'; import { GlobalDataContext } from "../../contexts/DataContext"; +import { primeTermDataCache } from "../../hooks/useTermData"; import { vars } from '../../theme/variables'; const { gray200, gray500, gray700, brand50, brand200, brand600, brand700, error50, error300, error700 } = vars; @@ -137,6 +138,8 @@ const ListView = ({ searchResults, loading }) => { const handleClick = (searchResult) => { updateStoredSearchTerm(searchResult?.label); const groupName = user?.groupname || 'base'; + // Seed the cache so the term page shows the title instantly. + primeTermDataCache(groupName, searchResult?.ilx, searchResult?.label); navigate(`/${groupName}/${searchResult?.ilx}/overview`); }; diff --git a/src/components/SingleTermView/index.jsx b/src/components/SingleTermView/index.jsx index f47ab91e..74ec9c38 100644 --- a/src/components/SingleTermView/index.jsx +++ b/src/components/SingleTermView/index.jsx @@ -379,7 +379,10 @@ const SingleTermView = () => { - {isLoadingTerm ? ( + {/* The label is already known from the search (storedSearchTerm), + so show it immediately and only fall back to a spinner on a + cold direct load where we have nothing to display yet. */} + {isLoadingTerm && !termData && !storedSearchTerm ? ( ) : ( displayedTermLabel diff --git a/src/hooks/useTermData.js b/src/hooks/useTermData.js index 094c4155..03d959f9 100644 --- a/src/hooks/useTermData.js +++ b/src/hooks/useTermData.js @@ -4,6 +4,18 @@ import { getSelectedTermLabel } from '../api/endpoints/apiService'; // Cache to store term data and avoid duplicate API calls const termDataCache = new Map(); +// Seed the cache with a label we already know (e.g. the one shown on a search +// result that was just clicked). The next useTermData(term, group) then gets an +// instant cache hit, so the title renders immediately instead of waiting on the +// term's full .jsonld download. +export const primeTermDataCache = (group, term, label) => { + if (!group || !term || !label) return; + const cacheKey = `${group}:${term}`; + if (!termDataCache.has(cacheKey)) { + termDataCache.set(cacheKey, { label, actualGroup: group }); + } +}; + export const useTermData = (searchTerm, group) => { const [termData, setTermData] = useState(null); const [actualGroup, setActualGroup] = useState(group); @@ -26,6 +38,12 @@ export const useTermData = (searchTerm, group) => { return; } + // Cache miss: drop the previous term's label right away so the view can + // fall back to the already-known search label (storedSearchTerm) instead of + // showing the stale title until the new fetch completes. + setTermData(null); + setIsUsingFallback(false); + // Cancel previous request if it exists if (abortControllerRef.current) { abortControllerRef.current.abort(); From fc6e069e192b2d529c21da11e6e17d2b08bb1792 Mon Sep 17 00:00:00 2001 From: Dario Del Piano Date: Wed, 24 Jun 2026 13:59:40 +0200 Subject: [PATCH 4/6] bulk edit object resolution, patch fixed, cosmetic fixes --- src/api/apiErrorBus.js | 10 + src/api/endpoints/apiService.ts | 46 +- .../EditBulkTerms/EditBulkTermsDialog.jsx | 180 ++--- .../Dashboard/EditBulkTerms/EditTerms.jsx | 6 + .../Dashboard/EditBulkTerms/SearchTerms.jsx | 12 +- .../Dashboard/EditBulkTerms/TermsTable.jsx | 27 +- .../AddNewOntologyDialog.jsx | 14 +- .../SingleTermView/OverView/OverView.jsx | 748 ++++++++++-------- .../OverView/OverviewSections.jsx | 83 ++ .../SingleTermView/OverView/overviewStore.js | 32 + src/components/common/ApiErrorDialog.jsx | 114 +++ src/components/common/OrganizationsList.jsx | 28 +- vite.config.js | 5 +- 13 files changed, 855 insertions(+), 450 deletions(-) create mode 100644 src/api/apiErrorBus.js create mode 100644 src/components/SingleTermView/OverView/OverviewSections.jsx create mode 100644 src/components/SingleTermView/OverView/overviewStore.js create mode 100644 src/components/common/ApiErrorDialog.jsx diff --git a/src/api/apiErrorBus.js b/src/api/apiErrorBus.js new file mode 100644 index 00000000..73dd6aed --- /dev/null +++ b/src/api/apiErrorBus.js @@ -0,0 +1,10 @@ +import { Subject } from "rxjs"; + +// Cross-cutting channel for surfacing failed backend requests to the UI. API +// callers report failures here; a single dialog subscribes and shows the +// queried URL plus the error returned by the backend. +const apiError$$ = new Subject(); + +export const reportApiError = (error) => apiError$$.next(error); + +export const apiError$ = apiError$$.asObservable(); diff --git a/src/api/endpoints/apiService.ts b/src/api/endpoints/apiService.ts index 1b88f38b..d0a1a960 100644 --- a/src/api/endpoints/apiService.ts +++ b/src/api/endpoints/apiService.ts @@ -4,6 +4,36 @@ import termParser from "../../parsers/termParser"; import { jsonldToTriplesAndEdges, PART_OF_IRI } from '../../parsers/hierarchies-parser' import { buildPredicateGroupsForFocus } from "../../parsers/predicateParser"; +// Error enriched with the queried URL + the backend's message, so the UI can +// show a meaningful dialog instead of a bare "HTTP 404". +export interface ApiRequestError extends Error { + url?: string; + status?: number; + body?: string; +} + +// Read a failed Response body and turn it into a clean, short message. Backend +// errors are often small HTML pages, so pull the

text / strip tags. +const buildRequestError = async (resp: Response, url: string): Promise => { + let raw = ""; + try { + raw = await resp.text(); + } catch { + /* body not readable */ + } + const para = raw.match(/

([\s\S]*?)<\/p>/i); + let message = (para ? para[1] : raw.replace(/<[^>]*>/g, " ")) + .replace(/"/g, '"').replace(/"/g, '"').replace(/&/g, "&") + .replace(/\s+/g, " ").trim(); + if (message.length > 400) message = `${message.slice(0, 400)}…`; + + const err = new Error(`HTTP ${resp.status}`) as ApiRequestError; + err.url = url; + err.status = resp.status; + err.body = message; + return err; +}; + export interface LoginRequest { username: string password: string @@ -361,10 +391,14 @@ export const getTermPredicates = async ({ headers: { Accept: "application/ld+json" }, credentials: "include", }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + if (!resp.ok) throw await buildRequestError(resp, url); const ct = resp.headers.get("content-type") || ""; if (!/application\/(ld\+json|json)/i.test(ct)) { - throw new Error(`Server did not return JSON-LD (content-type: ${ct || "n/a"})`); + const err = new Error(`Server did not return JSON-LD (content-type: ${ct || "n/a"})`) as ApiRequestError; + err.url = url; + err.status = resp.status; + err.body = `Expected JSON-LD but received content-type: ${ct || "n/a"}`; + throw err; } const jsonld = await resp.json(); @@ -391,10 +425,14 @@ export const getTermHierarchies = async ({ headers: { Accept: "application/ld+json" }, credentials: "include", }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + if (!resp.ok) throw await buildRequestError(resp, url); const ct = resp.headers.get("content-type") || ""; if (!/application\/(ld\+json|json)/i.test(ct)) { - throw new Error(`Server did not return JSON-LD (content-type: ${ct || "n/a"})`); + const err = new Error(`Server did not return JSON-LD (content-type: ${ct || "n/a"})`) as ApiRequestError; + err.url = url; + err.status = resp.status; + err.body = `Expected JSON-LD but received content-type: ${ct || "n/a"}`; + throw err; } const jsonld = await resp.json(); diff --git a/src/components/Dashboard/EditBulkTerms/EditBulkTermsDialog.jsx b/src/components/Dashboard/EditBulkTerms/EditBulkTermsDialog.jsx index 4a134379..d589dfcf 100644 --- a/src/components/Dashboard/EditBulkTerms/EditBulkTermsDialog.jsx +++ b/src/components/Dashboard/EditBulkTerms/EditBulkTermsDialog.jsx @@ -11,7 +11,8 @@ import CustomizedDialog from "../../common/CustomizedDialog"; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import SearchTermsData from "../../../static/SearchTermsData.json"; -import { patchTerm } from "../../../api/endpoints"; +import { patchEndpointsIlx } from "../../../api/endpoints/interLexURIStructureAPI"; +import { expandIri } from "../../../parsers/predicateMutations"; import { GlobalDataContext } from "../../../contexts/DataContext"; const initialSearchConditions = { attribute: '', value: '', condition: 'where', relation: SearchTermsData.objectOptions[0].value } @@ -78,6 +79,7 @@ const EditBulkTermsDialog = ({ open, handleClose, activeStep, setActiveStep }) = const [selectedOntology, setSelectedOntology] = useState(null); // eslint-disable-next-line no-unused-vars const [originalTerms, setOriginalTerms] = useState([]); + const [jsonLdContext, setJsonLdContext] = useState({}); const [batchUpdateResults, setBatchUpdateResults] = useState(null); const [isUpdating, setIsUpdating] = useState(false); @@ -88,107 +90,109 @@ const EditBulkTermsDialog = ({ open, handleClose, activeStep, setActiveStep }) = } }, [open, activeOntology, selectedOntology]); - // Helper function to replace "base" with user groupname in term data - const replaceBaseWithUserGroup = (obj, userGroupname) => { - if (!obj || !userGroupname) return obj; - - const replaceInValue = (value) => { - if (typeof value === 'string') { - return value.replace(/\bbase\b/g, userGroupname); - } else if (Array.isArray(value)) { - return value.map(replaceInValue); - } else if (value && typeof value === 'object') { - return replaceBaseWithUserGroup(value, userGroupname); + const rawNodeItemToRdfObject = (item, context) => { + if (item == null) return null; + if (typeof item === 'string') return { type: 'literal', value: item }; + if (typeof item === 'object') { + if (item['@id']) return { type: 'uri', value: expandIri(item['@id'], context) }; + if (item['@value'] != null) { + const obj = { type: 'literal', value: String(item['@value']) }; + if (item['@language']) obj.lang = item['@language']; + if (item['@type']) obj.datatype = item['@type']; + return obj; } - return value; - }; - - const result = {}; - for (const [key, value] of Object.entries(obj)) { - result[key] = replaceInValue(value); + return { type: 'literal', value: JSON.stringify(item) }; } - return result; + return { type: 'literal', value: String(item) }; + }; + + const parseValueToRdfObjects = (value, context) => { + if (value == null || value === '') return []; + try { + const arr = JSON.parse('[' + value + ']'); + if (Array.isArray(arr)) { + return arr.map(item => rawNodeItemToRdfObject(item, context)).filter(Boolean); + } + } catch {} + return [{ type: 'literal', value: String(value) }]; }; const performBatchUpdate = async (termsToUpdate = null) => { setIsUpdating(true); - - // Use provided terms or all ontology terms + const terms = termsToUpdate || ontologyTerms; - - const results = { - successful: [], - failed: [], - total: terms.length - }; + const results = { successful: [], failed: [], total: terms.length }; - try { - // Store original terms before starting updates (only if not retrying) - if (!termsToUpdate) { - setOriginalTerms([...ontologyTerms]); - } + const originalByIri = {}; + originalTerms.forEach(t => { originalByIri[t['@id']] = t; }); - // Process each term + try { for (const term of terms) { + const termIri = term['@id']; + let termId = termIri; + if (termId && termId.includes('/')) termId = termId.split('/').pop(); + const group = user?.groupname || 'base'; + try { - // Extract the group and term ID from the term - let termId = term.id || term['@id']; - - // If termId is in full URI format, extract just the ID part - if (termId && termId.includes('/')) { - termId = termId.split('/').pop(); + const originalTerm = originalByIri[termIri]; + if (!originalTerm) { + results.failed.push({ termId, error: 'Original term not found', term }); + continue; } - - // Use user's groupname instead of 'base' - const group = user?.groupname || 'base'; - - // Create the JSON-LD payload with the current term data, replacing base with user groupname - let jsonLdPayload = { - "@context": term["@context"] || { - "@vocab": `http://uri.interlex.org/${group}/`, - "owl": "http://www.w3.org/2002/07/owl#", - "rdfs": "http://www.w3.org/2000/01/rdf-schema#" - }, - "@id": term['@id'] || termId, - ...term - }; - // Replace any "base" references with user's groupname in the payload - if (user?.groupname && user.groupname !== 'base') { - jsonLdPayload = replaceBaseWithUserGroup(jsonLdPayload, user.groupname); + const add = []; + const del = []; + const allKeys = new Set([ + ...Object.keys(term), + ...Object.keys(originalTerm), + ].filter(k => k !== '@id' && k !== '_rawNode')); + + for (const predicate of allKeys) { + const oldValue = originalTerm[predicate]; + const newValue = term[predicate]; + if (oldValue === newValue) continue; + if (!oldValue && !newValue) continue; + + const expandedPredicate = expandIri(predicate, jsonLdContext); + const rawNode = originalTerm['_rawNode']; + + if (oldValue != null) { + const rawVal = rawNode ? rawNode[predicate] : null; + if (rawVal != null) { + const rawArr = Array.isArray(rawVal) ? rawVal : [rawVal]; + rawArr.forEach(item => { + const rdfObj = rawNodeItemToRdfObject(item, jsonLdContext); + if (rdfObj) del.push([termIri, expandedPredicate, rdfObj]); + }); + } else { + del.push([termIri, expandedPredicate, { type: 'literal', value: String(oldValue) }]); + } + } + + if (newValue != null && newValue !== '') { + parseValueToRdfObjects(newValue, jsonLdContext).forEach(rdfObj => { + add.push([termIri, expandedPredicate, rdfObj]); + }); + } } - // Send PATCH request - const response = await patchTerm(group, termId, jsonLdPayload); - - if (response.status === 200 || response.status === 201) { - results.successful.push({ - termId, - term: response.term, - status: response.status - }); - } else { - results.failed.push({ - termId, - error: `HTTP ${response.status}`, - term - }); + if (add.length === 0 && del.length === 0) { + results.successful.push({ termId, status: 'unchanged' }); + continue; } + + await patchEndpointsIlx(group, termId, { data: { add, del } }); + results.successful.push({ termId, status: 200 }); } catch (error) { - console.error(`Failed to update term ${term.id}:`, error); - results.failed.push({ - termId: term.id, - error: error.message || 'Unknown error', - term - }); + console.error(`Failed to update term ${termId}:`, error); + results.failed.push({ termId, error: error.message || 'Unknown error', term }); } } - // If retrying, merge with existing results if (termsToUpdate && batchUpdateResults) { setBatchUpdateResults({ successful: [...batchUpdateResults.successful, ...results.successful], - failed: results.failed, // Replace failed list with new attempt results + failed: results.failed, total: batchUpdateResults.total }); } else { @@ -196,16 +200,11 @@ const EditBulkTermsDialog = ({ open, handleClose, activeStep, setActiveStep }) = } } catch (error) { console.error('Batch update failed:', error); - const failureResults = { + setBatchUpdateResults({ successful: termsToUpdate && batchUpdateResults ? batchUpdateResults.successful : [], - failed: terms.map(term => ({ - termId: term.id, - error: error.message || 'Batch operation failed', - term - })), + failed: terms.map(t => ({ termId: t['@id'], error: error.message || 'Batch operation failed', term: t })), total: termsToUpdate && batchUpdateResults ? batchUpdateResults.total : terms.length - }; - setBatchUpdateResults(failureResults); + }); } finally { setIsUpdating(false); } @@ -269,9 +268,9 @@ const EditBulkTermsDialog = ({ open, handleClose, activeStep, setActiveStep }) = > <> { - activeStep === 0 && } { diff --git a/src/components/Dashboard/EditBulkTerms/EditTerms.jsx b/src/components/Dashboard/EditBulkTerms/EditTerms.jsx index d7f4052f..763b3461 100644 --- a/src/components/Dashboard/EditBulkTerms/EditTerms.jsx +++ b/src/components/Dashboard/EditBulkTerms/EditTerms.jsx @@ -157,6 +157,12 @@ const EditTerms = ({searchConditions, ontologyTerms, ontologyAttributes, onTerms attributes={attributes} ontologyTerms={filteredTerms} dynamicColumns={ontologyAttributes} + onTermsUpdate={(updatedFiltered) => { + if (!onTermsUpdate) return; + const byIri = {}; + updatedFiltered.forEach(t => { byIri[t['@id']] = t; }); + onTermsUpdate(ontologyTerms.map(t => byIri[t['@id']] || t)); + }} /> { +const SearchTerms = ({ searchConditions, setSearchConditions, initialSearchConditions, ontologyTerms, setOntologyTerms, ontologyAttributes, setOntologyAttributes, selectedOntology, setSelectedOntology, setOriginalTerms, setJsonLdContext }) => { const [ontologyEditOption, setOntologyEditOption] = useState(Confirmation.Yes); const [attributesLoading, setAttributesLoading] = useState(false); const { user } = useContext(GlobalDataContext); @@ -201,7 +201,7 @@ const SearchTerms = ({ searchConditions, setSearchConditions, initialSearchCondi if (term['@type'] === 'owl:Ontology') return; // Create a term object with all its properties - const termObj = { '@id': term['@id'] }; + const termObj = { '@id': term['@id'], '_rawNode': term }; // Process each property Object.keys(term).forEach(key => { @@ -212,8 +212,8 @@ const SearchTerms = ({ searchConditions, setSearchConditions, initialSearchCondi if (Array.isArray(value)) { // For arrays, extract meaningful values termObj[key] = value.map(item => { - if (typeof item === 'object' && item['@id']) { - return item['@id']; + if (typeof item === 'object' && item !== null) { + return JSON.stringify(item); } return item; }).join(', '); @@ -232,7 +232,8 @@ const SearchTerms = ({ searchConditions, setSearchConditions, initialSearchCondi } setOntologyTerms(termsData); - setOriginalTerms([...termsData]); // Store original terms for undo functionality + setOriginalTerms([...termsData]); + if (setJsonLdContext) setJsonLdContext(jsonldData['@context'] || {}); } catch (error) { console.error('Error fetching ontology attributes:', error); setOntologyAttributes([]); @@ -442,6 +443,7 @@ SearchTerms.propTypes = { selectedOntology: PropTypes.object, setSelectedOntology: PropTypes.func.isRequired, setOriginalTerms: PropTypes.func.isRequired, + setJsonLdContext: PropTypes.func, }; export default SearchTerms; diff --git a/src/components/Dashboard/EditBulkTerms/TermsTable.jsx b/src/components/Dashboard/EditBulkTerms/TermsTable.jsx index 13d6977d..d40bf78c 100644 --- a/src/components/Dashboard/EditBulkTerms/TermsTable.jsx +++ b/src/components/Dashboard/EditBulkTerms/TermsTable.jsx @@ -28,7 +28,7 @@ import { getComparator, stableSort } from "../../../helpers"; import { vars } from "../../../theme/variables"; const { gray200, gray50, gray700, brand600, gray800 } = vars; -const TermsTable = ({ setOpenEditAttributes, setAttributes, attributes, ontologyTerms, dynamicColumns }) => { +const TermsTable = ({ setOpenEditAttributes, setAttributes, attributes, ontologyTerms, dynamicColumns, onTermsUpdate }) => { // Memoize the static columns const interlexIdColumn = React.useMemo(() => ({ "id": "@id", @@ -80,7 +80,10 @@ const TermsTable = ({ setOpenEditAttributes, setAttributes, attributes, ontology if (columnId === 'interlex_id') return; setEditingCell({ rowIndex, columnId }); - setEditValue(currentValue || ''); + const editableValue = typeof currentValue === 'object' && currentValue !== null + ? JSON.stringify(currentValue) + : currentValue; + setEditValue(editableValue || ''); }; const handleEditSave = () => { @@ -94,6 +97,7 @@ const TermsTable = ({ setOpenEditAttributes, setAttributes, attributes, ontology }; setTerms(updatedTerms); + if (onTermsUpdate) onTermsUpdate(updatedTerms); setEditingCell(null); setEditValue(''); }; @@ -253,14 +257,15 @@ const TermsTable = ({ setOpenEditAttributes, setAttributes, attributes, ontology const cellValue = row && row[column.id]; return ( - !column.readOnly && handleCellDoubleClick(index, column.id, cellValue)} > @@ -271,10 +276,15 @@ const TermsTable = ({ setOpenEditAttributes, setAttributes, attributes, ontology onKeyDown={handleKeyPress} onBlur={handleEditCancel} autoFocus - size="small" + multiline variant="outlined" fullWidth - sx={{ minWidth: 0 }} + sx={{ + minWidth: 0, + '& .MuiOutlinedInput-root': { borderRadius: 0 }, + '& .MuiOutlinedInput-notchedOutline': { border: '2px solid' }, + '& textarea': { resize: 'none' }, + }} /> ) : Array.isArray(cellValue) && cellValue.length > 0 ? ( @@ -339,6 +349,7 @@ TermsTable.propTypes = { attributes: PropTypes.array, ontologyTerms: PropTypes.array, dynamicColumns: PropTypes.array, + onTermsUpdate: PropTypes.func, }; export default TermsTable; diff --git a/src/components/SingleOrganization/AddNewOntologyDialog.jsx b/src/components/SingleOrganization/AddNewOntologyDialog.jsx index d5cd1387..0f730e8a 100644 --- a/src/components/SingleOrganization/AddNewOntologyDialog.jsx +++ b/src/components/SingleOrganization/AddNewOntologyDialog.jsx @@ -36,6 +36,7 @@ const AddNewOntologyDialog = ({ open, handleClose, onOntologyAdded, organization const [submitting, setSubmitting] = useState(false); const [newOntology, setNewOntology] = useState({ uri: "", + title: "", description: "" }); const [newOntologyResponse, setNewOntologyResponse] = useState({ @@ -56,6 +57,7 @@ const AddNewOntologyDialog = ({ open, handleClose, onOntologyAdded, organization const resetComponentState = () => { setNewOntology({ uri: "", + title: "", description: "" }); setFiles([]); @@ -86,7 +88,7 @@ const AddNewOntologyDialog = ({ open, handleClose, onOntologyAdded, organization const ontologyName = (newOntology?.uri || files?.[0]?.data?.title || "").trim(); if (!ontologyName) return; - const title = ontologyName; + const title = newOntology.title.trim() || ontologyName; const subjects = files?.[0]?.data?.subjects; setSubmitting(true); @@ -380,6 +382,16 @@ const AddNewOntologyDialog = ({ open, handleClose, onOntologyAdded, organization + + + String(t || "").trim().toLowerCase(); + +// Surface a failed backend request (with its URL + backend message) to the +// shared error dialog. +const reportFetchFailure = (context, e) => { + reportApiError({ + context, + url: e?.url || "", + status: e?.status, + message: e?.body || e?.message || "Request failed", + }); +}; + +// normalize ID for API calls +const toILX = (curieLike) => { + const t = (curieLike || "").split("/").pop() || curieLike; + return t.replace(/^ilx_/i, "ILX:"); +}; + +// Normalize a predicate group's title + row predicates from full IRIs to curies. +const shortenGroup = (g) => ({ + ...g, + title: shortenIri(g.title), + tableData: Array.isArray(g.tableData) + ? g.tableData.map((r) => ({ ...r, predicate: shortenIri(r.predicate) })) + : g.tableData, +}); + +// Pure assembly of the predicate groups shown in the table/graph. +// +// TODO(predicates-freshness): TEMPORARY WORKAROUND. The transitive-query +// endpoint (getTermPredicates) does not reflect the term head right after a +// PATCH, so edited literal predicates would still show the old value. Until the +// backend serves head-consistent data, the editable literal predicates are +// overridden with the fresh .jsonld groups. Once fixed, return +// dedupePredicateGroups(predicateGroups.map(shortenGroup)). +const mergePredicates = ({ versionHash, jsonData, predicateGroups, focusCurie, searchTerm }) => { + const termId = focusCurie ? toILX(focusCurie) : searchTerm; + + if (versionHash) { + if (!jsonData) return []; + // isAbout / owl:versionIRI are synthetic markers the snapshot parser adds + // for termParser + Details; they are not real term predicates. + return dedupePredicateGroups( + buildPredicateGroupsForFocus(jsonData, termId) + .filter((g) => !META_TITLES.has(norm(g.title))) + .map(shortenGroup) + ); + } + + const freshGroups = jsonData + ? buildPredicateGroupsForFocus(jsonData, termId).map(shortenGroup) + : []; + const freshLiteralTitles = new Set( + freshGroups.filter((g) => getObjectInputKind(g.title) === "text").map((g) => norm(g.title)) + ); + const freshLiterals = freshGroups.filter((g) => freshLiteralTitles.has(norm(g.title))); + const transitive = (Array.isArray(predicateGroups) ? predicateGroups : []) + .map(shortenGroup) + .filter((g) => !freshLiteralTitles.has(norm(g.title))); + + return dedupePredicateGroups([...freshLiterals, ...transitive]); +}; + +// Fetch + parse both hierarchy directions for a focus id (always from "base"). +const fetchHierarchiesData = async (curieLike) => { + const termId = toILX(curieLike); + const [childRes, superRes] = await Promise.all([ + getTermHierarchies({ groupname: "base", termId, objToSub: true }), + getTermHierarchies({ groupname: "base", termId, objToSub: false }), + ]); + const childTriples = childRes?.triples || []; + const superTriples = superRes?.triples || []; + return { + treeChildren: buildChildrenTreeFromTriples(childTriples, termId), + treeSuperclasses: buildSuperclassesTreeFromTriples(superTriples, termId), + options: { + children: toHierarchyOptionsFromTriples(childTriples), + superclasses: toHierarchyOptionsFromTriples(superTriples), + }, + }; +}; // patchTerm resolves with { status, term } on success, or the raw axios error // (its .catch returns it) on failure. Normalize to { ok, status, message }. @@ -59,153 +141,216 @@ const interpretPatchResult = (res) => { }; const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, group = "base", versionHash }) => { - const [data, setData] = useState(null); - const [pageLoading, setPageLoading] = useState(true); - const [jsonData, setJsonData] = useState(null); - - // SingleSearch options + selection - const [hierarchyOptions, setHierarchyOptions] = useState({}); - const [selectedValue, setSelectedValue] = useState(null); // {id, label} - - // computed trees - const [treeChildren, setTreeChildren] = useState([]); - const [treeSuperclasses, setTreeSuperclasses] = useState([]); - - // predicates - const [predicateGroups, setPredicateGroups] = useState([]); + // Per-instance rxjs streams; each section subscribes to its own. + const storeRef = useRef(); + if (!storeRef.current) storeRef.current = createOverviewStore(); + const store = storeRef.current; + + // Guards against stale async writes after the term/group changed. + const loadTokenRef = useRef(0); + // Latest raw inputs to the predicate merge (arrive independently). + const jsonDataRef = useRef(null); + const groupsRef = useRef([]); + const jsonReadyRef = useRef(false); + const groupsReadyRef = useRef(false); // feedback for inline predicate edits (add/edit/delete) - const [mutationFeedback, setMutationFeedback] = useState(null); // { severity, message } - - // loading flags - const [loadingHierarchies, setLoadingHierarchies] = useState(true); - const [loadingPredicates, setLoadingPredicates] = useState(true); - - // Determine if we should show individual loaders or a single global loader - const allSectionsLoading = pageLoading && loadingHierarchies && loadingPredicates; - const hasAnyData = data !== null || !loadingHierarchies || !loadingPredicates; - const showIndividualLoaders = hasAnyData && !allSectionsLoading; - - // debounced search (explicit deps to satisfy eslint) - const debouncedFetchTerms = useMemo( - () => - debounce(async (term, groupname) => { - if (!term) { - setData(null); - setSelectedValue(null); - setPageLoading(false); - return; - } - try { - const apiData = await getMatchTerms(groupname, term); - const results = apiData?.results || []; - const first = results?.[0] || null; - - // normalize first result -> { id, label } - let id = - first?.curie || - first?.ilx || - first?.id || - first?.termId || - first?.identifier || - null; - let label = - first?.label || - first?.rdfsLabel || - first?.prefLabel || - first?.term || - first?.name || - id; - - if (id) setSelectedValue({ id, label }); - setData(first); - } finally { - setPageLoading(false); - } - }, 300), - [] + const [mutationFeedback, setMutationFeedback] = useState(null); + + // Push merged predicates once both inputs (fresh .jsonld + transitive groups) + // are ready; until then the section stays in its loading state. + const maybePushPredicates = useCallback( + (focus, token) => { + if (token != null && token !== loadTokenRef.current) return; + const ready = jsonReadyRef.current && groupsReadyRef.current; + const focusId = focus?.id || null; + if (!ready) { + store.predicates$.next({ loading: true, data: [], focusId }); + return; + } + const data = mergePredicates({ + versionHash: null, + jsonData: jsonDataRef.current, + predicateGroups: groupsRef.current, + focusCurie: focusId, + searchTerm, + }); + store.predicates$.next({ loading: false, data, focusId }); + }, + [store, searchTerm] ); - const fetchJSONFile = useCallback(() => { - if (!searchTerm) { - setJsonData(null); - return; - } - getRawData(group, searchTerm, "jsonld").then((rawResponse) => { - setJsonData(rawResponse); + // Live (head) load. Each fetch resolves and writes its own stream. + useEffect(() => { + if (versionHash) return undefined; + const token = ++loadTokenRef.current; + const isStale = () => token !== loadTokenRef.current; + + jsonDataRef.current = null; + groupsRef.current = []; + jsonReadyRef.current = false; + groupsReadyRef.current = false; + + store.details$.next({ loading: true, data: null, jsonData: null }); + store.hierarchy$.next({ + loading: true, + options: { children: [], superclasses: [] }, + treeChildren: [], + treeSuperclasses: [], }); - }, [searchTerm, group]); - - // normalize ID for API calls - const toILX = (curieLike) => { - let t = (curieLike || "").split("/").pop() || curieLike; - return t.replace(/^ilx_/i, "ILX:"); - }; - - // fetch both directions + compute trees + build options - const fetchHierarchies = useCallback(async (curieLike, groupname) => { - setLoadingHierarchies(true); - try { - const termId = toILX(curieLike); - - const [childRes, superRes] = await Promise.all([ - getTermHierarchies({ groupname, termId, objToSub: true }), - getTermHierarchies({ groupname, termId, objToSub: false }), - ]); + store.predicates$.next({ loading: true, data: [], focusId: null }); + store.selectedValue$.next(null); + + // React to focus changes: hierarchy + transitive predicates. + const sub = store.selectedValue$.subscribe((sv) => { + if (isStale()) return; + if (!sv?.id) { + store.hierarchy$.next({ + loading: false, + options: { children: [], superclasses: [] }, + treeChildren: [], + treeSuperclasses: [], + }); + groupsReadyRef.current = true; + maybePushPredicates(null, token); + return; + } - const childTriples = childRes?.triples || []; - const superTriples = superRes?.triples || []; + store.hierarchy$.next({ ...store.hierarchy$.getValue(), loading: true }); + fetchHierarchiesData(sv.id) + .then((res) => { + if (isStale()) return; + store.hierarchy$.next({ loading: false, ...res }); + }) + .catch((e) => { + if (isStale()) return; + console.error("fetchHierarchies error:", e); + reportFetchFailure("Loading hierarchy", e); + store.hierarchy$.next({ + loading: false, + options: { children: [], superclasses: [] }, + treeChildren: [], + treeSuperclasses: [], + }); + }); + + groupsReadyRef.current = false; + store.predicates$.next({ ...store.predicates$.getValue(), loading: true, focusId: sv.id }); + getTermPredicates({ groupname: "base", termId: toILX(sv.id) }) + .then((groups) => { + if (isStale()) return; + groupsRef.current = groups || []; + groupsReadyRef.current = true; + maybePushPredicates(sv, token); + }) + .catch((e) => { + if (isStale()) return; + console.error("fetchPredicates error:", e); + reportFetchFailure("Loading predicates", e); + groupsRef.current = []; + groupsReadyRef.current = true; + maybePushPredicates(sv, token); + }); + }); - // trees for the currently selected ID - setTreeChildren(buildChildrenTreeFromTriples(childTriples, termId)); - setTreeSuperclasses(buildSuperclassesTreeFromTriples(superTriples, termId)); + // term match -> details data + the initial focus + (async () => { + if (!searchTerm) { + if (isStale()) return; + store.details$.next({ loading: false, data: null, jsonData: null }); + store.selectedValue$.next(null); + return; + } + try { + const apiData = await getMatchTerms(group, searchTerm); + if (isStale()) return; + const first = apiData?.results?.[0] || null; + const id = + first?.curie || first?.ilx || first?.id || first?.termId || first?.identifier || null; + const label = + first?.label || first?.rdfsLabel || first?.prefLabel || first?.term || first?.name || id; + store.details$.next({ loading: false, data: first, jsonData: jsonDataRef.current }); + store.selectedValue$.next(id ? { id, label } : null); + } catch (e) { + if (isStale()) return; + console.error("term match error:", e); + store.details$.next({ loading: false, data: null, jsonData: jsonDataRef.current }); + store.selectedValue$.next(null); + } + })(); - // update SingleSearch options (union of both) - const children = toHierarchyOptionsFromTriples(childTriples); - const superclasses = toHierarchyOptionsFromTriples(superTriples); - setHierarchyOptions({children: children, superclasses: superclasses}); - setLoadingHierarchies(false); - } catch (e) { - console.error("fetchHierarchies error:", e); - setTreeChildren([]); - setTreeSuperclasses([]); - setHierarchyOptions([]); - setLoadingHierarchies(false); - } - }, []); + // .jsonld -> details.jsonData + fresh literal predicates + (async () => { + try { + const raw = await getRawData(group, searchTerm, "jsonld"); + if (isStale()) return; + jsonDataRef.current = raw; + jsonReadyRef.current = true; + store.details$.next({ ...store.details$.getValue(), jsonData: raw }); + maybePushPredicates(store.selectedValue$.getValue(), token); + } catch (e) { + if (isStale()) return; + console.error("jsonld fetch error:", e); + jsonReadyRef.current = true; // don't block predicates forever + maybePushPredicates(store.selectedValue$.getValue(), token); + } + })(); - const fetchPredicates = useCallback(async (curieLike, groupname) => { - setLoadingPredicates(true); - try { - const termId = toILX(curieLike); - const groups = await getTermPredicates({ groupname, termId }); - setPredicateGroups(groups || []); - } catch (e) { - console.error("fetchPredicates error:", e); - setLoadingPredicates(false); - } finally { - setLoadingPredicates(false); - } - }, []); + return () => { + sub.unsubscribe(); + // Invalidate any in-flight async writes from this load. + // eslint-disable-next-line react-hooks/exhaustive-deps + loadTokenRef.current++; + }; + }, [group, searchTerm, versionHash, store, maybePushPredicates]); - // Live (head) term load. Skipped in version mode — a snapshot is loaded below. - useEffect(() => { - if (versionHash) return; - setPageLoading(true); - setLoadingHierarchies(true); - setLoadingPredicates(true); - debouncedFetchTerms(searchTerm, group); - fetchJSONFile(); - return () => debouncedFetchTerms.cancel(); - }, [searchTerm, group, debouncedFetchTerms, fetchJSONFile, versionHash]); - - // Version mode: load a single term-version snapshot and feed it through the - // same JSON-LD pipeline (Details/predicates/raw) the head view uses. + // Version mode: load a single term-version snapshot through the same pipeline. useEffect(() => { - if (!versionHash || !searchTerm) return; - let active = true; - setPageLoading(true); - setLoadingPredicates(true); + if (!versionHash || !searchTerm) return undefined; + const token = ++loadTokenRef.current; + const isStale = () => token !== loadTokenRef.current; + + store.details$.next({ loading: true, data: null, jsonData: null }); + store.hierarchy$.next({ + loading: true, + options: { children: [], superclasses: [] }, + treeChildren: [], + treeSuperclasses: [], + }); + store.predicates$.next({ loading: true, data: [], focusId: null }); + store.selectedValue$.next(null); + + // Hierarchy still comes from the live graph for the selected focus. + const sub = store.selectedValue$.subscribe((sv) => { + if (isStale() || !sv?.id) { + if (!isStale() && !sv?.id) { + store.hierarchy$.next({ + loading: false, + options: { children: [], superclasses: [] }, + treeChildren: [], + treeSuperclasses: [], + }); + } + return; + } + store.hierarchy$.next({ ...store.hierarchy$.getValue(), loading: true }); + fetchHierarchiesData(sv.id) + .then((res) => { + if (!isStale()) store.hierarchy$.next({ loading: false, ...res }); + }) + .catch((e) => { + if (isStale()) return; + console.error("fetchHierarchies error:", e); + reportFetchFailure("Loading hierarchy", e); + store.hierarchy$.next({ + loading: false, + options: { children: [], superclasses: [] }, + treeChildren: [], + treeSuperclasses: [], + }); + }); + }); + (async () => { try { // Borrow the live head @context (richer curie set) when reachable. @@ -213,156 +358,129 @@ const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, g try { const head = await getRawData(group, searchTerm, "jsonld"); headContext = head?.["@context"]; - } catch { /* fall back to the parser's default context */ } + } catch { + /* fall back to the parser's default context */ + } const snapshot = await getTermVersion(group, searchTerm, versionHash); - if (!active) return; + if (isStale()) return; const jsonld = versionSnapshotToJsonLd(snapshot, versionHash, headContext); - setJsonData(jsonld); - const first = termParser(jsonld, searchTerm)?.results?.[0] || null; - setData(first); - const id = first?.id || searchTerm; - setSelectedValue({ id, label: first?.label || searchTerm }); + store.details$.next({ loading: false, data: first, jsonData: jsonld }); + const sv = { id: first?.id || searchTerm, label: first?.label || searchTerm }; + store.selectedValue$.next(sv); + const data = mergePredicates({ + versionHash, + jsonData: jsonld, + predicateGroups: [], + focusCurie: sv.id, + searchTerm, + }); + store.predicates$.next({ loading: false, data, focusId: sv.id }); } catch (e) { + if (isStale()) return; console.error("loadVersion error:", e); - if (active) { setData(null); setJsonData(null); } - } finally { - if (active) { setPageLoading(false); setLoadingPredicates(false); } + store.details$.next({ loading: false, data: null, jsonData: null }); + store.predicates$.next({ loading: false, data: [], focusId: null }); } })(); - return () => { active = false; }; - }, [versionHash, searchTerm, group]); - useEffect(() => { - if (selectedValue?.id) { - fetchHierarchies(selectedValue.id, "base"); - // In version mode predicates come from the snapshot, not the live graph. - if (!versionHash) fetchPredicates(selectedValue.id, "base"); - } else { - setTreeChildren([]); - setTreeSuperclasses([]); - setPredicateGroups([]); - setHierarchyOptions([]); - } - }, [selectedValue, fetchHierarchies, fetchPredicates, versionHash]); - - // Apply a single predicate triple add/edit/delete to the focus term and PATCH it. - const handlePredicateMutation = useCallback(async (mutation) => { - const patchId = (selectedValue?.id || searchTerm || "").split("/").pop(); - - // Predicate groups are sourced from the "base" graph, so expand curies with - // the base @context (the term's own group serializes a stripped context). - // GET-first guarantees the exact predicate IRIs round-trip. - const baseDoc = await getRawData("base", patchId, "jsonld"); - const context = baseDoc?.["@context"] || jsonData?.["@context"] || {}; - const node = focusNodeFromJsonLd(baseDoc) || focusNodeFromJsonLd(jsonData); - const subject = mutation.subject || node?.["@id"]; - if (!subject) { - setMutationFeedback({ severity: "error", message: "Could not resolve the term subject" }); - return; + return () => { + sub.unsubscribe(); + // Invalidate any in-flight async writes from this load. + // eslint-disable-next-line react-hooks/exhaustive-deps + loadTokenRef.current++; + }; + }, [versionHash, searchTerm, group, store]); + + const handleSelect = useCallback( + (value) => { + store.selectedValue$.next(value); + }, + [store] + ); + + // Refresh .jsonld + term data + transitive predicates after a structural + // (add/delete) mutation, without disturbing hierarchy/focus or scroll. + const reloadAfterMutation = useCallback(async () => { + const focus = store.selectedValue$.getValue(); + try { + const raw = await getRawData(group, searchTerm, "jsonld"); + jsonDataRef.current = raw; + jsonReadyRef.current = true; + store.details$.next({ ...store.details$.getValue(), jsonData: raw }); + } catch (e) { + console.error("reload jsonld error:", e); } - // For edit/delete, match the exact stored object (whitespace/lang/datatype). - const oldObject = - mutation.op === "edit" || mutation.op === "delete" - ? resolveStoredObject(node, mutation.predicate, mutation.oldValue) - : null; - const payload = buildTripleDiff(subject, { ...mutation, oldObject }, context); - // For an edit, the affected row shows an in-row loader until we emit a - // terminal (success/error) update keyed to it. - const isEdit = mutation.op === "edit"; - const rowKey = isEdit - ? makeRowKey(mutation.subject, mutation.predicate, mutation.oldValue) - : null; try { - // patchEndpointsIlx resolves on any 2xx (a 201 returns a bare version - // hash, not JSON) and throws an AxiosError on 4xx/5xx. We don't track the - // returned version id — a plain GET resolves the current head. - await patchEndpointsIlx(group, patchId, { data: payload }); - setMutationFeedback({ severity: "success", message: "Change saved" }); - if (isEdit) { - // Surgical update: refresh only the edited row so the rest of the - // predicates section is left untouched (no spinner / accordion reset). - emitPredicateRowUpdate({ rowKey, newValue: mutation.newValue, status: "success" }); - } else { - // add/delete change the table structure -> full refetch. - fetchJSONFile(); - debouncedFetchTerms(searchTerm, group); - if (selectedValue?.id) fetchPredicates(selectedValue.id, group); - } + const apiData = await getMatchTerms(group, searchTerm); + const first = apiData?.results?.[0] || null; + store.details$.next({ ...store.details$.getValue(), data: first }); } catch (e) { - console.error("handlePredicateMutation error:", e); - const { message } = interpretPatchResult(e); - setMutationFeedback({ severity: "error", message: message || "Could not save change" }); - // Clear the row loader and keep the original value (revert). - if (isEdit) emitPredicateRowUpdate({ rowKey, status: "error" }); + console.error("reload term match error:", e); } - }, [jsonData, selectedValue, searchTerm, group, fetchJSONFile, fetchPredicates, debouncedFetchTerms]); - - const memoData = useMemo(() => data, [data]); - - // Normalize a predicate group's title + row predicates from full IRIs to curies. - const shortenGroup = (g) => ({ - ...g, - title: shortenIri(g.title), - tableData: Array.isArray(g.tableData) - ? g.tableData.map((r) => ({ ...r, predicate: shortenIri(r.predicate) })) - : g.tableData, - }); - - // TODO(predicates-freshness): TEMPORARY WORKAROUND. The transitive-query - // endpoint (getTermPredicates) does not reflect the term head right after a - // PATCH, so edited literal predicates would still show the old value. Until - // the backend serves head-consistent data from that endpoint, we override the - // editable literal predicates with the fresh .jsonld (getRawData) below and - // re-shorten the stripped full-IRI keys to curies. Once the backend is fixed, - // delete freshGroups + the override merge and go back to: - // const predicates = dedupePredicateGroups(predicateGroups); - // Focus-node predicates from the head-resolving .jsonld (fresh after a PATCH). - const freshGroups = useMemo(() => { - if (!jsonData) return []; - const termId = selectedValue?.id ? toILX(selectedValue.id) : searchTerm; - return buildPredicateGroupsForFocus(jsonData, termId).map(shortenGroup); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [jsonData, selectedValue, searchTerm]); - - const predicates = useMemo(() => { - // Version mode: every predicate group comes straight from the snapshot - // JSON-LD (the post-PATCH freshness workaround below does not apply). - if (versionHash) { - if (!jsonData) return []; - const termId = selectedValue?.id ? toILX(selectedValue.id) : searchTerm; - // isAbout / owl:versionIRI are synthetic markers the snapshot parser adds - // for termParser + Details; they are not real term predicates. - const META_TITLES = new Set(["isabout", "ilx.isabout", "owl:versioniri"]); - return dedupePredicateGroups( - buildPredicateGroupsForFocus(jsonData, termId) - .filter((g) => !META_TITLES.has(String(g.title || "").trim().toLowerCase())) - .map(shortenGroup) - ); + if (focus?.id) { + try { + const groups = await getTermPredicates({ groupname: "base", termId: toILX(focus.id) }); + groupsRef.current = groups || []; + groupsReadyRef.current = true; + maybePushPredicates(focus, null); + } catch (e) { + console.error("reload predicates error:", e); + } } + }, [store, group, searchTerm, maybePushPredicates]); + + // Apply a single predicate triple add/edit/delete to the focus term and PATCH. + const handlePredicateMutation = useCallback( + async (mutation) => { + const jsonData = jsonDataRef.current; + const selectedValue = store.selectedValue$.getValue(); + const patchId = (selectedValue?.id || searchTerm || "").split("/").pop(); + + // Predicate groups are sourced from the "base" graph, so expand curies + // with the base @context. GET-first guarantees the predicate IRIs + // round-trip. + const baseDoc = await getRawData("base", patchId, "jsonld"); + const context = baseDoc?.["@context"] || jsonData?.["@context"] || {}; + const node = focusNodeFromJsonLd(baseDoc) || focusNodeFromJsonLd(jsonData); + const subject = mutation.subject || node?.["@id"]; + if (!subject) { + setMutationFeedback({ severity: "error", message: "Could not resolve the term subject" }); + return; + } + const oldObject = + mutation.op === "edit" || mutation.op === "delete" + ? resolveStoredObject(node, mutation.predicate, mutation.oldValue) + : null; + const payload = buildTripleDiff(subject, { ...mutation, oldObject }, context); + + const isEdit = mutation.op === "edit"; + const rowKey = isEdit + ? makeRowKey(mutation.subject, mutation.predicate, mutation.oldValue) + : null; + try { + await patchEndpointsIlx(group, patchId, { data: payload }); + setMutationFeedback({ severity: "success", message: "Change saved" }); + if (isEdit) { + // Surgical update: only the edited row refreshes. + emitPredicateRowUpdate({ rowKey, newValue: mutation.newValue, status: "success" }); + } else { + // add/delete change the table structure -> refresh predicates. + reloadAfterMutation(); + } + } catch (e) { + console.error("handlePredicateMutation error:", e); + const { message } = interpretPatchResult(e); + setMutationFeedback({ severity: "error", message: message || "Could not save change" }); + if (isEdit) emitPredicateRowUpdate({ rowKey, status: "error" }); + } + }, + [store, group, searchTerm, reloadAfterMutation] + ); - const norm = (t) => String(t || "").trim().toLowerCase(); - - // Editable literal predicates (synonym/definition/label) must reflect the - // fresh .jsonld; the transitive-query endpoint lags after a PATCH. - const freshLiteralTitles = new Set( - freshGroups - .filter((g) => getObjectInputKind(g.title) === "text") - .map((g) => norm(g.title)) - ); - const freshLiterals = freshGroups.filter((g) => freshLiteralTitles.has(norm(g.title))); - - // Everything else (relations, inbound partOf, ids) stays on the transitive - // source, minus the literal predicates we just refreshed. - const transitive = (Array.isArray(predicateGroups) ? predicateGroups : []) - .map(shortenGroup) - .filter((g) => !freshLiteralTitles.has(norm(g.title))); - - return dedupePredicateGroups([...freshLiterals, ...transitive]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [freshGroups, predicateGroups]); + const onMutate = versionHash ? undefined : handlePredicateMutation; return ( @@ -370,41 +488,28 @@ const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, g ) : ( <> - {/* Show single global loader when all sections are loading */} - {allSectionsLoading ? ( - - - - ) : ( - <> -

- - - - - - - - - - - - - )} + + + + + + + + + + + + )} ) : undefined} + ); }; diff --git a/src/components/SingleTermView/OverView/OverviewSections.jsx b/src/components/SingleTermView/OverView/OverviewSections.jsx new file mode 100644 index 00000000..f5d8314e --- /dev/null +++ b/src/components/SingleTermView/OverView/OverviewSections.jsx @@ -0,0 +1,83 @@ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { Box } from "@mui/material"; +import Details from "./Details"; +import Hierarchy from "./Hierarchy"; +import Predicates from "./Predicates"; +import { useObservable } from "./overviewStore"; + +// Each section subscribes to its own stream and is memoized, so an unrelated +// OverView re-render (or a sibling section resolving) never re-renders it. +// `reserveHeight` keeps a minimum footprint while loading so late-arriving +// content above a section can't shove a section you are already scrolled to. + +export const DetailsSection = memo(function DetailsSection({ subject, reserveHeight }) { + const { loading, data, jsonData } = useObservable(subject); + return ( + +
+ + ); +}); + +DetailsSection.propTypes = { + subject: PropTypes.object.isRequired, + reserveHeight: PropTypes.number, +}; + +export const HierarchySection = memo(function HierarchySection({ + subject, + selectedSubject, + onSelect, + reserveHeight, +}) { + const { loading, options, treeChildren, treeSuperclasses } = useObservable(subject); + const selectedValue = useObservable(selectedSubject); + return ( + + + + ); +}); + +HierarchySection.propTypes = { + subject: PropTypes.object.isRequired, + selectedSubject: PropTypes.object.isRequired, + onSelect: PropTypes.func, + reserveHeight: PropTypes.number, +}; + +export const PredicatesSection = memo(function PredicatesSection({ + subject, + group, + onMutate, + reserveHeight, +}) { + const { loading, data, focusId } = useObservable(subject); + return ( + + + + ); +}); + +PredicatesSection.propTypes = { + subject: PropTypes.object.isRequired, + group: PropTypes.string, + onMutate: PropTypes.func, + reserveHeight: PropTypes.number, +}; diff --git a/src/components/SingleTermView/OverView/overviewStore.js b/src/components/SingleTermView/OverView/overviewStore.js new file mode 100644 index 00000000..79127767 --- /dev/null +++ b/src/components/SingleTermView/OverView/overviewStore.js @@ -0,0 +1,32 @@ +import { BehaviorSubject } from "rxjs"; +import { useEffect, useState } from "react"; + +// Per-section state streams for the term Overview. OverView writes to these as +// each independent fetch resolves; each section component subscribes only to +// its own stream, so a slow section finishing never re-renders its siblings +// (and never resets the scroll position of a section you are already reading). +export const createOverviewStore = () => ({ + details$: new BehaviorSubject({ loading: true, data: null, jsonData: null }), + hierarchy$: new BehaviorSubject({ + loading: true, + options: { children: [], superclasses: [] }, + treeChildren: [], + treeSuperclasses: [], + }), + predicates$: new BehaviorSubject({ loading: true, data: [], focusId: null }), + // Shared interactive focus (the term selected in the hierarchy). Lives here so + // selecting a node re-drives the hierarchy/predicate fetches without routing + // the value back up through OverView's render. + selectedValue$: new BehaviorSubject(null), +}); + +// Subscribe a component to a BehaviorSubject and re-render on its emissions. +export const useObservable = (subject) => { + const [value, setValue] = useState(() => subject.getValue()); + useEffect(() => { + setValue(subject.getValue()); + const sub = subject.subscribe(setValue); + return () => sub.unsubscribe(); + }, [subject]); + return value; +}; diff --git a/src/components/common/ApiErrorDialog.jsx b/src/components/common/ApiErrorDialog.jsx new file mode 100644 index 00000000..a5327e88 --- /dev/null +++ b/src/components/common/ApiErrorDialog.jsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Stack, + Typography, + Chip, + IconButton, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; +import { apiError$ } from "../../api/apiErrorBus"; +import { vars } from "../../theme/variables"; + +const { gray100, gray200, gray600, gray800, error500, error700 } = vars; + +// Listens on the API error bus and shows a modal listing the requests that +// failed, with the queried URL and the message returned by the backend. +const ApiErrorDialog = () => { + const [errors, setErrors] = useState([]); + + useEffect(() => { + const sub = apiError$.subscribe((error) => { + setErrors((prev) => { + const key = `${error?.context || ""}|${error?.url || ""}|${error?.status || ""}`; + // Avoid stacking the exact same failure multiple times. + if (prev.some((e) => `${e.context || ""}|${e.url || ""}|${e.status || ""}` === key)) { + return prev; + } + return [...prev, error]; + }); + }); + return () => sub.unsubscribe(); + }, []); + + const handleClose = () => setErrors([]); + + return ( + 0} onClose={handleClose} maxWidth="sm" fullWidth> + + + + {errors.length > 1 ? `${errors.length} requests failed` : "Request failed"} + + + + + + + + {errors.map((error, index) => ( + + + {error?.status != null && ( + + )} + {error?.context && ( + + {error.context} + + )} + + {error?.url && ( + + {error.url} + + )} + + {error?.message || "The backend did not return a message."} + + + ))} + + + + + + + ); +}; + +export default ApiErrorDialog; diff --git a/src/components/common/OrganizationsList.jsx b/src/components/common/OrganizationsList.jsx index 1954f8b3..481346de 100644 --- a/src/components/common/OrganizationsList.jsx +++ b/src/components/common/OrganizationsList.jsx @@ -1,7 +1,7 @@ import PropTypes from "prop-types"; import { useNavigate } from "react-router-dom"; import Groups from '@mui/icons-material/Groups'; -import {Box, Typography, Button, List, ListItem, ListItemText} from "@mui/material"; +import {Box, Typography, Button, List, ListItem, ListItemText, Chip} from "@mui/material"; import { vars } from "../../theme/variables"; const { gray50, gray700, gray200, brand600 } = vars; @@ -25,9 +25,6 @@ const OrganizationsList = ({organizations, viewJoinButton = true}) => { '&:hover': { cursor: 'pointer', backgroundColor: gray50, - '& .join-button': { - visibility: 'visible' - }, '& .MuiListItemText-root': { position: 'relative', '&::before': { @@ -74,25 +71,17 @@ const OrganizationsList = ({organizations, viewJoinButton = true}) => { {orgName} {userRole && ( - - {userRole} - + )} } /> - + { viewJoinButton && - ) : ( + ) : user ? ( - )} + ) : null} { - + diff --git a/src/components/TermEditor/TermSidebar.jsx b/src/components/TermEditor/TermSidebar.jsx index 13f88e81..142eef0b 100644 --- a/src/components/TermEditor/TermSidebar.jsx +++ b/src/components/TermEditor/TermSidebar.jsx @@ -99,14 +99,10 @@ export default function TermSidebar({ open, loading, onToggle, data }) { {data?.synonym?.map((synonym) => ( - {synonym} {synonym} - - } + label={synonym} /> ))}