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",
}}
/>
-
+
+
+
{
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 = () => {
} onClick={handleOpenFeatureNotAvailableDialog}>
Request to merge changes to curated
- ) : (
+ ) : user ? (
} onClick={handleOpenForkDialog}>
Create fork
- )}
+ ) : 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 (
+
+ );
+};
+
+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 &&