diff --git a/package.json b/package.json index 9415d12..a37f0c8 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/App.jsx b/src/App.jsx index 08151ea..d836f73 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -45,12 +45,13 @@ const PageContainer = ({ children }) => { const ProtectedRoute = ({ children }) => { const { user } = useContext(GlobalDataContext); const navigate = useNavigate(); + const location = useLocation(); useEffect(() => { if (!user) { - navigate('/login'); + navigate('/login', { state: { from: location.pathname + location.search } }); } - }, [user, navigate]); + }, [user, navigate, location]); return user ? children : null; }; diff --git a/src/api/apiErrorBus.js b/src/api/apiErrorBus.js new file mode 100644 index 0000000..73dd6ae --- /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 1b88f38..d0a1a96 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/Auth/Login.jsx b/src/components/Auth/Login.jsx index c29181c..92f4449 100644 --- a/src/components/Auth/Login.jsx +++ b/src/components/Auth/Login.jsx @@ -18,7 +18,7 @@ import { requestUserSettings } from "./utils"; import Checkbox from "@mui/material/Checkbox"; import PasswordField from "./UI/PasswordField"; import { ArrowBack } from "@mui/icons-material"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useLocation } from "react-router-dom"; import { login } from "../../api/endpoints/apiService"; import { GlobalDataContext } from "../../contexts/DataContext"; import { CheckedIcon, UncheckedIcon, OrcidIcon } from "../../Icons"; @@ -51,6 +51,8 @@ const Login = () => { const { setUserData } = React.useContext(GlobalDataContext); const navigate = useNavigate(); + const location = useLocation(); + const redirectTo = location.state?.from || "/"; React.useEffect(() => { let eventMethod = window.addEventListener ? "addEventListener" : "attachEvent"; @@ -109,7 +111,7 @@ const Login = () => { settings: userData }); closePopups(); - navigate("/", { replace: true }); + navigate(redirectTo, { replace: true }); } catch (error) { console.error("Error fetching user settings:", error); removeCookie('session', { path: '/' }); @@ -185,7 +187,7 @@ const Login = () => { settings: userData }); closePopups(); - navigate("/", { replace: true }); + navigate(redirectTo, { replace: true }); } catch (error) { console.error("Error fetching user settings:", error); removeCookie('session', { path: '/' }); @@ -201,7 +203,7 @@ const Login = () => { setUserData({ name: orcid_meta.name, id: orcid_meta.orcid }); } closePopups(); - navigate("/", { replace: true }); + navigate(redirectTo, { replace: true }); } } catch (error) { console.error("Login error:", error); diff --git a/src/components/Dashboard/EditBulkTerms/EditBulkTermsDialog.jsx b/src/components/Dashboard/EditBulkTerms/EditBulkTermsDialog.jsx index 4a13437..a96f6e5 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 } @@ -76,8 +77,8 @@ const EditBulkTermsDialog = ({ open, handleClose, activeStep, setActiveStep }) = const [ontologyTerms, setOntologyTerms] = useState([]); const [ontologyAttributes, setOntologyAttributes] = useState([]); 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 +89,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 (_e) { /* not valid JSON array, fall through to plain literal */ } + 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 +199,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 +267,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 d7f4052..763b346 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([]); @@ -240,7 +241,7 @@ const SearchTerms = ({ searchConditions, setSearchConditions, initialSearchCondi } finally { setAttributesLoading(false); } - }, [setOntologyAttributes, setOntologyTerms, setSelectedOntology, setOriginalTerms]); + }, [setOntologyAttributes, setOntologyTerms, setSelectedOntology, setOriginalTerms, setJsonLdContext]); const updatedColumnsArray = useMemo(() => { // If an ontology is selected and we have attributes, use those @@ -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 13d6977..d40bf78 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/GraphViewer/Graph.jsx b/src/components/GraphViewer/Graph.jsx index e2e224e..e343455 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} diff --git a/src/components/Header/Search.jsx b/src/components/Header/Search.jsx index d01c6c2..6084872 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 8d0511a..c210013 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/SingleOrganization/AddNewOntologyDialog.jsx b/src/components/SingleOrganization/AddNewOntologyDialog.jsx index d5cd138..0f730e8 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 + + + { 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/Details.jsx b/src/components/SingleTermView/OverView/Details.jsx index 3cf5abd..b1a3d23 100644 --- a/src/components/SingleTermView/OverView/Details.jsx +++ b/src/components/SingleTermView/OverView/Details.jsx @@ -3,6 +3,7 @@ import { Chip, CircularProgress, Grid, Stack, + Tooltip, Typography } from "@mui/material"; import PropTypes from "prop-types"; @@ -12,6 +13,8 @@ import { formatTimestamp } from "../../../utils"; import { vars } from "../../../theme/variables"; const { gray800, gray500 } = vars; +const RELATED_SYNONYM_IRI = "http://uri.interlex.org/base/ilx_0737162"; + const Details = ({ loading, data, jsonData }) => { const handleChipClick = (url) => { window.open(url, '_blank'); @@ -24,7 +27,21 @@ const Details = ({ loading, data, jsonData }) => { return [existingID]; } return []; - } + }; + + const getSynonymGroups = () => { + const synonyms = processExistingIds(data?.synonym); + const graph = jsonData?.["@graph"]; + const focusNode = Array.isArray(graph) + ? graph.find(n => String(n?.["@type"] || "").toLowerCase().includes("class")) || null + : null; + const relatedRaw = focusNode?.[RELATED_SYNONYM_IRI]; + const relatedArr = Array.isArray(relatedRaw) ? relatedRaw : relatedRaw ? [relatedRaw] : []; + const related = relatedArr + .map(v => (typeof v === "string" ? v : v?.["@value"] || null)) + .filter(Boolean); + return { synonyms, related }; + }; if (loading) { return @@ -50,23 +67,45 @@ const Details = ({ loading, data, jsonData }) => { - + Synonyms - - {data?.synonym && processExistingIds(data?.synonym).map((syn) => ( - - {syn} {syn} - - } - />)) - } - + {(() => { + const { synonyms, related } = getSynonymGroups(); + return ( + + {synonyms.length > 0 && ( + + {synonyms.map((syn) => ( + + + + ))} + + )} + {related.length > 0 && ( + <> + Related + + {related.map((syn) => ( + + + + ))} + + + )} + {synonyms.length === 0 && related.length === 0 && null} + + ); + })()} diff --git a/src/components/SingleTermView/OverView/OverView.jsx b/src/components/SingleTermView/OverView/OverView.jsx index 051314e..5542fb7 100644 --- a/src/components/SingleTermView/OverView/OverView.jsx +++ b/src/components/SingleTermView/OverView/OverView.jsx @@ -1,19 +1,8 @@ // SingleTermView/OverView/OverView.jsx -import { - Box, - Divider, - Grid, - CircularProgress, - Snackbar, - Alert, -} from "@mui/material"; -import Details from "./Details"; -import { debounce } from "lodash"; +import { Box, Divider, Grid, Snackbar, Alert } from "@mui/material"; import PropTypes from "prop-types"; -import Hierarchy from "./Hierarchy"; -import Predicates from "./Predicates"; import RawDataViewer from "./RawDataViewer"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { getMatchTerms, getRawData, @@ -31,13 +20,107 @@ import { } from "../../../parsers/predicateMutations"; import { buildPredicateGroupsForFocus } from "../../../parsers/predicateParser"; import { shortenIri, getObjectInputKind } from "../../../configuration/predicateConfig"; - import { toHierarchyOptionsFromTriples, buildChildrenTreeFromTriples, buildSuperclassesTreeFromTriples, - dedupePredicateGroups + dedupePredicateGroups, } from "../../../parsers/hierarchies-parser"; +import { createOverviewStore } from "./overviewStore"; +import { DetailsSection, HierarchySection, PredicatesSection } from "./OverviewSections"; +import { emitPredicateRowUpdate, makeRowKey } from "./predicateMutationBus"; +import { reportApiError } from "../../../api/apiErrorBus"; +import ApiErrorDialog from "../../common/ApiErrorDialog"; + +// Reserved minimum heights while a section loads, so content arriving in one +// section can't shove a section the user is already scrolled to. +const DETAILS_MIN_HEIGHT = 240; +const HIERARCHY_MIN_HEIGHT = 420; +const PREDICATES_MIN_HEIGHT = 320; + +const META_TITLES = new Set(["isabout", "ilx.isabout", "owl:versioniri"]); +const norm = (t) => 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 }. @@ -58,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. @@ -212,141 +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); 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); + 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" }); + 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 ( @@ -354,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 0000000..f5d8314 --- /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/PredicatesAccordion.jsx b/src/components/SingleTermView/OverView/PredicatesAccordion.jsx index 3589d0a..539b3f1 100644 --- a/src/components/SingleTermView/OverView/PredicatesAccordion.jsx +++ b/src/components/SingleTermView/OverView/PredicatesAccordion.jsx @@ -14,7 +14,7 @@ import PropTypes from "prop-types"; import Graph from "../../GraphViewer/Graph"; import CustomizedTable from "./CustomizedTable"; import ViewDiagramDialog from "./ViewDiagramDialog"; -import CallMadeIcon from '@mui/icons-material/CallMade'; + import { FullscreenOutlined } from "@mui/icons-material"; import { TableChartIcon, GraphIcon } from "../../../Icons"; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; @@ -85,7 +85,6 @@ const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible, focusI > {pred.title} - diff --git a/src/components/SingleTermView/OverView/TableRow.jsx b/src/components/SingleTermView/OverView/TableRow.jsx index 47562ea..d8d9602 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/overviewStore.js b/src/components/SingleTermView/OverView/overviewStore.js new file mode 100644 index 0000000..7912776 --- /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/SingleTermView/OverView/predicateMutationBus.js b/src/components/SingleTermView/OverView/predicateMutationBus.js new file mode 100644 index 0000000..e517207 Binary files /dev/null and b/src/components/SingleTermView/OverView/predicateMutationBus.js differ diff --git a/src/components/SingleTermView/index.jsx b/src/components/SingleTermView/index.jsx index b9d3094..d3bb2d8 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); @@ -301,11 +344,11 @@ const SingleTermView = () => { // Can only add when an ontology is active and the term isn't already in it. disabled: !hasActiveOntology || isTermInActiveOntology }, - { + ...(user ? [{ icon: , label: "Create fork", action: handleCreateFork - }, + }] : []), { icon: , label: "Add term to another ontology", @@ -336,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 @@ -368,11 +414,11 @@ const SingleTermView = () => { - ) : ( + ) : user ? ( - )} + ) : null} { - + diff --git a/src/components/TermEditor/TermSidebar.jsx b/src/components/TermEditor/TermSidebar.jsx index 13f88e8..142eef0 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} /> ))} diff --git a/src/components/TermEditor/newTerm/FirstStepContent.jsx b/src/components/TermEditor/newTerm/FirstStepContent.jsx index 49b672a..a741b9c 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/components/common/ApiErrorDialog.jsx b/src/components/common/ApiErrorDialog.jsx new file mode 100644 index 0000000..a5327e8 --- /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 1954f8b..481346d 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 &&