diff --git a/apps/hash-api/src/graphql/resolvers/index.ts b/apps/hash-api/src/graphql/resolvers/index.ts index ed49351228b..7fa9911468f 100644 --- a/apps/hash-api/src/graphql/resolvers/index.ts +++ b/apps/hash-api/src/graphql/resolvers/index.ts @@ -143,6 +143,9 @@ export const resolvers: Omit & { /** Logged in users (who may not have completed signup) */ me: loggedInMiddleware(meResolver), getWaitlistPosition: loggedInMiddleware(getWaitlistPositionResolver), + getMyPendingInvitations: loggedInMiddleware( + getMyPendingInvitationsResolver, + ), /** Logged in and signed up users */ getBlockProtocolBlocks: loggedInAndSignedUpMiddleware( @@ -166,10 +169,6 @@ export const resolvers: Omit & { queryEntitySubgraph: loggedInAndSignedUpMiddleware( queryEntitySubgraphResolver, ), - getMyPendingInvitations: loggedInAndSignedUpMiddleware( - getMyPendingInvitationsResolver, - ), - getLinearOrganization: loggedInAndSignedUpMiddleware( getLinearOrganizationResolver, ), diff --git a/apps/hash-frontend/src/pages/_app.page.tsx b/apps/hash-frontend/src/pages/_app.page.tsx index f3e982280fa..a31d77984c1 100644 --- a/apps/hash-frontend/src/pages/_app.page.tsx +++ b/apps/hash-frontend/src/pages/_app.page.tsx @@ -333,12 +333,7 @@ const featureFlagHiddenPathnames: Record = { notes: ["/notes"], workers: ["/goals", "/flows", "/workers", "/agents"], ai: ["/goals"], - supplyChain: [ - "/supply-chain", - "/supply-chain/product/[product-id]", - "/supply-chain/site/[site-id]", - "/supply-chain/site/[site-id]/opportunity/[opportunity-type]/[product-id]/[step-id]", - ], + supplyChain: [], }; AppWithTypeSystemContextProvider.getInitialProps = async (appContext) => { diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx index e182290062f..d90152846c9 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx @@ -649,6 +649,7 @@ export const EntitiesVisualizer: FunctionComponent<{ currentlyDisplayedColumnsRef={currentlyDisplayedColumnsRef} currentlyDisplayedRowsRef={currentlyDisplayedRowsRef} handleEntityClick={handleEntityClick} + hasMoreRowsAvailable={nextCursor != null} loading={dataLoading} isViewingOnlyPages={isViewingOnlyPages} maxHeight={`calc(${tableHeight} - ${toolbarHeight}px)`} diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table.tsx index fba55d5ab3c..2c0d8c27d1e 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table.tsx @@ -106,6 +106,7 @@ export const EntitiesTable: FunctionComponent< loading: boolean; isViewingOnlyPages: boolean; maxHeight: string | number; + hasMoreRowsAvailable: boolean; loadMoreRows?: () => void; selectedRows: EntitiesTableRow[]; setActiveConversions: Dispatch< @@ -136,6 +137,7 @@ export const EntitiesTable: FunctionComponent< loading: entityDataLoading, isViewingOnlyPages, maxHeight, + hasMoreRowsAvailable, loadMoreRows, selectedRows, setActiveConversions, @@ -810,8 +812,6 @@ export const EntitiesTable: FunctionComponent< }); }, [rows.length]); - const hasMoreRowsAvailable = - !!totalResultCount && totalResultCount > rows.length; const loadMoreRowHeight = 60; return ( @@ -906,7 +906,9 @@ export const EntitiesTable: FunctionComponent< component="span" sx={{ color: ({ palette }) => palette.gray[50], ml: 0.5 }} > - - {formatNumber(totalResultCount - rows.length)} remaining + {totalResultCount != null + ? `- ${formatNumber(totalResultCount - rows.length)} remaining` + : ""} { const { authenticatedUser, refetch: refetchAuthenticatedUser } = useAuthInfo(); + const { refetch: refetchInvites } = useInvites(); + const { updateActiveWorkspaceWebId } = useActiveWorkspace(); const userHasVerifiedEmail = authenticatedUser?.emails.find(({ verified }) => verified) !== undefined; @@ -139,12 +146,24 @@ const SignupPage: NextPageWithLayout = () => { skip: !invitationId, }); + const { data: pendingInvitationsData, loading: pendingInvitationsLoading } = + useQuery< + GetMyPendingInvitationsQuery, + GetMyPendingInvitationsQueryVariables + >(getMyPendingInvitationsQuery, { + skip: !!invitationId || !authenticatedUser || !userHasVerifiedEmail, + }); + const [acceptInvitation] = useMutation< AcceptOrgInvitationMutation, AcceptOrgInvitationMutationVariables >(acceptOrgInvitationMutation); - const invitation = invitationData?.getPendingInvitationByEntityId; + const invitation = + invitationData?.getPendingInvitationByEntityId ?? + pendingInvitationsData?.getMyPendingInvitations[0]; + + const loadingInvitation = invitationLoading || pendingInvitationsLoading; const [acceptingInvitation, setAcceptingInvitation] = useState(false); const acceptingInvitationEntityIdRef = useRef( @@ -220,7 +239,9 @@ const SignupPage: NextPageWithLayout = () => { }) .then(async (result) => { if (result?.accepted || result?.alreadyAMember) { + refetchInvites(); await refetchAuthenticatedUser(); + updateActiveWorkspaceWebId(invitation.org.webId); void router.replace("/"); return; } @@ -245,7 +266,9 @@ const SignupPage: NextPageWithLayout = () => { clearInvitationAcceptanceReservation, invitation, refetchAuthenticatedUser, + refetchInvites, router, + updateActiveWorkspaceWebId, ]); useEffect(() => { @@ -253,7 +276,7 @@ const SignupPage: NextPageWithLayout = () => { router.isReady && authenticatedUser?.accountSignupComplete && invitationId && - !invitationLoading && + !loadingInvitation && !invitation && !acceptingInvitation ) { @@ -264,7 +287,7 @@ const SignupPage: NextPageWithLayout = () => { authenticatedUser?.accountSignupComplete, invitation, invitationId, - invitationLoading, + loadingInvitation, router, ]); @@ -340,6 +363,10 @@ const SignupPage: NextPageWithLayout = () => { } await refetchAuthenticatedUser(); + refetchInvites(); + if (invitation) { + updateActiveWorkspaceWebId(invitation.org.webId); + } void router.push("/"); }, @@ -347,8 +374,10 @@ const SignupPage: NextPageWithLayout = () => { acceptInvitationOnce, clearInvitationAcceptanceReservation, invitation, + refetchInvites, refetchAuthenticatedUser, updateAuthenticatedUser, + updateActiveWorkspaceWebId, router, ], ); @@ -395,7 +424,9 @@ const SignupPage: NextPageWithLayout = () => { > - {invitationLoading ? null : invitation && showInvitationStep ? ( + {loadingInvitation ? null : invitation && + showInvitationStep && + !authenticatedUser ? ( setShowInvitationStep(false)} diff --git a/apps/hash-frontend/src/pages/supply-chain/product/[product-id].page.tsx b/apps/hash-frontend/src/pages/supply-chain/product/[product-id].page.tsx index dcfccd2ae9e..ce0d2f6f1d5 100644 --- a/apps/hash-frontend/src/pages/supply-chain/product/[product-id].page.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/product/[product-id].page.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { css } from "@hashintel/ds-helpers/css"; import { fetchGraph } from "../shared/data"; -import { ErrorState, LoadingState } from "../shared/load-state"; +import { ErrorState, SupplyChainAppSkeleton } from "../shared/load-state"; import { getSupplyChainLayout } from "../shared/supply-chain-layout"; import { trackSupplyChainError, @@ -16,7 +16,6 @@ import { Overview } from "../supply-chain-data-shell/product"; import type { NextPageWithLayout } from "../../../shared/layout"; import type { GraphData } from "../shared/types"; -const loadingH = css({ h: "64" }); const errorPad = css({ px: "6", py: "4" }); const ProductPage: NextPageWithLayout = () => { @@ -79,9 +78,7 @@ const ProductPage: NextPageWithLayout = () => { }, [productId]); if (loading) { - return ( - - ); + return ; } if (error) { return ; diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/load-state.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/load-state.tsx index f9222cbda58..5ac1bd9ff39 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/load-state.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/load-state.tsx @@ -14,6 +14,43 @@ const loadingRow = css({ }); const loadingText = css({ textStyle: "sm", color: "fg.subtle" }); const errorText = css({ textStyle: "sm", color: "status.error.fg.body" }); +const appSkeletonRoot = css({ + display: "flex", + flexDirection: "column", + flex: "1", + minH: "0", + h: "full", + w: "full", + bg: "bgSolid.min", +}); +const appSkeletonTopBar = css({ + display: "flex", + alignItems: "center", + px: "6", + py: "3", + borderBottomWidth: "1px", + borderColor: "bd.subtle", + flexShrink: 0, +}); +const appSkeletonSitePicker = css({ + h: "10", + w: "64", + maxW: "[min(260px,45vw)]", + rounded: "md", + bg: "bg.subtle", +}); +const appSkeletonContent = css({ + flex: "1", + minH: "0", + p: "6", + display: "flex", +}); +const appSkeletonContentBlock = css({ + flex: "1", + minH: "[280px]", + rounded: "lg", + bg: "bg.subtle", +}); /** Centered, muted "loading…" message with a ds spinner. Size the area via `className` (e.g. `h-32`). */ export const LoadingState = ({ @@ -33,6 +70,26 @@ export const LoadingState = ({ ); }; +/** Supply-chain route loading skeleton matching the page chrome. */ +export const SupplyChainAppSkeleton = ({ + className, +}: { + className?: string; +}) => ( +
+
+
+
+
+
+
+
+); + /** Inline error message. Pad/position via `className` (e.g. `px-6 py-4`). */ export const ErrorState = ({ message, diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/supply-chain-layout.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/supply-chain-layout.tsx index ec6bd93fbf6..2406203c260 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/supply-chain-layout.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/supply-chain-layout.tsx @@ -1,25 +1,15 @@ import { useRef } from "react"; import { PortalContainerContext } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; import { getLayoutWithSidebar } from "../../../shared/layout"; import { HEADER_HEIGHT } from "../../../shared/layout/layout-with-header/page-header"; import { useActiveWorkspace } from "../../shared/workspace-context"; import { SupplyChainDataShell } from "../supply-chain-data-shell"; -import { LoadingState } from "./load-state"; +import { SupplyChainAppSkeleton } from "./load-state"; import type { ReactElement, ReactNode } from "react"; -const emptyState = css({ - h: "full", - display: "flex", - alignItems: "center", - justifyContent: "center", - color: "fg.subtle", - textStyle: "sm", -}); - /** * Layout shared by every `/supply-chain/*` page. * @@ -46,7 +36,7 @@ const SupplyChainShell = ({ children }: { children: ReactNode }) => { }} > {activeWorkspaceWebId === undefined ? ( - + ) : ( { }, [opportunityType, productId, siteId, stepId]); if (!router.isReady) { - return ; + return ; } if (!siteId || !productId || !stepId) { diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell.tsx index 361be4412ba..ebd792dd10c 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell.tsx @@ -8,6 +8,8 @@ import { import { css } from "@hashintel/ds-helpers/css"; +import { useAuthInfo } from "../shared/auth-info-context"; +import { useActiveWorkspace } from "../shared/workspace-context"; import { CostParamsContext, DEFAULT_CURRENCY, @@ -22,7 +24,7 @@ import { type SiteRef, } from "./shared/data"; import { DocsProvider } from "./shared/docs/docs-context"; -import { LoadingState } from "./shared/load-state"; +import { SupplyChainAppSkeleton } from "./shared/load-state"; import { LowSampleContext } from "./shared/low-sample-context"; import { BASE_MEASURES, @@ -52,23 +54,15 @@ const screenBg = css({ minH: "0", bg: "bgSolid.min", }); -const centerScreen = css({ - minH: "screen", - display: "flex", - alignItems: "center", - justifyContent: "center", - bg: "bgSolid.min", -}); + const errorStack = css({ - textAlign: "center", + textAlign: "left", display: "flex", flexDirection: "column", gap: "3", - maxW: "md", }); -const errorTitle = css({ color: "status.error.fg.body", fontWeight: "medium" }); -const subtleSm = css({ textStyle: "sm", color: "fg.subtle" }); -const subtleXs = css({ textStyle: "xs", color: "fg.subtle" }); +const errorTitle = css({ fontWeight: "medium", textStyle: "lg" }); +const subtleSm = css({ color: "fg.subtle" }); const emptyText = css({ color: "fg.subtle" }); const mainArea = css({ flex: "1", @@ -80,6 +74,18 @@ const mainArea = css({ flexDirection: "column", }); +const getOrderedWebIds = (webIds: (WebId | null | undefined)[]): WebId[] => { + const orderedWebIds: WebId[] = []; + + for (const webId of webIds) { + if (webId && !orderedWebIds.includes(webId)) { + orderedWebIds.push(webId); + } + } + + return orderedWebIds; +}; + function normaliseTimeRange( value: string | null, fallback: TimeRange = "12m", @@ -151,6 +157,7 @@ export const SupplyChainDataShell = ({ const [mounted, setMounted] = useState(false); const [products, setProducts] = useState([]); const [sites, setSites] = useState([]); + const [dataScope, setDataScope] = useState(scope); const [defaultCurrency, setDefaultCurrency] = useState(DEFAULT_CURRENCY); const [defaultStorageCost, setDefaultStorageCost] = useState(DEFAULT_STORAGE_COST); @@ -162,6 +169,19 @@ export const SupplyChainDataShell = ({ saveSettings: saveUserPreferenceSettings, } = useSupplyChainUserPreferences(); + const { authenticatedUser } = useAuthInfo(); + const { activeWorkspace } = useActiveWorkspace(); + + const candidateWebIds = useMemo( + () => + getOrderedWebIds([ + scope, + ...(authenticatedUser?.memberOf.map(({ org }) => org.webId) ?? []), + authenticatedUser?.accountId as WebId | undefined, + ]), + [authenticatedUser, scope], + ); + const [searchParams, setSearchParams] = useSearchParams(); const waccRate = normaliseWacc(searchParams.get("wacc")); const storageCost = normaliseStorageCost( @@ -303,25 +323,73 @@ export const SupplyChainDataShell = ({ }, []); useEffect(() => { - configureDataSource({ scope }); - }, [scope]); + let cancelled = false; + const isCancelled = () => cancelled; + + const loadRegistry = async () => { + let lastError: unknown = null; + + for (const candidateWebId of candidateWebIds) { + if (isCancelled()) { + return; + } + + configureDataSource({ scope: candidateWebId }); + + try { + const [candidateProducts, candidateSiteRefs] = await Promise.all([ + fetchProducts(), + fetchSites(), + ]); + + if (candidateProducts.length === 0) { + lastError = new Error("No supply chain products found."); + continue; + } + + if (isCancelled()) { + return; + } + + setDataScope(candidateWebId); + setProducts(candidateProducts); + setSites(candidateSiteRefs); + setLoading(false); + return; + } catch (caught) { + lastError = caught; + } + } + + if (isCancelled()) { + return; + } + + configureDataSource({ scope }); + setDataScope(scope); + setProducts([]); + setSites([]); + setError( + lastError instanceof Error + ? lastError.message + : typeof lastError === "string" + ? lastError + : "No supply chain data found.", + ); + setLoading(false); + }; - useEffect(() => { setLoading(true); setError(null); - Promise.all([fetchProducts(), fetchSites()]) - .then(([prods, siteRefs]) => { - setProducts(prods); - setSites(siteRefs); - }) - .catch((caught) => { - setError(caught instanceof Error ? caught.message : String(caught)); - }) - .finally(() => setLoading(false)); - }, [scope]); + void loadRegistry(); + + return () => { + cancelled = true; + }; + }, [candidateWebIds, scope]); const demoActive = products.some((product) => product.id === "_demo"); - const scopeContextValue = useMemo(() => ({ scope }), [scope]); + const scopeContextValue = useMemo(() => ({ scope: dataScope }), [dataScope]); const registryContextValue = useMemo( () => ({ products, sites, demoActive }), [demoActive, products, sites], @@ -366,17 +434,23 @@ export const SupplyChainDataShell = ({ ); if (!mounted || loading || userPreferencesLoading) { - return ; + return ; } if (error) { return ( -
+
-

Failed to load data

-

{error}

-

- Check that a supply chain dataset is published for this workspace. +

+ No supply chain data found in{" "} + + {activeWorkspace?.shortname + ? `@${activeWorkspace.shortname}` + : "this web"} + +

+

+ Use the web switcher in the top left to switch to a different web.

@@ -385,9 +459,9 @@ export const SupplyChainDataShell = ({ if (products.length === 0) { return ( -
+

- No supply-chain products are available in this workspace. + No supply chain products are available in this workspace.

); diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity.tsx index c862fdf6816..012f5c65edc 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity.tsx @@ -15,7 +15,7 @@ import { detailDateKeyFromColumns } from "../shared/detail-date-key"; import { useDocs } from "../shared/docs/use-docs"; import { buildCsvContent, downloadCsv } from "../shared/export-utils"; import { useSupplierPerformanceEnabled } from "../shared/feature-flags"; -import { LoadingState, ErrorState } from "../shared/load-state"; +import { ErrorState, SupplyChainAppSkeleton } from "../shared/load-state"; import { ensureNodeStats } from "../shared/normalize-contract"; import { applyOutlierSelectionToStep } from "../shared/outlier-selection"; import { @@ -1788,12 +1788,7 @@ export const OpportunityBrief = ({ } if (siteSummaryLoading || !step) { - return ( - - ); + return ; } if (!filteredStep || !historicalStep || !brief) { diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site.tsx index 3341d710ae8..fc6152d9447 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site.tsx @@ -9,7 +9,7 @@ import { AnalysisSettingsPanel, HeaderActionButtons, } from "../shared/header-actions"; -import { LoadingState, ErrorState } from "../shared/load-state"; +import { ErrorState, SupplyChainAppSkeleton } from "../shared/load-state"; import { useLowSampleSetting } from "../shared/low-sample-context"; import { ScopeSelect } from "../shared/scope-select"; import { StatChip } from "../shared/stat-chip"; @@ -52,7 +52,6 @@ import type { SupplierMode, } from "./site/shared/row-types"; -const loadingH = css({ h: "64" }); const errorPad = css({ px: "6", py: "4" }); // Fill the layout's main area (a flex column) and clamp our own height to it so // the content pane can scroll internally instead of overflowing the viewport. @@ -406,7 +405,7 @@ export const SiteOverview = ({ ? statusTarget : null; if (loading) { - return ; + return ; } if (error) { return ; diff --git a/apps/hash-frontend/src/shared/get-inbox-href.ts b/apps/hash-frontend/src/shared/get-inbox-href.ts new file mode 100644 index 00000000000..f9b4595cbdd --- /dev/null +++ b/apps/hash-frontend/src/shared/get-inbox-href.ts @@ -0,0 +1,29 @@ +type GetInboxHrefParams = { + includeDraftEntityActions?: boolean; + numberOfDraftEntityActions?: number; + numberOfPendingInvites?: number; + numberOfUnreadNotifications?: number; + fallbackHref: string; +}; + +export const getInboxHref = ({ + fallbackHref, + includeDraftEntityActions = true, + numberOfDraftEntityActions = 0, + numberOfPendingInvites = 0, + numberOfUnreadNotifications = 0, +}: GetInboxHrefParams) => { + if (includeDraftEntityActions && numberOfDraftEntityActions > 0) { + return "/actions"; + } + + if (numberOfUnreadNotifications > 0) { + return "/notifications"; + } + + if (numberOfPendingInvites > 0) { + return "/invites"; + } + + return fallbackHref; +}; diff --git a/apps/hash-frontend/src/shared/layout/layout-with-header/notifications-dropdown.tsx b/apps/hash-frontend/src/shared/layout/layout-with-header/notifications-dropdown.tsx index 05a121ed58e..9a192a40bf3 100644 --- a/apps/hash-frontend/src/shared/layout/layout-with-header/notifications-dropdown.tsx +++ b/apps/hash-frontend/src/shared/layout/layout-with-header/notifications-dropdown.tsx @@ -3,6 +3,7 @@ import { Tooltip, useTheme } from "@mui/material"; import { FontAwesomeIcon } from "@hashintel/design-system"; +import { getInboxHref } from "../../get-inbox-href"; import { useInvites } from "../../invites-context"; import { useNotificationCount } from "../../notification-count-context"; import { Link } from "../../ui"; @@ -16,9 +17,16 @@ export const NotificationsDropdown: FunctionComponent = () => { const { numberOfUnreadNotifications } = useNotificationCount(); const { pendingInvites } = useInvites(); + const href = getInboxHref({ + fallbackHref: "/notifications", + includeDraftEntityActions: false, + numberOfPendingInvites: pendingInvites.length, + numberOfUnreadNotifications, + }); + return ( - + { const numberOfPendingActions = draftEntitiesCount ?? 0; const unreadNotifications = numberOfUnreadNotifications ?? 0; - const shouldInboxLinkToActions = - numberOfPendingActions > 0 || - (unreadNotifications === 0 && pendingInvites.length === 0); - return [ { title: "Home", @@ -165,11 +162,12 @@ export const PageSidebar: FunctionComponent = () => { }, { title: "Inbox", - path: shouldInboxLinkToActions - ? "/actions" - : unreadNotifications > 0 - ? "/notifications" - : "/invites", + path: getInboxHref({ + fallbackHref: "/actions", + numberOfDraftEntityActions: numberOfPendingActions, + numberOfPendingInvites: pendingInvites.length, + numberOfUnreadNotifications: unreadNotifications, + }), icon: , tooltipTitle: "", count: