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 }) => ( + + You already have a fork of this term + + + "{termLabel}" is already forked under {groupname}. Continue working on that fork, or start + a scratch version from the current curated term instead. + + + + + + + + +); + +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 ? ( - - ) : !isInPersonalNamespace && user ? ( - - ) : null} - - :last-child": { - borderTop: `1px solid ${gray200}`, - color: error700 - } - }} - /> - - Download as - - {dataFormats.map(dataFormat => ( - handleDataFormatMenuItemClick(dataFormat)}>{dataFormat} - ))} - + 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 && ( + + )} + {/* Offered on every term, forks included: it is also the way into a scratch + version once the user already holds a fork. */} + {user && ( + + )} + + :last-child": { + borderTop: `1px solid ${gray200}`, + color: error700 + } + }} + /> + + Download as + + {dataFormats.map(dataFormat => ( + handleDataFormatMenuItemClick(dataFormat)}>{dataFormat} + ))} + + + + + + {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) + );