Add layered error boundaries to compliance portal
Introduce global, page, and section-level error handling for the compliance portal so a failure is contained at the smallest possible scope instead of blanking the whole page. Add a portal-local Relay fetch that throws only request-level errors (and always redirects on UNAUTHENTICATED) while leaving field-level errors in the response, so Relay surfaces them at the reading component through @throwOnFieldError and the nearest boundary. Add a NotFoundError for node __typename mismatches mapped to a not-found page. Ship reusable v2 kit primitives (ErrorBoundary, ErrorState, InlineError) matching the Figma global/local/inline designs, wire the bootstrap and route boundaries, and demonstrate section and row boundaries on the home page. Update the error-handling and relay guides accordingly. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -18,15 +18,19 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
|
import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary";
|
||||||
import { RouterProvider } from "react-router";
|
import { RouterProvider } from "react-router";
|
||||||
|
|
||||||
|
import { BootstrapError } from "#/components/errors/BootstrapError";
|
||||||
import { RelayProvider } from "#/lib/relay/RelayProvider";
|
import { RelayProvider } from "#/lib/relay/RelayProvider";
|
||||||
import { router } from "#/routes";
|
import { router } from "#/routes";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<RelayProvider>
|
<ErrorBoundary fallback={<BootstrapError />}>
|
||||||
<RouterProvider router={router} />
|
<RelayProvider>
|
||||||
</RelayProvider>
|
<RouterProvider router={router} />
|
||||||
|
</RelayProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,32 @@
|
|||||||
"description": "The page you are looking for does not exist or has moved.",
|
"description": "The page you are looking for does not exist or has moved.",
|
||||||
"backHome": "Back to home"
|
"backHome": "Back to home"
|
||||||
},
|
},
|
||||||
|
"errors": {
|
||||||
|
"notFound": {
|
||||||
|
"title": "Page not found",
|
||||||
|
"description": "The page you're looking for doesn't exist or may have been moved."
|
||||||
|
},
|
||||||
|
"forbidden": {
|
||||||
|
"title": "Access denied",
|
||||||
|
"description": "You don't have permission to view this page."
|
||||||
|
},
|
||||||
|
"serverError": {
|
||||||
|
"title": "Something went wrong",
|
||||||
|
"description": "We hit an unexpected error. Please try again in a moment."
|
||||||
|
},
|
||||||
|
"generic": {
|
||||||
|
"title": "Something went wrong",
|
||||||
|
"description": "We hit an unexpected error. Please try again in a moment."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"backToTrustCenter": "Back to trust center",
|
||||||
|
"tryAgain": "Try again"
|
||||||
|
},
|
||||||
|
"inline": {
|
||||||
|
"message": "Unable to load content",
|
||||||
|
"retry": "Retry"
|
||||||
|
}
|
||||||
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"poweredBy": "Powered by"
|
"poweredBy": "Powered by"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,6 +36,32 @@
|
|||||||
"description": "La page que vous recherchez n'existe pas ou a été déplacée.",
|
"description": "La page que vous recherchez n'existe pas ou a été déplacée.",
|
||||||
"backHome": "Retour à l'accueil"
|
"backHome": "Retour à l'accueil"
|
||||||
},
|
},
|
||||||
|
"errors": {
|
||||||
|
"notFound": {
|
||||||
|
"title": "Page introuvable",
|
||||||
|
"description": "La page que vous recherchez n'existe pas ou a été déplacée."
|
||||||
|
},
|
||||||
|
"forbidden": {
|
||||||
|
"title": "Accès refusé",
|
||||||
|
"description": "Vous n'avez pas l'autorisation de consulter cette page."
|
||||||
|
},
|
||||||
|
"serverError": {
|
||||||
|
"title": "Une erreur est survenue",
|
||||||
|
"description": "Une erreur inattendue s'est produite. Veuillez réessayer dans un instant."
|
||||||
|
},
|
||||||
|
"generic": {
|
||||||
|
"title": "Une erreur est survenue",
|
||||||
|
"description": "Une erreur inattendue s'est produite. Veuillez réessayer dans un instant."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"backToTrustCenter": "Retour au trust center",
|
||||||
|
"tryAgain": "Réessayer"
|
||||||
|
},
|
||||||
|
"inline": {
|
||||||
|
"message": "Impossible de charger le contenu",
|
||||||
|
"retry": "Réessayer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"poweredBy": "Propulsé par"
|
"poweredBy": "Propulsé par"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
|
import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary";
|
||||||
|
import { InlineError } from "@probo/ui/src/v2/InlineError/InlineError";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
@@ -26,8 +28,11 @@ import { HomeSection } from "#/components/HomeSection/HomeSection";
|
|||||||
import type { ComplianceFrameworksSection_trustCenter$key } from "./__generated__/ComplianceFrameworksSection_trustCenter.graphql";
|
import type { ComplianceFrameworksSection_trustCenter$key } from "./__generated__/ComplianceFrameworksSection_trustCenter.graphql";
|
||||||
import { ComplianceFrameworkListItem } from "./ComplianceFrameworkListItem";
|
import { ComplianceFrameworkListItem } from "./ComplianceFrameworkListItem";
|
||||||
|
|
||||||
|
// @throwOnFieldError makes a field error in this fragment throw at the read
|
||||||
|
// below, where the section's ErrorBoundary contains it. See
|
||||||
|
// contrib/claude/error-handling.md.
|
||||||
const complianceFrameworksSectionFragment = graphql`
|
const complianceFrameworksSectionFragment = graphql`
|
||||||
fragment ComplianceFrameworksSection_trustCenter on TrustCenter {
|
fragment ComplianceFrameworksSection_trustCenter on TrustCenter @throwOnFieldError {
|
||||||
complianceFrameworks(first: 8) {
|
complianceFrameworks(first: 8) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -44,9 +49,30 @@ interface ComplianceFrameworksSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// "Compliance" section: the grid of certification frameworks the trust center
|
// "Compliance" section: the grid of certification frameworks the trust center
|
||||||
// covers.
|
// covers. Wraps its data-reading content in a boundary so a load failure
|
||||||
|
// degrades to an inline error instead of taking down the page.
|
||||||
export function ComplianceFrameworksSection({ trustCenterKey }: ComplianceFrameworksSectionProps) {
|
export function ComplianceFrameworksSection({ trustCenterKey }: ComplianceFrameworksSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={(_, reset) => (
|
||||||
|
<HomeSection title={t("home.sections.compliance")}>
|
||||||
|
<InlineError
|
||||||
|
message={t("errors.inline.message")}
|
||||||
|
retryLabel={t("errors.inline.retry")}
|
||||||
|
onRetry={reset}
|
||||||
|
/>
|
||||||
|
</HomeSection>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ComplianceFrameworksSectionContent trustCenterKey={trustCenterKey} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComplianceFrameworksSectionContent({ trustCenterKey }: ComplianceFrameworksSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const data = useFragment(complianceFrameworksSectionFragment, trustCenterKey);
|
const data = useFragment(complianceFrameworksSectionFragment, trustCenterKey);
|
||||||
const frameworks = data.complianceFrameworks.edges.map(edge => edge.node);
|
const frameworks = data.complianceFrameworks.edges.map(edge => edge.node);
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { formatRelativeTime } from "#/lib/datetime/relativeTime";
|
|||||||
import type { MailingListUpdateListItem_update$key } from "./__generated__/MailingListUpdateListItem_update.graphql";
|
import type { MailingListUpdateListItem_update$key } from "./__generated__/MailingListUpdateListItem_update.graphql";
|
||||||
|
|
||||||
const mailingListUpdateListItemFragment = graphql`
|
const mailingListUpdateListItemFragment = graphql`
|
||||||
fragment MailingListUpdateListItem_update on MailingListUpdate {
|
fragment MailingListUpdateListItem_update on MailingListUpdate @throwOnFieldError {
|
||||||
id
|
id
|
||||||
title
|
title
|
||||||
updatedAt
|
updatedAt
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
import { Link } from "@probo/ui/src/v2/Button/Link";
|
||||||
|
import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary";
|
||||||
|
import { InlineError } from "@probo/ui/src/v2/InlineError/InlineError";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
@@ -28,8 +30,10 @@ import { dotPatternStyle } from "#/components/MediaTile/variants";
|
|||||||
|
|
||||||
import type { RecentUpdatesSection_trustCenter$key } from "./__generated__/RecentUpdatesSection_trustCenter.graphql";
|
import type { RecentUpdatesSection_trustCenter$key } from "./__generated__/RecentUpdatesSection_trustCenter.graphql";
|
||||||
|
|
||||||
|
// @throwOnFieldError surfaces a field error at the read below so the section
|
||||||
|
// ErrorBoundary contains it. See contrib/claude/error-handling.md.
|
||||||
const recentUpdatesSectionFragment = graphql`
|
const recentUpdatesSectionFragment = graphql`
|
||||||
fragment RecentUpdatesSection_trustCenter on TrustCenter {
|
fragment RecentUpdatesSection_trustCenter on TrustCenter @throwOnFieldError {
|
||||||
updates(first: 5) {
|
updates(first: 5) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -46,9 +50,29 @@ interface RecentUpdatesSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// "Recent updates" section: the latest mailing-list updates as a list, with a
|
// "Recent updates" section: the latest mailing-list updates as a list, with a
|
||||||
// link to the full updates page.
|
// link to the full updates page. A load failure degrades to an inline error.
|
||||||
export function RecentUpdatesSection({ trustCenterKey }: RecentUpdatesSectionProps) {
|
export function RecentUpdatesSection({ trustCenterKey }: RecentUpdatesSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={(_, reset) => (
|
||||||
|
<HomeSection title={t("home.sections.recentUpdates")}>
|
||||||
|
<InlineError
|
||||||
|
message={t("errors.inline.message")}
|
||||||
|
retryLabel={t("errors.inline.retry")}
|
||||||
|
onRetry={reset}
|
||||||
|
/>
|
||||||
|
</HomeSection>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RecentUpdatesSectionContent trustCenterKey={trustCenterKey} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentUpdatesSectionContent({ trustCenterKey }: RecentUpdatesSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const data = useFragment(recentUpdatesSectionFragment, trustCenterKey);
|
const data = useFragment(recentUpdatesSectionFragment, trustCenterKey);
|
||||||
const updates = data.updates.edges.map(edge => edge.node);
|
const updates = data.updates.edges.map(edge => edge.node);
|
||||||
|
|
||||||
@@ -70,7 +94,22 @@ export function RecentUpdatesSection({ trustCenterKey }: RecentUpdatesSectionPro
|
|||||||
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px]" />
|
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px]" />
|
||||||
<div className="relative divide-y divide-sand-a2">
|
<div className="relative divide-y divide-sand-a2">
|
||||||
{updates.map(update => (
|
{updates.map(update => (
|
||||||
<MailingListUpdateListItem key={update.id} updateKey={update} />
|
// A single failing row degrades to a compact horizontal inline error.
|
||||||
|
<ErrorBoundary
|
||||||
|
key={update.id}
|
||||||
|
fallback={(_, reset) => (
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<InlineError
|
||||||
|
layout="horizontal"
|
||||||
|
message={t("errors.inline.message")}
|
||||||
|
retryLabel={t("errors.inline.retry")}
|
||||||
|
onRetry={reset}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MailingListUpdateListItem updateKey={update} />
|
||||||
|
</ErrorBoundary>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { Anchor } from "@probo/ui/src/v2/Button/Anchor";
|
||||||
|
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||||
|
import { ErrorState } from "@probo/ui/src/v2/ErrorState/ErrorState";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { getPathPrefix } from "#/lib/http/pathPrefix";
|
||||||
|
|
||||||
|
// Outermost fallback for failures that happen before (or in) the router itself.
|
||||||
|
// It cannot use the router (no context yet), so navigation is a plain anchor and
|
||||||
|
// recovery is a hard reload.
|
||||||
|
export function BootstrapError() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
fullPage
|
||||||
|
title={t("errors.generic.title")}
|
||||||
|
description={t("errors.generic.description")}
|
||||||
|
actions={(
|
||||||
|
<>
|
||||||
|
<Anchor href={getPathPrefix() || "/"} variant="solid" color="neutral" highContrast size={2}>
|
||||||
|
{t("errors.actions.backToTrustCenter")}
|
||||||
|
</Anchor>
|
||||||
|
<Button variant="soft" color="neutral" size={2} onClick={() => window.location.reload()}>
|
||||||
|
{t("errors.actions.tryAgain")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/compliance-portal/src/components/errors/GlobalError.tsx
Normal file
89
apps/compliance-portal/src/components/errors/GlobalError.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { ForbiddenError, InternalServerError, UnAuthenticatedError } from "@probo/relay";
|
||||||
|
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||||
|
import { Link } from "@probo/ui/src/v2/Button/Link";
|
||||||
|
import { ErrorState } from "@probo/ui/src/v2/ErrorState/ErrorState";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { NotFoundError } from "#/lib/relay/errors";
|
||||||
|
|
||||||
|
interface ErrorContent {
|
||||||
|
code?: string;
|
||||||
|
titleKey: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map a caught error to the page-level copy. Recognizes the portal error
|
||||||
|
// classes first, then falls back to the code embedded in generic Error messages
|
||||||
|
// (thrown request-level by lib/relay/fetch.ts).
|
||||||
|
function resolveContent(error: unknown): ErrorContent {
|
||||||
|
const message = error instanceof Error ? error.message : "";
|
||||||
|
|
||||||
|
if (error instanceof NotFoundError || message.includes("NOT_FOUND")) {
|
||||||
|
return { code: "404", titleKey: "errors.notFound.title", descriptionKey: "errors.notFound.description" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
error instanceof ForbiddenError
|
||||||
|
|| error instanceof UnAuthenticatedError
|
||||||
|
|| message.includes("FORBIDDEN")
|
||||||
|
|| message.includes("UNAUTHENTICATED")
|
||||||
|
) {
|
||||||
|
return { code: "403", titleKey: "errors.forbidden.title", descriptionKey: "errors.forbidden.description" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof InternalServerError || message.includes("INTERNAL_SERVER_ERROR")) {
|
||||||
|
return { code: "500", titleKey: "errors.serverError.title", descriptionKey: "errors.serverError.description" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { titleKey: "errors.generic.title", descriptionKey: "errors.generic.description" };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalErrorProps {
|
||||||
|
error: unknown;
|
||||||
|
// When provided, a "Try again" secondary action is shown.
|
||||||
|
onRetry?: () => void;
|
||||||
|
// Full viewport (standalone) vs inside the app chrome (in-shell).
|
||||||
|
fullPage?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-level error fallback: renders the v2 ErrorState with portal copy and
|
||||||
|
// actions. Used by the bootstrap boundary and the route boundaries.
|
||||||
|
export function GlobalError({ error, onRetry, fullPage = false }: GlobalErrorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { code, titleKey, descriptionKey } = resolveContent(error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
fullPage={fullPage}
|
||||||
|
code={code}
|
||||||
|
title={t(titleKey)}
|
||||||
|
description={t(descriptionKey)}
|
||||||
|
actions={(
|
||||||
|
<>
|
||||||
|
<Link to="/" variant="solid" color="neutral" highContrast size={2}>
|
||||||
|
{t("errors.actions.backToTrustCenter")}
|
||||||
|
</Link>
|
||||||
|
{onRetry && (
|
||||||
|
<Button variant="soft" color="neutral" size={2} onClick={onRetry}>
|
||||||
|
{t("errors.actions.tryAgain")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { useRouteError } from "react-router";
|
||||||
|
|
||||||
|
import { GlobalError } from "./GlobalError";
|
||||||
|
|
||||||
|
// Child-route boundary: a page failure is contained to the layout's Outlet, so
|
||||||
|
// the error renders inside the app chrome (TopBar + footer survive).
|
||||||
|
export function PageErrorBoundary() {
|
||||||
|
const error = useRouteError();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlobalError
|
||||||
|
error={error}
|
||||||
|
onRetry={() => window.location.reload()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { useRouteError } from "react-router";
|
||||||
|
|
||||||
|
import { GlobalError } from "./GlobalError";
|
||||||
|
|
||||||
|
// Root route boundary: a failure in the layout (or anything above the page
|
||||||
|
// boundaries) takes down the whole tree, so it renders a standalone full-page
|
||||||
|
// error without the app chrome.
|
||||||
|
export function RootErrorBoundary() {
|
||||||
|
const error = useRouteError();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlobalError
|
||||||
|
error={error}
|
||||||
|
fullPage
|
||||||
|
onRetry={() => window.location.reload()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,10 +18,10 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { makeFetchQuery } from "@probo/relay";
|
|
||||||
import { Environment, Network, RecordSource, Store } from "relay-runtime";
|
import { Environment, Network, RecordSource, Store } from "relay-runtime";
|
||||||
|
|
||||||
import { buildEndpoint } from "#/lib/http/endpoint";
|
import { buildEndpoint } from "#/lib/http/endpoint";
|
||||||
|
import { makeFetchQuery } from "#/lib/relay/fetch";
|
||||||
|
|
||||||
const store = new Store(new RecordSource(), {
|
const store = new Store(new RecordSource(), {
|
||||||
queryCacheExpirationTime: 1 * 60 * 1000,
|
queryCacheExpirationTime: 1 * 60 * 1000,
|
||||||
|
|||||||
25
apps/compliance-portal/src/lib/relay/errors.ts
Normal file
25
apps/compliance-portal/src/lib/relay/errors.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
// Thrown when a fetched `node(id:)` resolves to a type other than the one the
|
||||||
|
// view expects (e.g. `node.__typename !== "MailingListUpdate"`). Treated as a
|
||||||
|
// not-found (404) by the error boundaries. Prefer this over a bare
|
||||||
|
// `throw new Error(...)` so the boundary can render the dedicated 404 state.
|
||||||
|
export class NotFoundError extends Error {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super(message ?? "NOT_FOUND");
|
||||||
|
this.name = "NotFoundError";
|
||||||
|
Object.setPrototypeOf(this, NotFoundError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
apps/compliance-portal/src/lib/relay/fetch.ts
Normal file
123
apps/compliance-portal/src/lib/relay/fetch.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { ForbiddenError, InternalServerError, UnAuthenticatedError } from "@probo/relay";
|
||||||
|
import { type GraphQLError } from "graphql";
|
||||||
|
import { type FetchFunction, type GraphQLResponse } from "relay-runtime";
|
||||||
|
|
||||||
|
// A GraphQL error is "request-level" when it has no `path` — it applies to the
|
||||||
|
// whole operation (auth, malformed request, transport) rather than a single
|
||||||
|
// field. Field-level errors carry a `path` and are left in the response so
|
||||||
|
// Relay can attribute them to the reading field; combined with
|
||||||
|
// `@throwOnFieldError` on a query/fragment, they surface at the nearest error
|
||||||
|
// boundary around the component that reads them, instead of collapsing the whole
|
||||||
|
// operation. See contrib/claude/error-handling.md.
|
||||||
|
const isRequestLevel = (error: GraphQLError) =>
|
||||||
|
error.path === undefined || error.path === null || error.path.length === 0;
|
||||||
|
|
||||||
|
// The portal fetch only throws for request-level failures. Everything else
|
||||||
|
// (field-level errors) flows through to Relay untouched.
|
||||||
|
export const makeFetchQuery = (endpoint: string): FetchFunction => {
|
||||||
|
return async (request, variables, _, uploadables) => {
|
||||||
|
const requestInit: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (uploadables) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append(
|
||||||
|
"operations",
|
||||||
|
JSON.stringify({
|
||||||
|
operationName: request.name,
|
||||||
|
query: request.text,
|
||||||
|
variables: variables,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadableMap: {
|
||||||
|
[key: string]: string[];
|
||||||
|
} = {};
|
||||||
|
const uploadableKeys = Object.keys(uploadables);
|
||||||
|
|
||||||
|
uploadableKeys.forEach((key) => {
|
||||||
|
uploadableMap[key] = [`variables.${key}`];
|
||||||
|
});
|
||||||
|
|
||||||
|
formData.append("map", JSON.stringify(uploadableMap));
|
||||||
|
|
||||||
|
uploadableKeys.forEach((key) => {
|
||||||
|
formData.append(key, uploadables[key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
requestInit.body = formData;
|
||||||
|
} else {
|
||||||
|
requestInit.headers = {
|
||||||
|
"Accept": "application/graphql-response+json; charset=utf-8, application/json; charset=utf-8",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
requestInit.body = JSON.stringify({
|
||||||
|
operationName: request.name,
|
||||||
|
query: request.text,
|
||||||
|
variables,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, requestInit);
|
||||||
|
|
||||||
|
if (response.status === 500) {
|
||||||
|
throw new InternalServerError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await response.json()) as GraphQLResponse & {
|
||||||
|
errors?: GraphQLError[];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (json.errors) {
|
||||||
|
// An unauthenticated session is always a global concern: the backend
|
||||||
|
// attaches the resolver path even to auth errors, so we must scan every
|
||||||
|
// error (not just request-level ones) and redirect regardless of where it
|
||||||
|
// surfaced.
|
||||||
|
const unauthenticated = json.errors.find(
|
||||||
|
error => error.extensions?.code === "UNAUTHENTICATED",
|
||||||
|
);
|
||||||
|
if (unauthenticated) {
|
||||||
|
throw new UnAuthenticatedError(unauthenticated.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else is only thrown here when it is request-level (no path) —
|
||||||
|
// a whole-operation failure. Field-level errors (including a FORBIDDEN on
|
||||||
|
// a single field/section) are left in the response so Relay surfaces them
|
||||||
|
// at the reading component via @throwOnFieldError, containing the failure
|
||||||
|
// to that component's boundary instead of the whole page.
|
||||||
|
const requestErrors = json.errors.filter(isRequestLevel);
|
||||||
|
|
||||||
|
const forbidden = requestErrors.find(
|
||||||
|
error => error.extensions?.code === "FORBIDDEN",
|
||||||
|
);
|
||||||
|
if (forbidden) {
|
||||||
|
throw new ForbiddenError(forbidden.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestError = requestErrors[0];
|
||||||
|
if (requestError) {
|
||||||
|
throw new Error(requestError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -32,7 +32,7 @@ import { TrustedBySection } from "#/components/TrustedBy/TrustedBySection";
|
|||||||
import type { HomePageQuery } from "./__generated__/HomePageQuery.graphql";
|
import type { HomePageQuery } from "./__generated__/HomePageQuery.graphql";
|
||||||
|
|
||||||
export const homePageQuery = graphql`
|
export const homePageQuery = graphql`
|
||||||
query HomePageQuery {
|
query HomePageQuery @throwOnFieldError {
|
||||||
currentTrustCenter @required(action: THROW) {
|
currentTrustCenter @required(action: THROW) {
|
||||||
organization {
|
organization {
|
||||||
name
|
name
|
||||||
|
|||||||
@@ -28,13 +28,14 @@ import { graphql, usePreloadedQuery } from "react-relay";
|
|||||||
|
|
||||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||||
import { formatDate } from "#/lib/datetime/formatDate";
|
import { formatDate } from "#/lib/datetime/formatDate";
|
||||||
|
import { NotFoundError } from "#/lib/relay/errors";
|
||||||
|
|
||||||
import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql";
|
import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql";
|
||||||
import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton";
|
import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton";
|
||||||
import { updateArticle } from "./_components/variants";
|
import { updateArticle } from "./_components/variants";
|
||||||
|
|
||||||
export const updateDetailPageQuery = graphql`
|
export const updateDetailPageQuery = graphql`
|
||||||
query UpdateDetailPageQuery($updateId: ID!) {
|
query UpdateDetailPageQuery($updateId: ID!) @throwOnFieldError {
|
||||||
node(id: $updateId) {
|
node(id: $updateId) {
|
||||||
__typename
|
__typename
|
||||||
... on MailingListUpdate {
|
... on MailingListUpdate {
|
||||||
@@ -55,7 +56,7 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
|
|||||||
const data = usePreloadedQuery<UpdateDetailPageQuery>(updateDetailPageQuery, queryRef);
|
const data = usePreloadedQuery<UpdateDetailPageQuery>(updateDetailPageQuery, queryRef);
|
||||||
|
|
||||||
if (data.node?.__typename !== "MailingListUpdate") {
|
if (data.node?.__typename !== "MailingListUpdate") {
|
||||||
throw new Error("Update not found");
|
throw new NotFoundError("Update not found");
|
||||||
}
|
}
|
||||||
const update = data.node;
|
const update = data.node;
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { lazy } from "@probo/react-lazy";
|
|||||||
import { type AppRoute, routeFromAppRoute } from "@probo/routes";
|
import { type AppRoute, routeFromAppRoute } from "@probo/routes";
|
||||||
import { createBrowserRouter } from "react-router";
|
import { createBrowserRouter } from "react-router";
|
||||||
|
|
||||||
|
import { PageErrorBoundary } from "#/components/errors/PageErrorBoundary";
|
||||||
|
import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary";
|
||||||
import { getPathPrefix } from "#/lib/http/pathPrefix";
|
import { getPathPrefix } from "#/lib/http/pathPrefix";
|
||||||
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
|
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
|
||||||
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
|
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
|
||||||
@@ -33,25 +35,34 @@ const routes = [
|
|||||||
path: "/",
|
path: "/",
|
||||||
Fallback: MainLayoutSkeleton,
|
Fallback: MainLayoutSkeleton,
|
||||||
Component: lazy(() => import("#/pages/MainLayoutLoader")),
|
Component: lazy(() => import("#/pages/MainLayoutLoader")),
|
||||||
|
// A layout failure takes down the shell, so it shows a standalone full page.
|
||||||
|
ErrorBoundary: RootErrorBoundary,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
// Pathless layout route: page failures bubble here and render inside the
|
||||||
Fallback: HomePageSkeleton,
|
// MainLayout Outlet, keeping the TopBar and footer chrome.
|
||||||
Component: lazy(() => import("#/pages/HomePageLoader")),
|
ErrorBoundary: PageErrorBoundary,
|
||||||
},
|
children: [
|
||||||
{
|
{
|
||||||
path: "documents",
|
index: true,
|
||||||
Component: lazy(() => import("#/pages/DocumentsPage")),
|
Fallback: HomePageSkeleton,
|
||||||
},
|
Component: lazy(() => import("#/pages/HomePageLoader")),
|
||||||
...subprocessorRoutes,
|
},
|
||||||
...updateRoutes,
|
{
|
||||||
{
|
path: "documents",
|
||||||
path: "requests",
|
Component: lazy(() => import("#/pages/DocumentsPage")),
|
||||||
Component: lazy(() => import("#/pages/RequestsPage")),
|
},
|
||||||
},
|
...subprocessorRoutes,
|
||||||
{
|
...updateRoutes,
|
||||||
path: "*",
|
{
|
||||||
Component: lazy(() => import("#/pages/NotFoundPage")),
|
path: "requests",
|
||||||
|
Component: lazy(() => import("#/pages/RequestsPage")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "*",
|
||||||
|
Component: lazy(() => import("#/pages/NotFoundPage")),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -156,6 +156,93 @@ function PublishButton() {
|
|||||||
|
|
||||||
For Relay mutations, prefer the built-in `onCompleted` / `onError` callbacks (see [`relay.md`](relay.md)) over a manual `try`/`catch`; use `try`/`catch` for non-Relay async work (fetch, parsing, third-party SDKs).
|
For Relay mutations, prefer the built-in `onCompleted` / `onError` callbacks (see [`relay.md`](relay.md)) over a manual `try`/`catch`; use `try`/`catch` for non-Relay async work (fetch, parsing, third-party SDKs).
|
||||||
|
|
||||||
|
## Relay field errors and fragment-level boundaries
|
||||||
|
|
||||||
|
A GraphQL response can be **partial**: `data` is present but one field carries an
|
||||||
|
error (with a `path`). To contain such a failure to the component that reads the
|
||||||
|
bad field — instead of collapsing the whole page — two pieces cooperate:
|
||||||
|
|
||||||
|
1. **The fetch layer only throws request-level errors.** A request-level error
|
||||||
|
has **no `path`** (auth, malformed request, transport) and applies to the
|
||||||
|
whole operation, so it throws and propagates to the nearest boundary.
|
||||||
|
Field-level errors (those with a `path`) are **left in the response** so Relay
|
||||||
|
can attribute them to the reading field.
|
||||||
|
|
||||||
|
The compliance-portal wires this in its own
|
||||||
|
[`apps/compliance-portal/src/lib/relay/fetch.ts`](../../apps/compliance-portal/src/lib/relay/fetch.ts)
|
||||||
|
(it does **not** use `@probo/relay`'s `makeFetchQuery`, which throws for the
|
||||||
|
whole operation on any known code).
|
||||||
|
|
||||||
|
2. **`@throwOnFieldError` on the query/fragment that reads the field.** With the
|
||||||
|
directive set, a field error throws **at the read site** (`usePreloadedQuery`
|
||||||
|
for a query, `useFragment` for a fragment). Put it on the **fragment** to
|
||||||
|
isolate a section/row, and on the **query** to route page-level field errors
|
||||||
|
to the route boundary.
|
||||||
|
|
||||||
|
Because `useFragment` throws in the component body (not in a child), the boundary
|
||||||
|
must be an **ancestor**. Split the component into a thin wrapper (holds the
|
||||||
|
`ErrorBoundary`) and a `*Content` child (reads the fragment):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function RecentUpdatesSection({ trustCenterKey }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={(_, reset) => (
|
||||||
|
<InlineError message={t("errors.inline.message")} onRetry={reset} retryLabel={t("errors.inline.retry")} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RecentUpdatesSectionContent trustCenterKey={trustCenterKey} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentUpdatesSectionContent({ trustCenterKey }: Props) {
|
||||||
|
const data = useFragment(fragment, trustCenterKey); // throws here on a field error
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = graphql`
|
||||||
|
fragment RecentUpdatesSection_trustCenter on TrustCenter @throwOnFieldError { ... }
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
The portal ships three fallback tiers, all backed by the same `ErrorBoundary`:
|
||||||
|
|
||||||
|
| Tier | Placement | Fallback |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| Global (bootstrap) | around `RouterProvider` in `App.tsx`, and the root route | `ErrorState` full page (standalone) |
|
||||||
|
| Page | pathless child route inside the layout | `ErrorState` inside the shell (TopBar/footer survive) |
|
||||||
|
| Section / row | around a fragment-reading subtree | `InlineError` (vertical for sections, horizontal for rows) with a retry |
|
||||||
|
|
||||||
|
`ErrorState` and `InlineError` are presentational v2 kit components (see
|
||||||
|
[`ui.md`](ui.md)); the app maps the caught error to copy/actions and passes them
|
||||||
|
in.
|
||||||
|
|
||||||
|
## Custom errors for node-type mismatches
|
||||||
|
|
||||||
|
When a page fetches `node(id:)` and the resolved `__typename` is not the type the
|
||||||
|
view expects, throw a dedicated error, not a bare `Error`, so the boundary can
|
||||||
|
render the correct state (404):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good — a typed error the boundary maps to the not-found page
|
||||||
|
import { NotFoundError } from "#/lib/relay/errors";
|
||||||
|
|
||||||
|
if (data.node?.__typename !== "MailingListUpdate") {
|
||||||
|
throw new NotFoundError("Update not found");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad — an untyped error the boundary can only show as a generic failure
|
||||||
|
if (data.node?.__typename !== "MailingListUpdate") {
|
||||||
|
throw new Error("Update not found");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [`relay.md`](relay.md) (Node type guards).
|
||||||
|
|
||||||
## Placement guidance
|
## Placement guidance
|
||||||
|
|
||||||
- **Route root** — one boundary so an unhandled failure shows a full-page error instead of a blank screen.
|
- **Route root** — one boundary so an unhandled failure shows a full-page error instead of a blank screen.
|
||||||
|
|||||||
@@ -261,6 +261,52 @@ const logoUrl = organization.logo?.downloadUrl ?? undefined; // logo stays optio
|
|||||||
|
|
||||||
Do **not** reach for `@required` to silence nullability on fields that are *genuinely* optional (an avatar, a logo, a description that may be empty). Those keep their nullable type and get a real empty/fallback state. Likewise, never select a field, mark it `@required(action: THROW)`, and rely on the throw as control flow for an expected-empty case — that is an error path, not a branch. And there is no need to annotate fields the schema already declares non-null (`String!`, `Organization!`).
|
Do **not** reach for `@required` to silence nullability on fields that are *genuinely* optional (an avatar, a logo, a description that may be empty). Those keep their nullable type and get a real empty/fallback state. Likewise, never select a field, mark it `@required(action: THROW)`, and rely on the throw as control flow for an expected-empty case — that is an error path, not a branch. And there is no need to annotate fields the schema already declares non-null (`String!`, `Organization!`).
|
||||||
|
|
||||||
|
### Node type guards
|
||||||
|
|
||||||
|
A `node(id:)` query resolves to an interface (`Node`), so the page must narrow it
|
||||||
|
to the concrete type before use. When the `__typename` is not what the view
|
||||||
|
expects, throw a **typed** error the nearest error boundary can map to the right
|
||||||
|
state (a not-found page), not a bare `Error`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good — NotFoundError is mapped to the 404 state by the boundary
|
||||||
|
import { NotFoundError } from "#/lib/relay/errors";
|
||||||
|
|
||||||
|
const data = usePreloadedQuery<UpdateDetailPageQuery>(updateDetailPageQuery, queryRef);
|
||||||
|
if (data.node?.__typename !== "MailingListUpdate") {
|
||||||
|
throw new NotFoundError("Update not found");
|
||||||
|
}
|
||||||
|
const update = data.node; // narrowed to MailingListUpdate
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad — an untyped error only renders a generic failure
|
||||||
|
if (data.node?.__typename !== "MailingListUpdate") {
|
||||||
|
throw new Error("Update not found");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a client-side invariant, distinct from server field errors (which flow
|
||||||
|
through `@throwOnFieldError`; see below). See
|
||||||
|
[`error-handling.md`](error-handling.md).
|
||||||
|
|
||||||
|
### Field errors (`@throwOnFieldError`)
|
||||||
|
|
||||||
|
To contain a **partial** GraphQL failure (data present, one field errored) to the
|
||||||
|
component that reads the bad field, annotate the query or fragment with
|
||||||
|
`@throwOnFieldError`. The field error then throws at the read site
|
||||||
|
(`usePreloadedQuery` / `useFragment`) and is caught by the nearest `ErrorBoundary`
|
||||||
|
— on a **fragment** to isolate a section/row, on a **query** for page-level
|
||||||
|
fields. This only works when the network layer leaves field-level errors in the
|
||||||
|
response (see the portal fetch in [`error-handling.md`](error-handling.md)).
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
# Good — a field error in this fragment throws at the section's useFragment
|
||||||
|
fragment RecentUpdatesSection_trustCenter on TrustCenter @throwOnFieldError {
|
||||||
|
updates(first: 5) { edges { node { id ...MailingListUpdateListItem_update } } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Refetchable fragments
|
### Refetchable fragments
|
||||||
|
|
||||||
For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`:
|
For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`:
|
||||||
|
|||||||
55
packages/ui/src/v2/ErrorBoundary/ErrorBoundary.tsx
Normal file
55
packages/ui/src/v2/ErrorBoundary/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface ErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
// A node, or a render function that receives the caught error + a reset fn.
|
||||||
|
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
|
||||||
|
onError?: (error: Error, info: ErrorInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The single reusable error boundary primitive (the sanctioned use of a class).
|
||||||
|
// Generic — works at bootstrap, route, section, or component level; only the
|
||||||
|
// placement and the `fallback` differ. See contrib/claude/error-handling.md.
|
||||||
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
state: ErrorBoundaryState = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
this.props.onError?.(error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset = () => this.setState({ error: null });
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { error } = this.state;
|
||||||
|
if (error) {
|
||||||
|
const { fallback } = this.props;
|
||||||
|
if (typeof fallback === "function") {
|
||||||
|
return fallback(error, this.reset);
|
||||||
|
}
|
||||||
|
return fallback ?? null;
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
packages/ui/src/v2/ErrorState/ErrorState.stories.tsx
Normal file
73
packages/ui/src/v2/ErrorState/ErrorState.stories.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
|
||||||
|
import { Button } from "../Button/Button";
|
||||||
|
|
||||||
|
import { ErrorState } from "./ErrorState";
|
||||||
|
|
||||||
|
const actions = (
|
||||||
|
<>
|
||||||
|
<Button size={2} color="neutral" highContrast>Back to trust center</Button>
|
||||||
|
<Button size={2} variant="soft" color="neutral">Contact support</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "v2/ErrorState",
|
||||||
|
component: ErrorState,
|
||||||
|
args: {
|
||||||
|
code: "404",
|
||||||
|
title: "Page not found",
|
||||||
|
description: "The page you're looking for doesn't exist or may have been moved.",
|
||||||
|
actions,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof ErrorState>;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof ErrorState>;
|
||||||
|
|
||||||
|
export const Playground: Story = {};
|
||||||
|
|
||||||
|
export const NotFound: Story = {
|
||||||
|
args: {
|
||||||
|
code: "404",
|
||||||
|
title: "Page not found",
|
||||||
|
description: "The page you're looking for doesn't exist or may have been moved.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Forbidden: Story = {
|
||||||
|
args: {
|
||||||
|
code: "403",
|
||||||
|
title: "Access denied",
|
||||||
|
description: "You don't have permission to view this page.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ServerError: Story = {
|
||||||
|
args: {
|
||||||
|
code: "500",
|
||||||
|
title: "Something went wrong",
|
||||||
|
description: "We hit an unexpected error. Please try again later.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithoutCode: Story = {
|
||||||
|
args: {
|
||||||
|
code: undefined,
|
||||||
|
title: "Something went wrong",
|
||||||
|
description: "We hit an unexpected error. Please try again later.",
|
||||||
|
},
|
||||||
|
};
|
||||||
59
packages/ui/src/v2/ErrorState/ErrorState.tsx
Normal file
59
packages/ui/src/v2/ErrorState/ErrorState.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { VariantProps } from "tailwind-variants/lite";
|
||||||
|
|
||||||
|
import { Heading } from "../typography/Heading";
|
||||||
|
import { Text } from "../typography/Text";
|
||||||
|
|
||||||
|
import { errorState } from "./variants";
|
||||||
|
|
||||||
|
export type ErrorStateProps
|
||||||
|
= VariantProps<typeof errorState>
|
||||||
|
& {
|
||||||
|
title: string;
|
||||||
|
// Optional status code / label shown above the title (e.g. "404").
|
||||||
|
code?: string;
|
||||||
|
description?: string;
|
||||||
|
// Action slot (primary / secondary buttons). Left to the caller so the
|
||||||
|
// native Button/Link props stay editable. See contrib/claude/ui.md.
|
||||||
|
actions?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Presentational full-page error message (Figma "Error message / Page"). Copy
|
||||||
|
// and actions come from the caller; this only lays them out.
|
||||||
|
export function ErrorState({ code, title, description, actions, fullPage, className }: ErrorStateProps) {
|
||||||
|
const slots = errorState({ fullPage });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={slots.root({ className })}>
|
||||||
|
<div className={slots.block()}>
|
||||||
|
<div className={slots.content()}>
|
||||||
|
{code && (
|
||||||
|
<Text size={1} color="gold" align="center">{code}</Text>
|
||||||
|
)}
|
||||||
|
<Heading level={1} size={4} weight="medium" align="center" highContrast>
|
||||||
|
{title}
|
||||||
|
</Heading>
|
||||||
|
{description && (
|
||||||
|
<Text size={2} color="neutral" align="center">{description}</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && <div className={slots.actions()}>{actions}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
packages/ui/src/v2/ErrorState/variants.ts
Normal file
40
packages/ui/src/v2/ErrorState/variants.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { tv } from "tailwind-variants/lite";
|
||||||
|
|
||||||
|
// Centered full-page error message block for portal boundaries (Figma
|
||||||
|
// "Error message / Page"). Used for 404 / 403 / 500 / generic page-level errors.
|
||||||
|
// root the centering wrapper (page vs in-shell sizing)
|
||||||
|
// block the 256px content column (code + title + description + actions)
|
||||||
|
// content the stacked text region
|
||||||
|
// actions the primary/secondary action row
|
||||||
|
export const errorState = tv({
|
||||||
|
slots: {
|
||||||
|
root: "flex w-full items-center justify-center",
|
||||||
|
block: "flex min-w-64 max-w-md flex-col items-center gap-6 text-center",
|
||||||
|
content: "flex w-full flex-col items-center gap-2",
|
||||||
|
actions: "flex items-center justify-center gap-2",
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
// Standalone fills the viewport; in-shell sits inside the app chrome.
|
||||||
|
fullPage: {
|
||||||
|
true: { root: "min-h-screen px-6 py-16" },
|
||||||
|
false: { root: "px-6 py-12" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
fullPage: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
63
packages/ui/src/v2/InlineError/InlineError.stories.tsx
Normal file
63
packages/ui/src/v2/InlineError/InlineError.stories.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
|
||||||
|
import { InlineError } from "./InlineError";
|
||||||
|
|
||||||
|
const message = "Unable to load content";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "v2/InlineError",
|
||||||
|
component: InlineError,
|
||||||
|
args: {
|
||||||
|
layout: "vertical",
|
||||||
|
message,
|
||||||
|
onRetry: () => {},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof InlineError>;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof InlineError>;
|
||||||
|
|
||||||
|
export const Playground: Story = {
|
||||||
|
render: args => (
|
||||||
|
<div className="w-96">
|
||||||
|
<InlineError {...args} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Vertical: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="w-96">
|
||||||
|
<InlineError layout="vertical" message={message} onRetry={() => {}} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Horizontal: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="w-96">
|
||||||
|
<InlineError layout="horizontal" message={message} onRetry={() => {}} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithoutRetry: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="w-96">
|
||||||
|
<InlineError layout="vertical" message={message} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
47
packages/ui/src/v2/InlineError/InlineError.tsx
Normal file
47
packages/ui/src/v2/InlineError/InlineError.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { VariantProps } from "tailwind-variants/lite";
|
||||||
|
|
||||||
|
import { Button } from "../Button/Button";
|
||||||
|
import { Text } from "../typography/Text";
|
||||||
|
|
||||||
|
import { inlineError } from "./variants";
|
||||||
|
|
||||||
|
export type InlineErrorProps
|
||||||
|
= VariantProps<typeof inlineError>
|
||||||
|
& {
|
||||||
|
message: string;
|
||||||
|
// Retry handler. When omitted, the retry action is hidden.
|
||||||
|
onRetry?: () => void;
|
||||||
|
retryLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Presentational inline error (Figma "Error message / Inline"). Copy and the
|
||||||
|
// retry handler come from the caller; this only lays them out.
|
||||||
|
export function InlineError({ layout, message, onRetry, retryLabel = "Retry", className }: InlineErrorProps) {
|
||||||
|
const slots = inlineError({ layout });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={slots.root({ className })}>
|
||||||
|
<Text size={2} color="neutral" className={slots.message()}>{message}</Text>
|
||||||
|
{onRetry && (
|
||||||
|
<Button size={2} variant="soft" color="neutral" onClick={onRetry}>
|
||||||
|
{retryLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
packages/ui/src/v2/InlineError/variants.ts
Normal file
41
packages/ui/src/v2/InlineError/variants.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { tv } from "tailwind-variants/lite";
|
||||||
|
|
||||||
|
// Localized inline error for section / card / list / row load failures (Figma
|
||||||
|
// "Error message / Inline").
|
||||||
|
// vertical centered column for contained spaces (sections, cards, panels)
|
||||||
|
// horizontal compact row for list / table rows
|
||||||
|
export const inlineError = tv({
|
||||||
|
slots: {
|
||||||
|
root: "flex w-full gap-2",
|
||||||
|
message: "",
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
layout: {
|
||||||
|
vertical: {
|
||||||
|
root: "flex-col items-center justify-center text-center",
|
||||||
|
message: "w-full",
|
||||||
|
},
|
||||||
|
horizontal: {
|
||||||
|
root: "flex-row items-center",
|
||||||
|
message: "flex-1 text-left",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
layout: "vertical",
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user