diff --git a/src/App.jsx b/src/App.jsx
index d836f73..98a9270 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -5,6 +5,7 @@ import {
BrowserRouter as Router,
Routes,
Route,
+ Navigate,
useLocation,
useNavigate
} from "react-router-dom";
@@ -223,6 +224,7 @@ function MainContent() {
}
/>
+ } />
diff --git a/src/api/endpoints/index.ts b/src/api/endpoints/index.ts
index 96478b9..f33e9ef 100644
--- a/src/api/endpoints/index.ts
+++ b/src/api/endpoints/index.ts
@@ -6,6 +6,7 @@ import curieParser from '../../parsers/curieParser';
import termParser, { elasticSearchParser, getTerm } from '../../parsers/termParser';
import axios from 'axios';
import { API_CONFIG } from '../../config';
+import { reportApiError } from '../apiErrorBus';
const useApi = () => api;
const useMockApi = () => mockApi;
@@ -148,6 +149,13 @@ const fetchData = async (url, method = "GET", data: object | null = null) => {
},
withCredentials : true
});
+ // Some backends (e.g. the Elasticsearch proxy) return HTTP 200 with
+ // the failure encoded in the body, so axios never rejects on its own.
+ if (response.data?.error) {
+ const bodyError: any = new Error(response.data.error.message || `Request failed with code ${response.data.error.code}`);
+ bodyError.status = response.data.error.code;
+ throw bodyError;
+ }
return response.data;
} catch (error) {
console.error(`API Error at ${url}:`, error);
@@ -175,6 +183,12 @@ export const elasticSearch = async (
total = initialResponse?.hits?.total ?? 0;
} catch (error) {
console.error("Failed to fetch total count from Elasticsearch:", error);
+ reportApiError({
+ context: `Search "${query}"`,
+ url,
+ status: error?.status ?? error?.response?.status,
+ message: error?.message || "Request failed",
+ });
return { results: [], total: 0 };
}
}
@@ -192,6 +206,12 @@ export const elasticSearch = async (
};
} catch (error) {
console.error("Error when performing elastic search", error);
+ reportApiError({
+ context: `Search "${query}"`,
+ url,
+ status: error?.status ?? error?.response?.status,
+ message: error?.message || "Request failed",
+ });
return { results: [], total: 0 };
}
};
diff --git a/src/components/Auth/Register.jsx b/src/components/Auth/Register.jsx
index 5c35ca3..e75dbee 100644
--- a/src/components/Auth/Register.jsx
+++ b/src/components/Auth/Register.jsx
@@ -16,7 +16,7 @@ import { API_CONFIG } from "../../config";
import { useCookies } from 'react-cookie';
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 { GlobalDataContext } from "../../contexts/DataContext";
const OLYMPIAN_GODS = import.meta.env.MODE === "production" ? "" : API_CONFIG.OLYMPIAN_GODS;
@@ -54,11 +54,12 @@ const Register = () => {
const [existingCookies, setCookie, removeCookie] = useCookies(['session']);
const prevSnackbarOpen = React.useRef(snackbarOpen);
const navigate = useNavigate();
+ const location = useLocation();
React.useEffect(() => {
if (prevSnackbarOpen.current && !snackbarOpen) {
closePopups();
- navigate("/login");
+ navigate("/login", { state: { from: location.state?.from } });
}
prevSnackbarOpen.current = snackbarOpen;
// eslint-disable-next-line react-hooks/exhaustive-deps
diff --git a/src/components/CurieEditor/CuriesTabPanel.jsx b/src/components/CurieEditor/CuriesTabPanel.jsx
index 27e5a49..c7fd6f0 100644
--- a/src/components/CurieEditor/CuriesTabPanel.jsx
+++ b/src/components/CurieEditor/CuriesTabPanel.jsx
@@ -53,11 +53,39 @@ const CuriesTabPanel = (props) => {
const [columnIndex, setColumnIndex] = React.useState(-1);
const [order, setOrder] = React.useState('asc');
const [orderBy, setOrderBy] = React.useState('prefix');
+ const [displayOrder, setDisplayOrder] = React.useState([]);
+ const isEditing = rowId !== null;
- const sortedRows = React.useMemo(() => {
+ const sortIds = (rowsArr) => stableSort(rowsArr, getComparator(order, orderBy)).map((row) => row._id);
+
+ // Manual header sort always re-orders, even mid-edit.
+ React.useEffect(() => {
+ setDisplayOrder(sortIds(Array.isArray(rows) ? rows : []));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [order, orderBy]);
+
+ // Row content changes (typing, add/delete) only trigger a full re-sort once
+ // editing is done; while editing, just reconcile which rows exist so a row
+ // doesn't jump position under the user as soon as it gets a value.
+ React.useEffect(() => {
const safeRows = Array.isArray(rows) ? rows : [];
- return stableSort(safeRows, getComparator(order, orderBy));
- }, [rows, order, orderBy]);
+ if (!isEditing) {
+ setDisplayOrder(sortIds(safeRows));
+ return;
+ }
+ const currentIds = safeRows.map((row) => row._id);
+ setDisplayOrder((prevOrder) => {
+ const stillPresent = prevOrder.filter((id) => currentIds.includes(id));
+ const newIds = currentIds.filter((id) => !prevOrder.includes(id));
+ return [...stillPresent, ...newIds];
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [rows, isEditing]);
+
+ const sortedRows = React.useMemo(() => {
+ const rowsById = new Map((Array.isArray(rows) ? rows : []).map((row) => [row._id, row]));
+ return displayOrder.map((id) => rowsById.get(id)).filter(Boolean);
+ }, [displayOrder, rows]);
React.useEffect(() => {
onCurieAmountChange?.(rows.length)
diff --git a/src/components/CurieEditor/index.jsx b/src/components/CurieEditor/index.jsx
index c8215c0..cd74eb2 100644
--- a/src/components/CurieEditor/index.jsx
+++ b/src/components/CurieEditor/index.jsx
@@ -30,7 +30,11 @@ const CurieEditor = () => {
setOpenCurieEditor(true);
};
- const handleCloseCurieEditor = () => setOpenCurieEditor(false);
+ const handleCloseCurieEditor = () => {
+ // Discard any unsaved edits/added rows made in the dialog.
+ setLocalCuries(curies);
+ setOpenCurieEditor(false);
+ };
const handleChangeTabs = (event, newValue) => setTabValue(newValue);
const handleCurieAmountChange = (value) => setCurieAmount(value);
@@ -126,7 +130,6 @@ const CurieEditor = () => {
loading={curiesLoading}
editMode={tab === 'base'}
rows={localCuries[tab]}
- onCurieAmountChange={handleCurieAmountChange}
onAddRow={handleAddNewCurieRow}
onDeleteRow={handleDeleteCurieRow}
onChangeRow={handleInputChangeCurieRow}
diff --git a/src/components/Header/Search.jsx b/src/components/Header/Search.jsx
index 6084872..f284b13 100644
--- a/src/components/Header/Search.jsx
+++ b/src/components/Header/Search.jsx
@@ -10,6 +10,7 @@ import {
Chip,
List,
ListItem,
+ LinearProgress,
} from "@mui/material";
import { debounce } from 'lodash';
import PropTypes from 'prop-types';
@@ -91,6 +92,7 @@ const Search = () => {
const [terms, setTerms] = useState([]);
const [organizations, setOrganizations] = useState([]);
const [ontologies, setOntologies] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
const { storedSearchTerm, updateStoredSearchTerm, user } = useContext(GlobalDataContext);
// Get the group name based on user login status
@@ -155,6 +157,7 @@ const Search = () => {
setTerms([])
setOntologies([])
setOrganizations([])
+ setIsSearching(false);
};
const escapeSearch = useCallback(() => {
@@ -164,11 +167,14 @@ const Search = () => {
setTerms([])
setOntologies([])
setOrganizations([])
+ setIsSearching(false);
}, []);
const handleKeyDown = useCallback(event => {
- if (event.ctrlKey && event.key === 'k') {
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
+ event.preventDefault();
setOpenList(true);
+ document.getElementById('interlex-search-input')?.focus();
}
if (event.key === 'Escape') {
escapeSearch();
@@ -182,13 +188,18 @@ const Search = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const fetchTerms = useCallback(debounce(async (searchTerm) => {
- const data = await elasticSearch(searchTerm, 20, 0);
- const dataTerms = data?.results.results?.filter(result => result.type === SEARCH_TYPES.TERM);
- const dataOrganizations = data?.results.results?.filter(result => result.type === SEARCH_TYPES.ORGANIZATION);
- const dataOntologies = data?.results.results?.filter(result => result.type === SEARCH_TYPES.ONTOLOGY);
- setTerms(dataTerms);
- setOrganizations(dataOrganizations);
- setOntologies(dataOntologies);
+ setIsSearching(true);
+ try {
+ const data = await elasticSearch(searchTerm, 20, 0);
+ const dataTerms = data?.results.results?.filter(result => result.type === SEARCH_TYPES.TERM);
+ const dataOrganizations = data?.results.results?.filter(result => result.type === SEARCH_TYPES.ORGANIZATION);
+ const dataOntologies = data?.results.results?.filter(result => result.type === SEARCH_TYPES.ONTOLOGY);
+ setTerms(dataTerms);
+ setOrganizations(dataOrganizations);
+ setOntologies(dataOntologies);
+ } finally {
+ setIsSearching(false);
+ }
}, 500), [searchAll]);
useEffect(() => {
@@ -208,6 +219,7 @@ const Search = () => {
const ListboxComponent = forwardRef(function ListboxComponent(props, ref) {
return (
<>
+ {isSearching && }
{searchTerm && (<>
{
placeholder="Find something..."
onChange={handleInputChange}
onKeyDown={handleEnterKey}
+ inputProps={{
+ ...params.inputProps,
+ id: 'interlex-search-input',
+ }}
InputProps={{
...params.InputProps,
startAdornment: (
@@ -373,7 +389,7 @@ const Search = () => {
),
endAdornment: (
- {openList ? (
+ {searchTerm ? (
{
>
- Esc
+ {openList && Esc}
) : (
Ctrl + K
diff --git a/src/components/Header/index.jsx b/src/components/Header/index.jsx
index a9595b5..682e0b8 100644
--- a/src/components/Header/index.jsx
+++ b/src/components/Header/index.jsx
@@ -23,7 +23,7 @@ import Search from './Search';
import { useContext } from "react";
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
-import { useNavigate } from "react-router-dom";
+import { useNavigate, useLocation } from "react-router-dom";
import Logo from '../../Icons/svg/interlex_logo.svg'
import ListItemText from '@mui/material/ListItemText';
import ListItemIcon from '@mui/material/ListItemIcon';
@@ -195,6 +195,7 @@ const Header = () => {
setUserData(user, organization);
};
const navigate = useNavigate();
+ const location = useLocation();
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
@@ -233,16 +234,6 @@ const Header = () => {
const openUser = Boolean(anchorElUser);
const idUser = open ? 'simple-popover' : undefined;
- const [openList, setOpenList] = React.useState(false);
-
- const handleCloseList = () => {
- setOpenList(false);
- };
-
- const toggleList = () => {
- setOpenList(!openList);
- };
-
const handleMenuClick = async (e, menu) => {
// Close both popovers
handleClose();
@@ -267,24 +258,6 @@ const Header = () => {
}
}
- React.useEffect(() => {
- const handleKeyDown = (event) => {
- if (event.ctrlKey && event.key === 'k') {
- toggleList();
- }
- if (event.key === 'Escape') {
- handleCloseList();
- }
- };
-
- document.addEventListener('keydown', handleKeyDown);
-
- return () => {
- document.removeEventListener('keydown', handleKeyDown);
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
React.useEffect(() => {
console.log("Stored user in context ", user)
if (user !== null && user?.groupname !== undefined) {
@@ -383,8 +356,8 @@ const Header = () => {
{!isLoggedIn ? (
-
-
+
+
diff --git a/src/components/SearchResults/ListView.jsx b/src/components/SearchResults/ListView.jsx
index c210013..014f312 100644
--- a/src/components/SearchResults/ListView.jsx
+++ b/src/components/SearchResults/ListView.jsx
@@ -59,7 +59,7 @@ const TitleSection = ({ searchResult, onAddToActiveOntology }) => {
const Description = ({ description }) => {
return (
- {description === '' ? '-' : description}
+ {description || '-'}
);
};
@@ -200,7 +200,7 @@ const ListView = ({ searchResults, loading }) => {
-
+
diff --git a/src/components/SearchResults/SearchResultsBox.jsx b/src/components/SearchResults/SearchResultsBox.jsx
index f6d05d0..1cd7a07 100644
--- a/src/components/SearchResults/SearchResultsBox.jsx
+++ b/src/components/SearchResults/SearchResultsBox.jsx
@@ -5,7 +5,7 @@ import { TableChartIcon, ListIcon } from '../../Icons';
import OntologySearch from '../SingleTermView/OntologySearch';
import CustomSingleSelect from '../common/CustomSingleSelect';
import CustomViewButton from '../common/CustomViewButton';
-import { Box, Typography, Grid, ButtonGroup, Stack, Divider } from '@mui/material';
+import { Box, Typography, Grid, ButtonGroup, Stack, Divider, CircularProgress } from '@mui/material';
import CustomPagination from '../common/CustomPagination';
import { vars } from '../../theme/variables';
import { GlobalDataContext } from '../../contexts/DataContext';
@@ -45,7 +45,6 @@ const getPaginationSettings = (totalItems) => {
};
const SearchResultsBox = ({
- allResults,
pageResults,
searchTerm,
loading,
@@ -99,8 +98,12 @@ const SearchResultsBox = ({
-
- {allResults.length} results for {searchTerm} search
+
+ {loading ? (
+
+ ) : (
+ `${totalItems} results for ${searchTerm} search`
+ )}
@@ -154,7 +157,6 @@ const SearchResultsBox = ({
};
SearchResultsBox.propTypes = {
- allResults: PropTypes.object,
pageResults: PropTypes.object,
searchTerm: PropTypes.string,
loading: PropTypes.bool,
diff --git a/src/components/SearchResults/index.jsx b/src/components/SearchResults/index.jsx
index 604b762..ff7d50c 100644
--- a/src/components/SearchResults/index.jsx
+++ b/src/components/SearchResults/index.jsx
@@ -4,6 +4,7 @@ import { debounce } from 'lodash';
import { useQuery } from '../../helpers';
import SearchResultsBox from './SearchResultsBox';
import FiltersSidebar from '../Sidebar/FiltersSidebar';
+import ApiErrorDialog from '../common/ApiErrorDialog';
import { elasticSearch } from '../../api/endpoints';
const SearchResults = () => {
@@ -98,13 +99,13 @@ const SearchResults = () => {
return (
<>
+
{
+const HeaderRightSideContent = ({ handleClose, onAddNewOntology, disabled, ready }) => {
return (
)
@@ -28,7 +28,8 @@ const HeaderRightSideContent = ({ handleClose, onAddNewOntology, disabled }) =>
HeaderRightSideContent.propTypes = {
handleClose: PropTypes.func,
onAddNewOntology: PropTypes.func,
- disabled: PropTypes.bool
+ disabled: PropTypes.bool,
+ ready: PropTypes.bool
}
const AddNewOntologyDialog = ({ open, handleClose, onOntologyAdded, organizationName }) => {
@@ -350,6 +351,7 @@ const AddNewOntologyDialog = ({ open, handleClose, onOntologyAdded, organization
handleClose={handleDialogClose}
onAddNewOntology={handleAddNewOntology}
disabled={submitting || !newOntology.uri.trim()}
+ ready={!!newOntology.uri.trim()}
/>
}
>
diff --git a/src/components/SingleTermView/OverView/OverView.jsx b/src/components/SingleTermView/OverView/OverView.jsx
index 028b27a..1b10b8b 100644
--- a/src/components/SingleTermView/OverView/OverView.jsx
+++ b/src/components/SingleTermView/OverView/OverView.jsx
@@ -28,6 +28,7 @@ import {
} from "../../../parsers/hierarchies-parser";
import { createOverviewStore } from "./overviewStore";
import { DetailsSection, HierarchySection, PredicatesSection } from "./OverviewSections";
+import OverviewSideNav from "./OverviewSideNav";
import { emitPredicateRowUpdate, makeRowKey } from "./predicateMutationBus";
import { reportApiError } from "../../../api/apiErrorBus";
import ApiErrorDialog from "../../common/ApiErrorDialog";
@@ -39,6 +40,12 @@ const DETAILS_MIN_HEIGHT = 240;
const HIERARCHY_MIN_HEIGHT = 420;
const PREDICATES_MIN_HEIGHT = 320;
+const SIDE_NAV_ITEMS = [
+ { id: "overview-section-details", label: "Details" },
+ { id: "overview-section-hierarchy", label: "Hierarchy & relations" },
+ { id: "overview-section-predicates", label: "Predicates" },
+];
+
const META_TITLES = new Set(["isabout", "ilx.isabout", "owl:versioniri"]);
const norm = (t) => String(t || "").trim().toLowerCase();
@@ -484,11 +491,14 @@ const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, g
) : (
<>
-
+
+
+
+
-
+
-
+
{
+ const [activeId, setActiveId] = useState(items[0]?.id);
+
+ useEffect(() => {
+ const elements = items
+ .map(({ id }) => document.getElementById(id))
+ .filter(Boolean);
+ if (!elements.length) return undefined;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const visible = entries
+ .filter((entry) => entry.isIntersecting)
+ .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
+ if (visible) setActiveId(visible.target.id);
+ },
+ { rootMargin: "-15% 0px -70% 0px", threshold: [0, 0.25, 0.5, 0.75, 1] }
+ );
+
+ elements.forEach((el) => observer.observe(el));
+ return () => observer.disconnect();
+ }, [items]);
+
+ const handleClick = (id) => {
+ document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
+ };
+
+ return (
+
+ {items.map(({ id, label }) => (
+ handleClick(id)}
+ sx={{
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "flex-end",
+ gap: "0.5rem",
+ p: 0,
+ border: "none",
+ background: "none",
+ cursor: "pointer",
+ "&:hover .overview-side-nav-dot": {
+ transform: "scale(1.6)",
+ backgroundColor: activeId === id ? brand600 : gray600,
+ },
+ "&:hover .overview-side-nav-label": {
+ opacity: 1,
+ transform: "translateX(0)",
+ },
+ }}
+ >
+
+ {label}
+
+
+
+ ))}
+
+ );
+};
+
+OverviewSideNav.propTypes = {
+ items: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ })
+ ).isRequired,
+};
+
+export default OverviewSideNav;