diff --git a/src/components/CellCards/CellCard/CellCardPanel.jsx b/src/components/CellCards/CellCard/CellCardPanel.jsx
index 09f1f1f..ea29d75 100644
--- a/src/components/CellCards/CellCard/CellCardPanel.jsx
+++ b/src/components/CellCards/CellCard/CellCardPanel.jsx
@@ -1,7 +1,7 @@
import { useCallback } from "react";
import PropTypes from "prop-types";
import { useNavigate, useLocation } from "react-router-dom";
-import { Box, Container, Stack, Skeleton, Alert, AlertTitle, Button, Typography } from "@mui/material";
+import { Box, Container, Stack, Alert, AlertTitle, Button, Typography } from "@mui/material";
import CellCard from "./CellCard";
import EmptyState from "../../common/EmptyState";
import { useContextTerm } from "../../../hooks/useContextOntology";
@@ -14,9 +14,6 @@ import {
} from "../config/gridConfig";
import { useTermRecordAvailability } from "../../../hooks/useTermRecordAvailability";
-// A three-column skeleton, so the (unavoidable) whole-ontology load reads as the page arriving
-// rather than as a blank panel. Cold entry pays a ~16MB fetch + a 39,788-node parse before the
-// first cell can render; navigating from the grid hits the memoized parse and skips both.
// PageContainer fixes the page height and expects each tab to scroll internally (OverView does the
// same). Without this the card runs on underneath the site footer.
//
@@ -29,26 +26,6 @@ const Scroll = ({ children }) => (
Scroll.propTypes = { children: PropTypes.node };
-const LoadingSkeleton = () => (
-
- {[0, 1, 2].map((col) => (
-
-
- {Array.from({ length: col === 1 ? 8 : 6 }).map((_, i) => (
-
- ))}
-
- ))}
-
-);
-
/**
* The Cell Card tab: resolves the term against the context ontology and renders the card.
*
@@ -93,13 +70,8 @@ const CellCardPanel = ({ term, group }) => {
[data, goToCell, group, ontologySlug]
);
- if (loading) {
- return (
-
-
-
- );
- }
+ // The page's loading overlay covers the ontology load (a ~16MB fetch + parse on cold entry).
+ if (loading) return null;
if (error) {
return (
diff --git a/src/components/SingleTermView/CreateForkDialog.jsx b/src/components/SingleTermView/CreateForkDialog.jsx
index 495a235..2dd0281 100644
--- a/src/components/SingleTermView/CreateForkDialog.jsx
+++ b/src/components/SingleTermView/CreateForkDialog.jsx
@@ -27,7 +27,7 @@ HeaderRightSideContent.propTypes = {
isSaving: PropTypes.bool,
};
-const CreateForkDialog = ({ open, handleClose, user, searchTerm, termLabel, group }) => {
+const CreateForkDialog = ({ open, handleClose, onForkCreated, user, searchTerm, termLabel, group }) => {
const navigate = useNavigate();
const [ownerNotSupportedOpen, setOwnerNotSupportedOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
@@ -45,6 +45,7 @@ const CreateForkDialog = ({ open, handleClose, user, searchTerm, termLabel, grou
if (result.ok) {
handleClose();
navigate(`/${groupname}/${searchTerm}/overview`);
+ onForkCreated?.();
} else {
setSaveError(`Fork creation failed (status ${result.status}). Please try again.`);
}
@@ -140,6 +141,7 @@ const CreateForkDialog = ({ open, handleClose, user, searchTerm, termLabel, grou
CreateForkDialog.propTypes = {
open: PropTypes.bool,
handleClose: PropTypes.func,
+ onForkCreated: PropTypes.func,
user: PropTypes.object,
searchTerm: PropTypes.string,
termLabel: PropTypes.string,
diff --git a/src/components/SingleTermView/Discussion/index.jsx b/src/components/SingleTermView/Discussion/index.jsx
index dccb27f..3260ffd 100644
--- a/src/components/SingleTermView/Discussion/index.jsx
+++ b/src/components/SingleTermView/Discussion/index.jsx
@@ -6,6 +6,7 @@ import { vars } from "../../../theme/variables";
import { useState, useRef, useEffect } from "react";
import CommentEditor from "./CommentEditor";
import { getTermDiscussions } from "../../../api/endpoints/apiService";
+import { useReportLoading } from "../../../contexts/pageLoading";
const { gray25, gray200, gray700 } = vars;
@@ -14,6 +15,8 @@ const Discussion = ({ term }) => {
const commentsEndRef = useRef(null);
const [discussions, setDiscussions] = useState([]);
const [showMockWarning, setShowMockWarning] = useState(true);
+ const [loading, setLoading] = useState(true);
+ useReportLoading(loading);
const getDiscussions = async () => {
try {
@@ -21,6 +24,8 @@ const Discussion = ({ term }) => {
setDiscussions(Array.isArray(data) ? data : [])
} catch (error) {
setDiscussions([])
+ } finally {
+ setLoading(false)
}
}
diff --git a/src/components/SingleTermView/ExistingForkDialog.jsx b/src/components/SingleTermView/ExistingForkDialog.jsx
new file mode 100644
index 0000000..e3766f9
--- /dev/null
+++ b/src/components/SingleTermView/ExistingForkDialog.jsx
@@ -0,0 +1,47 @@
+import PropTypes from "prop-types";
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+} from "@mui/material";
+import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
+
+const ExistingForkDialog = ({ open, handleClose, groupname, termLabel, onUseExisting, onStartFromScratch }) => (
+
+);
+
+ExistingForkDialog.propTypes = {
+ open: PropTypes.bool.isRequired,
+ handleClose: PropTypes.func.isRequired,
+ groupname: PropTypes.string,
+ termLabel: PropTypes.string,
+ onUseExisting: PropTypes.func.isRequired,
+ onStartFromScratch: PropTypes.func.isRequired,
+};
+
+export default ExistingForkDialog;
diff --git a/src/components/SingleTermView/History/HistoryPanel.jsx b/src/components/SingleTermView/History/HistoryPanel.jsx
index d0120cd..15ad32f 100644
--- a/src/components/SingleTermView/History/HistoryPanel.jsx
+++ b/src/components/SingleTermView/History/HistoryPanel.jsx
@@ -1,7 +1,7 @@
import React from "react";
import PropTypes from 'prop-types';
import HistoryItem from "./HistoryItem";
-import { Box, List, CircularProgress } from "@mui/material";
+import { Box, List } from "@mui/material";
import { vars } from "../../../theme/variables";
const { gray50 } = vars;
@@ -27,11 +27,7 @@ const HistoryPanel = ({ /*searchTerm, group = "base",*/ versionsData, versionsLo
);
}, [versionsData]);
- if (versionsLoading) {
- return
-
-
- }
+ if (versionsLoading) return null;
if (!versions.length) return
No version history found
diff --git a/src/components/SingleTermView/OntologySearch.jsx b/src/components/SingleTermView/OntologySearch.jsx
index 5eb4d89..fe149cb 100644
--- a/src/components/SingleTermView/OntologySearch.jsx
+++ b/src/components/SingleTermView/OntologySearch.jsx
@@ -17,6 +17,7 @@ import FolderSharedOutlinedIcon from '@mui/icons-material/FolderSharedOutlined';
import { vars } from "../../theme/variables";
import { getOrganizationsOntologies, getOntologyTerms } from "../../api/endpoints/apiService";
import { GlobalDataContext } from "../../contexts/DataContext";
+import { useReportLoading } from "../../contexts/pageLoading";
const { brand600, gray50, gray300, gray400, white, gray700, gray200, paperShadow } = vars;
@@ -90,6 +91,7 @@ const OntologySearch = ({ placeholder, fullWidth = false, disabled, extra, userG
const [selectedValue, setSelectedValue] = useState(null);
const [ontologies, setOntologies] = useState([]);
const [loading, setLoading] = useState(false);
+ useReportLoading(loading);
const autocompleteRef = useRef(null);
const popperRef = useRef(null);
diff --git a/src/components/SingleTermView/OverView/Details.jsx b/src/components/SingleTermView/OverView/Details.jsx
index 8762ebd..c5fda07 100644
--- a/src/components/SingleTermView/OverView/Details.jsx
+++ b/src/components/SingleTermView/OverView/Details.jsx
@@ -1,6 +1,6 @@
import {
Box,
- Chip, CircularProgress,
+ Chip,
Grid,
Stack,
Tooltip,
@@ -55,11 +55,7 @@ const Details = ({ loading, data, jsonData, group = "base", termVersion, onMutat
return { synonyms, related };
};
- if (loading) {
- return
-
-
- }
+ if (loading) return null;
if (!data) {
return
No data available
;
diff --git a/src/components/SingleTermView/OverView/Hierarchy.jsx b/src/components/SingleTermView/OverView/Hierarchy.jsx
index 647ec6c..da3a2d7 100644
--- a/src/components/SingleTermView/OverView/Hierarchy.jsx
+++ b/src/components/SingleTermView/OverView/Hierarchy.jsx
@@ -7,7 +7,6 @@ import {
Divider,
Stack,
Typography,
- CircularProgress
} from "@mui/material";
import { vars } from "../../../theme/variables";
import { RestartAlt, TargetCross } from "../../../Icons";
@@ -141,11 +140,7 @@ const Hierarchy = ({
[items, searchTerm]
);
- if (loading) {
- return
-
-
- }
+ if (loading) return null;
return (
diff --git a/src/components/SingleTermView/OverView/OverviewSections.jsx b/src/components/SingleTermView/OverView/OverviewSections.jsx
index c485619..b9b51f8 100644
--- a/src/components/SingleTermView/OverView/OverviewSections.jsx
+++ b/src/components/SingleTermView/OverView/OverviewSections.jsx
@@ -5,6 +5,7 @@ import Details from "./Details";
import Hierarchy from "./Hierarchy";
import Predicates from "./Predicates";
import { useObservable } from "./overviewStore";
+import { useReportLoading } from "../../../contexts/pageLoading";
import { focusNodeFromJsonLd } from "../../../parsers/predicateMutations";
const SUBCLASS_OF_IRI = "http://www.w3.org/2000/01/rdf-schema#subClassOf";
@@ -16,6 +17,7 @@ const SUBCLASS_OF_IRI = "http://www.w3.org/2000/01/rdf-schema#subClassOf";
export const DetailsSection = memo(function DetailsSection({ subject, group, termVersion, onMutate, reserveHeight }) {
const { loading, data, jsonData } = useObservable(subject);
+ useReportLoading(loading);
return (
@@ -41,6 +43,7 @@ export const HierarchySection = memo(function HierarchySection({
reserveHeight,
}) {
const { loading, options, treeChildren, treeSuperclasses } = useObservable(subject);
+ useReportLoading(loading);
const selectedValue = useObservable(selectedSubject);
// Direct superclasses are the only editable part of the hierarchy: they are
// rdfs:subClassOf triples ON the focus node. Children are the same predicate
@@ -89,6 +92,7 @@ export const PredicatesSection = memo(function PredicatesSection({
reserveHeight,
}) {
const { loading, data, focusId } = useObservable(subject);
+ useReportLoading(loading);
return (
-
-
- }
+ if (loading) return null;
return (
diff --git a/src/components/SingleTermView/OverView/RawDataViewer.jsx b/src/components/SingleTermView/OverView/RawDataViewer.jsx
index e372366..9b63d58 100644
--- a/src/components/SingleTermView/OverView/RawDataViewer.jsx
+++ b/src/components/SingleTermView/OverView/RawDataViewer.jsx
@@ -3,6 +3,7 @@ import { useState, useEffect } from 'react';
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
import { a11yLight } from 'react-syntax-highlighter/dist/esm/styles/hljs';
import { getRawData, getTermVersion } from '../../../api/endpoints/apiService';
+import { useReportLoading } from '../../../contexts/pageLoading';
import { vars } from '../../../theme/variables';
const { gray25, gray200, gray500 } = vars;
@@ -26,8 +27,8 @@ const formatExtensions = {
const RawDataViewer = ({ dataId, dataFormat, group = "base", versionHash }) => {
const [formattedData, setFormattedData] = useState(null);
- // eslint-disable-next-line no-unused-vars
const [loading, setLoading] = useState(true);
+ useReportLoading(loading);
useEffect(() => {
setFormattedData(null);
@@ -37,10 +38,16 @@ const RawDataViewer = ({ dataId, dataFormat, group = "base", versionHash }) => {
const request = versionHash
? getTermVersion(group, dataId, versionHash)
: getRawData(group, dataId, formatExtensions[dataFormat]);
- request.then(rawResponse => {
- setFormattedData(JSON.stringify(rawResponse, null, 2));
- setLoading(false);
- });
+ let active = true;
+ request
+ .then(rawResponse => {
+ if (active) setFormattedData(JSON.stringify(rawResponse, null, 2));
+ })
+ .catch(error => console.error('Error loading raw data:', error))
+ .finally(() => {
+ if (active) setLoading(false);
+ });
+ return () => { active = false; };
}, [dataId, dataFormat, group, versionHash]);
return (
@@ -66,8 +73,8 @@ const RawDataViewer = ({ dataId, dataFormat, group = "base", versionHash }) => {
>
{formattedData}
- ) : (
-
Loading...
+ ) : !loading && (
+
No data available
)}
);
diff --git a/src/components/SingleTermView/Variants/VariantsPanel.jsx b/src/components/SingleTermView/Variants/VariantsPanel.jsx
index a51ea11..35eaef5 100644
--- a/src/components/SingleTermView/Variants/VariantsPanel.jsx
+++ b/src/components/SingleTermView/Variants/VariantsPanel.jsx
@@ -1,6 +1,6 @@
import * as React from 'react';
import PropTypes from 'prop-types';
-import { Box, CircularProgress } from '@mui/material';
+import { Box } from '@mui/material';
import VariantsTable from './VariantsTable';
import ErrorModal from '../../common/ErrorModal';
@@ -49,11 +49,7 @@ const mapVersionsToRows = (data) => {
const VariantsPanel = ({ searchTerm, group = "base", versionsData, versionsLoading, versionsError, onDismissError }) => {
const variants = React.useMemo(() => mapVersionsToRows(versionsData), [versionsData]);
- if (versionsLoading) {
- return
-
-
- }
+ if (versionsLoading) return null;
if (versionsError) {
return {
const [selectedDataFormat, setSelectedDataFormat] = useState('JSON-LD');
const [openRequestMergeDialog, setOpenRequestMergeDialog] = useState(false);
const [openForkDialog, setOpenForkDialog] = useState(false);
+ const [openExistingForkDialog, setOpenExistingForkDialog] = useState(false);
+ const [scratchNotAvailableOpen, setScratchNotAvailableOpen] = useState(false);
const [featureNotAvailableDialog, setFeatureNotAvailableDialog] = useState(false);
// Use the optimized term data hook instead of manual fetching
@@ -157,6 +159,11 @@ const SingleTermView = () => {
const [versionsError, setVersionsError] = useState(null);
const clearVersionsError = useCallback(() => setVersionsError(null), []);
+ // Bumped when this page writes a new variant. Forking a term already viewed under the user's own
+ // group lands on the same URL, so nothing else would tell the term API reads to run again.
+ const [termRevision, setTermRevision] = useState(0);
+ const handleForkCreated = useCallback(() => setTermRevision((revision) => revision + 1), []);
+
// Remove redundant query logic - use term from URL params directly
const searchTerm = term;
const openDataFormatMenu = Boolean(dataFormatAnchorEl);
@@ -361,8 +368,34 @@ const SingleTermView = () => {
setOpenForkDialog(false);
}, []);
+ // Every group's variants of the term are listed in its versions, so a group that already
+ // published one owns a fork: forking again would fail on the backend with a misleading error.
+ const hasExistingFork = useMemo(
+ () => hasForkIn(versionsData, user?.groupname),
+ [versionsData, user?.groupname]
+ );
+
const handleOpenForkDialog = useCallback(() => {
- setOpenForkDialog(true);
+ if (hasExistingFork) {
+ setOpenExistingForkDialog(true);
+ } else {
+ setOpenForkDialog(true);
+ }
+ }, [hasExistingFork]);
+
+ const handleExistingForkDialogClose = useCallback(() => {
+ setOpenExistingForkDialog(false);
+ }, []);
+
+ const handleUseExistingFork = useCallback(() => {
+ setOpenExistingForkDialog(false);
+ navigate(`/${user?.groupname}/${searchTerm}/overview`);
+ }, [navigate, user?.groupname, searchTerm]);
+
+ // Scratch versions are not supported by the backend yet; this is only their entry point.
+ const handleStartForkFromScratch = useCallback(() => {
+ setOpenExistingForkDialog(false);
+ setScratchNotAvailableOpen(true);
}, []);
const handleClickDataFormatMenu = useCallback((event) => {
@@ -443,7 +476,10 @@ const SingleTermView = () => {
}, [termData, updateStoredSearchTerm]);
useEffect(() => {
- if (!actualGroup || !searchTerm) return;
+ if (!actualGroup || !searchTerm) {
+ setVersionsLoading(false);
+ return;
+ }
let active = true;
setVersionsLoading(true);
setVersionsError(null);
@@ -451,7 +487,7 @@ const SingleTermView = () => {
.then(data => { if (active) { setVersionsData(data); setVersionsLoading(false); } })
.catch(err => { if (active) { setVersionsError(err); setVersionsLoading(false); } });
return () => { active = false; };
- }, [actualGroup, searchTerm]);
+ }, [actualGroup, searchTerm, termRevision]);
const termVersion = useMemo(
() => resolveTermVersion(versionsData, actualGroup, versionHash),
@@ -497,20 +533,34 @@ const SingleTermView = () => {
// A term living outside the curated groups is only a fork if a curated original with the same
// id actually exists — a term made from scratch via "Add a new term" never has one.
- const [hasCuratedOriginal, setHasCuratedOriginal] = useState(false);
+ // `undefined` while the check is in flight.
+ const [hasCuratedOriginal, setHasCuratedOriginal] = useState(undefined);
useEffect(() => {
if (!isInPersonalNamespace || !searchTerm) {
setHasCuratedOriginal(false);
return;
}
let active = true;
+ setHasCuratedOriginal(undefined);
termExistsInGroup(curatedGroup, searchTerm).then(exists => {
if (active) setHasCuratedOriginal(exists);
});
return () => { active = false; };
}, [isInPersonalNamespace, curatedGroup, searchTerm]);
- const isItFork = isInPersonalNamespace && hasCuratedOriginal;
+ // Every group reads through to the curated record, so the term answering under this group proves
+ // nothing: only a variant the group published itself makes it a fork.
+ const hasOwnVariant = useMemo(() => hasForkIn(versionsData, actualGroup), [versionsData, actualGroup]);
+ const isItFork = isInPersonalNamespace && hasOwnVariant && hasCuratedOriginal === true;
+
+ // Viewed under the user's own group without a fork there, the page is only reading through to
+ // the curated record, which is therefore what a new fork copies.
+ const forkSourceGroup = actualGroup === user?.groupname ? curatedGroup : actualGroup;
+ const isCuratedOriginalPending = isInPersonalNamespace && hasCuratedOriginal === undefined;
+
+ // What the header itself waits on; the tabs report their own loading through the provider.
+ const isHeaderLoading =
+ isLoadingTerm || versionsLoading || isCuratedOriginalPending || isTermRecordPending || contextLoading;
// Memoize tab content to prevent unnecessary re-renders
const tabContent = useMemo(() => {
@@ -518,13 +568,7 @@ const SingleTermView = () => {
// cell is still being probed. Mounting one now would fire a request that 404s — raising the
// shared error dialog over a tab we are a moment away from redirecting off. The Cell Card
// reads the ontology graph instead, so it starts its (much heavier) load straight away.
- if (isTermRecordPending && tabValue !== CELL_CARD_TAB) {
- return (
-
-
-
- );
- }
+ if (isTermRecordPending && tabValue !== CELL_CARD_TAB) return null;
switch (tabValue) {
case CELL_CARD_TAB:
@@ -603,10 +647,6 @@ const SingleTermView = () => {
}
}, [activeOntology, actualGroup, searchTerm, setOntologyData]);
- const handleCreateFork = () => {
- handleOpenFeatureNotAvailableDialog();
- };
-
const handleAddToAnotherOntology = () => {
handleOpenFeatureNotAvailableDialog();
};
@@ -626,7 +666,7 @@ const SingleTermView = () => {
...(user ? [{
icon: ,
label: "Create fork",
- action: handleCreateFork
+ action: handleOpenForkDialog
}] : []),
{
icon: ,
@@ -644,187 +684,193 @@ const SingleTermView = () => {
return (
-
-
-
-
-
-
-
-
-
- {/* The context ontology's two ways out, in the design's order: back to its grid,
- then off to its community. Grouped on `contextEntry` because §4.2 omits both
- entirely without a context — and the divider with them, since it is what
- separates them from the active-ontology selector. */}
- {contextEntry && (
- <>
-
-
-
- >
- )}
- Active Ontology:
-
-
-
-
-
-
-
- {/* 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
- )}
-
- {isItFork ? : null}
+
+
+
+
+
+
+
+
- {isUsingFallback && (
-
- Note: This term is not available in "{group}" group. Showing data from "base" group instead.
-
- )}
-
- {termIdentityUrl ? (
-
- ) : (
- // Waiting on the ontology for a cell's IRI (see termIdentityUrl). The
- // placeholder holds the row's height so the tab bar below does not jump when
- // the link arrives.
-
+
+ {/* The context ontology's two ways out, in the design's order: back to its grid,
+ then off to its community. Grouped on `contextEntry` because §4.2 omits both
+ entirely without a context — and the divider with them, since it is what
+ separates them from the active-ontology selector. */}
+ {contextEntry && (
+ <>
+
+
+
+ >
)}
- {/* A fork is a copy of a curated record: name the original and offer the way back
- to it, so the reader can tell which of the two they are looking at. */}
- {isItFork && (
- <>
-
- navigate(curatedTermPath)}
- >
- View curated term
-
- >
- )}
-
-
-
-
- {/* Editing applies to the Overview tab, which is where every
- field backed by a triple on this term lives. */}
-
-
- {/* Only a variant has something to propose: the base group *is* curated.
- Opening the request writes to the fork's group, so it needs a session. */}
- {isItFork && user ? (
- } onClick={handleOpenRequestMergeDialog}>
- Request to merge changes to curated
-
- ) : !isInPersonalNamespace && user ? (
- } onClick={handleOpenForkDialog}>
- Create fork
-
- ) : null}
-
- :last-child": {
- borderTop: `1px solid ${gray200}`,
- color: error700
- }
- }}
- />
-
- Download as
-
+ Active Ontology:
+
-
-
- {toggleButtonGroup}
+
+
+
+
+ {displayedTermLabel}
+
+ {isItFork ? : null}
+
+ {isUsingFallback && (
+
+ Note: This term is not available in "{group}" group. Showing data from "base" group instead.
+
+ )}
+
+ {termIdentityUrl && }
+ {/* A fork is a copy of a curated record: name the original and offer the way back
+ to it, so the reader can tell which of the two they are looking at. */}
+ {isItFork && (
+ <>
+
+ navigate(curatedTermPath)}
+ >
+ View curated term
+
+ >
+ )}
+
+
+
+
+ {/* Editing applies to the Overview tab, which is where every
+ field backed by a triple on this term lives. */}
+
+
+ {/* Only a variant has something to propose: the base group *is* curated.
+ Opening the request writes to the fork's group, so it needs a session. */}
+ {isItFork && user && (
+ } onClick={handleOpenRequestMergeDialog}>
+ Request to merge changes to curated
+
+ )}
+ {/* Offered on every term, forks included: it is also the way into a scratch
+ version once the user already holds a fork. */}
+ {user && (
+ } onClick={handleOpenForkDialog} disabled={versionsLoading}>
+ Create fork
+
+ )}
+
+ :last-child": {
+ borderTop: `1px solid ${gray200}`,
+ color: error700
+ }
+ }}
+ />
+
+ Download as
+
+
+
+
+
+ {toggleButtonGroup}
+
-
-
- {versionHash && (
-
- navigate(`/${group}/${term}/overview`)}
- closeText="Back to current term"
- >
- Viewing a historical version of this term (identity graph {versionHash}). This snapshot is read-only.
-
-
- )}
- {/* Why nothing on the Overview is editable: the record shown is the ontology's, not InterLex's. */}
- {!versionHash && servedByOntology && tabValue === OVERVIEW_TAB && (
-
-
- This term has no InterLex record yet, so its Overview is read from{" "}
- {contextOntologyData?.meta?.title || "the context ontology"} and is read-only. Curate
- it by editing that ontology.
-
+ {versionHash && (
+
+ navigate(`/${group}/${term}/overview`)}
+ closeText="Back to current term"
+ >
+ Viewing a historical version of this term (identity graph {versionHash}). This snapshot is read-only.
+
+
+ )}
+ {/* Why nothing on the Overview is editable: the record shown is the ontology's, not InterLex's. */}
+ {!versionHash && servedByOntology && tabValue === OVERVIEW_TAB && (
+
+
+ This term has no InterLex record yet, so its Overview is read from{" "}
+ {contextOntologyData?.meta?.title || "the context ontology"} and is read-only. Curate
+ it by editing that ontology.
+
+
+ )}
+ {tabContent}
+
+ {isItFork && (
+
)}
- {tabContent}
-
- {isItFork && (
-
- )}
-
-
- {/* Feature Not Available Dialog */}
-
- setOntologySnackbar(null)}
- anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
- >
- setOntologySnackbar(null)} severity={ontologySnackbar?.severity} sx={{ width: '100%' }}>
- {ontologySnackbar?.message}
-
-
+
+ setScratchNotAvailableOpen(false)}
+ title="Scratch version not available yet"
+ message="Starting a scratch version of a term you have already forked is not implemented yet. You can keep working on your existing fork in the meantime."
+ />
+
+ {/* Feature Not Available Dialog */}
+
+ setOntologySnackbar(null)}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
+ >
+ setOntologySnackbar(null)} severity={ontologySnackbar?.severity} sx={{ width: '100%' }}>
+ {ontologySnackbar?.message}
+
+
+
)
}
diff --git a/src/components/common/LoadingOverlay.jsx b/src/components/common/LoadingOverlay.jsx
new file mode 100644
index 0000000..e9f9254
--- /dev/null
+++ b/src/components/common/LoadingOverlay.jsx
@@ -0,0 +1,24 @@
+import PropTypes from "prop-types";
+import { Backdrop, CircularProgress } from "@mui/material";
+import { alpha, styled } from "@mui/material/styles";
+
+// Above the page's fixed section nav (10) and below the sticky app header (99), so navigation
+// stays reachable while a page loads.
+const PAGE_OVERLAY_Z_INDEX = 50;
+
+const Overlay = styled(Backdrop)(({ theme }) => ({
+ zIndex: PAGE_OVERLAY_Z_INDEX,
+ backgroundColor: alpha(theme.palette.background.default, 0.8),
+}));
+
+const LoadingOverlay = ({ open }) => (
+
+
+
+);
+
+LoadingOverlay.propTypes = {
+ open: PropTypes.bool.isRequired,
+};
+
+export default LoadingOverlay;
diff --git a/src/contexts/PageLoadingContext.jsx b/src/contexts/PageLoadingContext.jsx
new file mode 100644
index 0000000..9d6863c
--- /dev/null
+++ b/src/contexts/PageLoadingContext.jsx
@@ -0,0 +1,43 @@
+import PropTypes from "prop-types";
+import { useCallback, useEffect, useRef, useState } from "react";
+import LoadingOverlay from "../components/common/LoadingOverlay";
+import { PageLoadingContext } from "./pageLoading";
+
+// Requests often chain — one settles and its effect fires the next a frame later. Without a grace
+// period the overlay would drop and come back in between, flashing half-loaded content.
+const SETTLE_MS = 200;
+
+export const PageLoadingProvider = ({ loading = false, children }) => {
+ const pendingRef = useRef(new Set());
+ const [pendingCount, setPendingCount] = useState(0);
+
+ const report = useCallback((id, isLoading) => {
+ const pending = pendingRef.current;
+ if (isLoading) pending.add(id);
+ else pending.delete(id);
+ setPendingCount(pending.size);
+ }, []);
+
+ const busy = loading || pendingCount > 0;
+ const [lingering, setLingering] = useState(busy);
+ useEffect(() => {
+ if (busy) {
+ setLingering(true);
+ return undefined;
+ }
+ const timer = setTimeout(() => setLingering(false), SETTLE_MS);
+ return () => clearTimeout(timer);
+ }, [busy]);
+
+ return (
+
+ {children}
+
+
+ );
+};
+
+PageLoadingProvider.propTypes = {
+ loading: PropTypes.bool,
+ children: PropTypes.node,
+};
diff --git a/src/contexts/pageLoading.js b/src/contexts/pageLoading.js
new file mode 100644
index 0000000..640c002
--- /dev/null
+++ b/src/contexts/pageLoading.js
@@ -0,0 +1,19 @@
+// Kept apart from the provider component so that file only exports components (fast refresh).
+import { createContext, useContext, useEffect, useId } from "react";
+
+export const PageLoadingContext = createContext(null);
+
+/**
+ * Holds the page's loading overlay up while `loading` is true. Sections report here instead of
+ * drawing their own spinner, so a page settles once rather than piece by piece. A no-op outside a
+ * PageLoadingProvider.
+ */
+export const useReportLoading = (loading) => {
+ const report = useContext(PageLoadingContext);
+ const id = useId();
+ useEffect(() => {
+ if (!report || !loading) return undefined;
+ report(id, true);
+ return () => report(id, false);
+ }, [report, id, loading]);
+};
diff --git a/src/parsers/termVersion.ts b/src/parsers/termVersion.ts
index c5e4086..98b387e 100644
--- a/src/parsers/termVersion.ts
+++ b/src/parsers/termVersion.ts
@@ -84,3 +84,10 @@ export const resolveTermVersion = (
if (!target) return undefined;
return { number: history.indexOf(target) + 1, identityGraph: target.identityGraph };
};
+
+/** Has `group` published any version of the term, i.e. does it already hold a fork of it? */
+export const hasForkIn = (versionsData: VersionsData | null | undefined, group?: string): boolean =>
+ Boolean(group) &&
+ (versionsData?.versions ?? []).some((version) =>
+ (version.appears_in ?? []).some((appearance) => forkOf(appearance.uri) === group)
+ );