From 9cd73816b0fe4dcf88c874ee44ddc1c9eab828b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 9 Jul 2026 20:32:30 -0400 Subject: [PATCH] Add layered error boundaries to compliance portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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é --- apps/compliance-portal/src/App.tsx | 10 +- .../compliance-portal/src/_locales/en-US.json | 26 ++++ .../compliance-portal/src/_locales/fr-FR.json | 26 ++++ .../ComplianceFrameworksSection.tsx | 30 ++++- .../MailingListUpdateListItem.tsx | 2 +- .../RecentUpdates/RecentUpdatesSection.tsx | 45 ++++++- .../src/components/errors/BootstrapError.tsx | 45 +++++++ .../src/components/errors/GlobalError.tsx | 89 +++++++++++++ .../components/errors/PageErrorBoundary.tsx | 30 +++++ .../components/errors/RootErrorBoundary.tsx | 32 +++++ .../src/lib/relay/environment.ts | 2 +- .../compliance-portal/src/lib/relay/errors.ts | 25 ++++ apps/compliance-portal/src/lib/relay/fetch.ts | 123 ++++++++++++++++++ apps/compliance-portal/src/pages/HomePage.tsx | 2 +- .../src/pages/updates/UpdateDetailPage.tsx | 5 +- apps/compliance-portal/src/routes.tsx | 45 ++++--- contrib/claude/error-handling.md | 87 +++++++++++++ contrib/claude/relay.md | 46 +++++++ .../ui/src/v2/ErrorBoundary/ErrorBoundary.tsx | 55 ++++++++ .../src/v2/ErrorState/ErrorState.stories.tsx | 73 +++++++++++ packages/ui/src/v2/ErrorState/ErrorState.tsx | 59 +++++++++ packages/ui/src/v2/ErrorState/variants.ts | 40 ++++++ .../v2/InlineError/InlineError.stories.tsx | 63 +++++++++ .../ui/src/v2/InlineError/InlineError.tsx | 47 +++++++ packages/ui/src/v2/InlineError/variants.ts | 41 ++++++ 25 files changed, 1018 insertions(+), 30 deletions(-) create mode 100644 apps/compliance-portal/src/components/errors/BootstrapError.tsx create mode 100644 apps/compliance-portal/src/components/errors/GlobalError.tsx create mode 100644 apps/compliance-portal/src/components/errors/PageErrorBoundary.tsx create mode 100644 apps/compliance-portal/src/components/errors/RootErrorBoundary.tsx create mode 100644 apps/compliance-portal/src/lib/relay/errors.ts create mode 100644 apps/compliance-portal/src/lib/relay/fetch.ts create mode 100644 packages/ui/src/v2/ErrorBoundary/ErrorBoundary.tsx create mode 100644 packages/ui/src/v2/ErrorState/ErrorState.stories.tsx create mode 100644 packages/ui/src/v2/ErrorState/ErrorState.tsx create mode 100644 packages/ui/src/v2/ErrorState/variants.ts create mode 100644 packages/ui/src/v2/InlineError/InlineError.stories.tsx create mode 100644 packages/ui/src/v2/InlineError/InlineError.tsx create mode 100644 packages/ui/src/v2/InlineError/variants.ts diff --git a/apps/compliance-portal/src/App.tsx b/apps/compliance-portal/src/App.tsx index c8471e4e5..f7f4e21dc 100644 --- a/apps/compliance-portal/src/App.tsx +++ b/apps/compliance-portal/src/App.tsx @@ -18,15 +18,19 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary"; import { RouterProvider } from "react-router"; +import { BootstrapError } from "#/components/errors/BootstrapError"; import { RelayProvider } from "#/lib/relay/RelayProvider"; import { router } from "#/routes"; export function App() { return ( - - - + }> + + + + ); } diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json index aa9d0baea..482f7dbe7 100644 --- a/apps/compliance-portal/src/_locales/en-US.json +++ b/apps/compliance-portal/src/_locales/en-US.json @@ -36,6 +36,32 @@ "description": "The page you are looking for does not exist or has moved.", "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": { "poweredBy": "Powered by" }, diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json index 8d043eeab..aa603edb3 100644 --- a/apps/compliance-portal/src/_locales/fr-FR.json +++ b/apps/compliance-portal/src/_locales/fr-FR.json @@ -36,6 +36,32 @@ "description": "La page que vous recherchez n'existe pas ou a été déplacée.", "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": { "poweredBy": "Propulsé par" }, diff --git a/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx b/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx index ae0422432..385c58994 100644 --- a/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx +++ b/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx @@ -18,6 +18,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // 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 { 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 { 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` - fragment ComplianceFrameworksSection_trustCenter on TrustCenter { + fragment ComplianceFrameworksSection_trustCenter on TrustCenter @throwOnFieldError { complianceFrameworks(first: 8) { edges { node { @@ -44,9 +49,30 @@ interface ComplianceFrameworksSectionProps { } // "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) { const { t } = useTranslation(); + + return ( + ( + + + + )} + > + + + ); +} + +function ComplianceFrameworksSectionContent({ trustCenterKey }: ComplianceFrameworksSectionProps) { + const { t } = useTranslation(); const data = useFragment(complianceFrameworksSectionFragment, trustCenterKey); const frameworks = data.complianceFrameworks.edges.map(edge => edge.node); diff --git a/apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx b/apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx index 30b89f337..29fbd6800 100644 --- a/apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx +++ b/apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx @@ -29,7 +29,7 @@ import { formatRelativeTime } from "#/lib/datetime/relativeTime"; import type { MailingListUpdateListItem_update$key } from "./__generated__/MailingListUpdateListItem_update.graphql"; const mailingListUpdateListItemFragment = graphql` - fragment MailingListUpdateListItem_update on MailingListUpdate { + fragment MailingListUpdateListItem_update on MailingListUpdate @throwOnFieldError { id title updatedAt diff --git a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx index acff9cb54..334f0a504 100644 --- a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx +++ b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx @@ -19,6 +19,8 @@ // SOFTWARE. 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 { 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"; +// @throwOnFieldError surfaces a field error at the read below so the section +// ErrorBoundary contains it. See contrib/claude/error-handling.md. const recentUpdatesSectionFragment = graphql` - fragment RecentUpdatesSection_trustCenter on TrustCenter { + fragment RecentUpdatesSection_trustCenter on TrustCenter @throwOnFieldError { updates(first: 5) { edges { node { @@ -46,9 +50,29 @@ interface RecentUpdatesSectionProps { } // "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) { const { t } = useTranslation(); + + return ( + ( + + + + )} + > + + + ); +} + +function RecentUpdatesSectionContent({ trustCenterKey }: RecentUpdatesSectionProps) { + const { t } = useTranslation(); const data = useFragment(recentUpdatesSectionFragment, trustCenterKey); const updates = data.updates.edges.map(edge => edge.node); @@ -70,7 +94,22 @@ export function RecentUpdatesSection({ trustCenterKey }: RecentUpdatesSectionPro
{updates.map(update => ( - + // A single failing row degrades to a compact horizontal inline error. + ( +
+ +
+ )} + > + +
))}
diff --git a/apps/compliance-portal/src/components/errors/BootstrapError.tsx b/apps/compliance-portal/src/components/errors/BootstrapError.tsx new file mode 100644 index 000000000..759d0527e --- /dev/null +++ b/apps/compliance-portal/src/components/errors/BootstrapError.tsx @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + + + {t("errors.actions.backToTrustCenter")} + + + + )} + /> + ); +} diff --git a/apps/compliance-portal/src/components/errors/GlobalError.tsx b/apps/compliance-portal/src/components/errors/GlobalError.tsx new file mode 100644 index 000000000..ecb266b91 --- /dev/null +++ b/apps/compliance-portal/src/components/errors/GlobalError.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + + + {t("errors.actions.backToTrustCenter")} + + {onRetry && ( + + )} + + )} + /> + ); +} diff --git a/apps/compliance-portal/src/components/errors/PageErrorBoundary.tsx b/apps/compliance-portal/src/components/errors/PageErrorBoundary.tsx new file mode 100644 index 000000000..8ebe3ba11 --- /dev/null +++ b/apps/compliance-portal/src/components/errors/PageErrorBoundary.tsx @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + window.location.reload()} + /> + ); +} diff --git a/apps/compliance-portal/src/components/errors/RootErrorBoundary.tsx b/apps/compliance-portal/src/components/errors/RootErrorBoundary.tsx new file mode 100644 index 000000000..fb17adeb0 --- /dev/null +++ b/apps/compliance-portal/src/components/errors/RootErrorBoundary.tsx @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + window.location.reload()} + /> + ); +} diff --git a/apps/compliance-portal/src/lib/relay/environment.ts b/apps/compliance-portal/src/lib/relay/environment.ts index bd7934a67..f1bb7b17c 100644 --- a/apps/compliance-portal/src/lib/relay/environment.ts +++ b/apps/compliance-portal/src/lib/relay/environment.ts @@ -18,10 +18,10 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { makeFetchQuery } from "@probo/relay"; import { Environment, Network, RecordSource, Store } from "relay-runtime"; import { buildEndpoint } from "#/lib/http/endpoint"; +import { makeFetchQuery } from "#/lib/relay/fetch"; const store = new Store(new RecordSource(), { queryCacheExpirationTime: 1 * 60 * 1000, diff --git a/apps/compliance-portal/src/lib/relay/errors.ts b/apps/compliance-portal/src/lib/relay/errors.ts new file mode 100644 index 000000000..83ead5089 --- /dev/null +++ b/apps/compliance-portal/src/lib/relay/errors.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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); + } +} diff --git a/apps/compliance-portal/src/lib/relay/fetch.ts b/apps/compliance-portal/src/lib/relay/fetch.ts new file mode 100644 index 000000000..302c45ef2 --- /dev/null +++ b/apps/compliance-portal/src/lib/relay/fetch.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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; + }; +}; diff --git a/apps/compliance-portal/src/pages/HomePage.tsx b/apps/compliance-portal/src/pages/HomePage.tsx index 847ce1543..3d2a70f3f 100644 --- a/apps/compliance-portal/src/pages/HomePage.tsx +++ b/apps/compliance-portal/src/pages/HomePage.tsx @@ -32,7 +32,7 @@ import { TrustedBySection } from "#/components/TrustedBy/TrustedBySection"; import type { HomePageQuery } from "./__generated__/HomePageQuery.graphql"; export const homePageQuery = graphql` - query HomePageQuery { + query HomePageQuery @throwOnFieldError { currentTrustCenter @required(action: THROW) { organization { name diff --git a/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx b/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx index 5714fab73..08fb5a2dd 100644 --- a/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx +++ b/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx @@ -28,13 +28,14 @@ import { graphql, usePreloadedQuery } from "react-relay"; import { HeaderBand } from "#/components/HeaderBand/HeaderBand"; import { formatDate } from "#/lib/datetime/formatDate"; +import { NotFoundError } from "#/lib/relay/errors"; import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql"; import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton"; import { updateArticle } from "./_components/variants"; export const updateDetailPageQuery = graphql` - query UpdateDetailPageQuery($updateId: ID!) { + query UpdateDetailPageQuery($updateId: ID!) @throwOnFieldError { node(id: $updateId) { __typename ... on MailingListUpdate { @@ -55,7 +56,7 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) { const data = usePreloadedQuery(updateDetailPageQuery, queryRef); if (data.node?.__typename !== "MailingListUpdate") { - throw new Error("Update not found"); + throw new NotFoundError("Update not found"); } const update = data.node; diff --git a/apps/compliance-portal/src/routes.tsx b/apps/compliance-portal/src/routes.tsx index 14944792f..8ad4dd3f3 100644 --- a/apps/compliance-portal/src/routes.tsx +++ b/apps/compliance-portal/src/routes.tsx @@ -22,6 +22,8 @@ import { lazy } from "@probo/react-lazy"; import { type AppRoute, routeFromAppRoute } from "@probo/routes"; import { createBrowserRouter } from "react-router"; +import { PageErrorBoundary } from "#/components/errors/PageErrorBoundary"; +import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary"; import { getPathPrefix } from "#/lib/http/pathPrefix"; import { HomePageSkeleton } from "#/pages/HomePageSkeleton"; import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton"; @@ -33,25 +35,34 @@ const routes = [ path: "/", Fallback: MainLayoutSkeleton, Component: lazy(() => import("#/pages/MainLayoutLoader")), + // A layout failure takes down the shell, so it shows a standalone full page. + ErrorBoundary: RootErrorBoundary, children: [ { - index: true, - Fallback: HomePageSkeleton, - Component: lazy(() => import("#/pages/HomePageLoader")), - }, - { - path: "documents", - Component: lazy(() => import("#/pages/DocumentsPage")), - }, - ...subprocessorRoutes, - ...updateRoutes, - { - path: "requests", - Component: lazy(() => import("#/pages/RequestsPage")), - }, - { - path: "*", - Component: lazy(() => import("#/pages/NotFoundPage")), + // Pathless layout route: page failures bubble here and render inside the + // MainLayout Outlet, keeping the TopBar and footer chrome. + ErrorBoundary: PageErrorBoundary, + children: [ + { + index: true, + Fallback: HomePageSkeleton, + Component: lazy(() => import("#/pages/HomePageLoader")), + }, + { + path: "documents", + Component: lazy(() => import("#/pages/DocumentsPage")), + }, + ...subprocessorRoutes, + ...updateRoutes, + { + path: "requests", + Component: lazy(() => import("#/pages/RequestsPage")), + }, + { + path: "*", + Component: lazy(() => import("#/pages/NotFoundPage")), + }, + ], }, ], }, diff --git a/contrib/claude/error-handling.md b/contrib/claude/error-handling.md index 9eabe8abe..a117ff93e 100644 --- a/contrib/claude/error-handling.md +++ b/contrib/claude/error-handling.md @@ -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). +## 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 ( + ( + + )} + > + + + ); +} + +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 - **Route root** — one boundary so an unhandled failure shows a full-page error instead of a blank screen. diff --git a/contrib/claude/relay.md b/contrib/claude/relay.md index 56c3a8919..8f2f49797 100644 --- a/contrib/claude/relay.md +++ b/contrib/claude/relay.md @@ -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!`). +### 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, 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 For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`: diff --git a/packages/ui/src/v2/ErrorBoundary/ErrorBoundary.tsx b/packages/ui/src/v2/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 000000000..c0dfde27f --- /dev/null +++ b/packages/ui/src/v2/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { + 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; + } +} diff --git a/packages/ui/src/v2/ErrorState/ErrorState.stories.tsx b/packages/ui/src/v2/ErrorState/ErrorState.stories.tsx new file mode 100644 index 000000000..2196a70cb --- /dev/null +++ b/packages/ui/src/v2/ErrorState/ErrorState.stories.tsx @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 = ( + <> + + + +); + +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; + +type Story = StoryObj; + +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.", + }, +}; diff --git a/packages/ui/src/v2/ErrorState/ErrorState.tsx b/packages/ui/src/v2/ErrorState/ErrorState.tsx new file mode 100644 index 000000000..b8055be00 --- /dev/null +++ b/packages/ui/src/v2/ErrorState/ErrorState.tsx @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 + & { + 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 ( +
+
+
+ {code && ( + {code} + )} + + {title} + + {description && ( + {description} + )} +
+ {actions &&
{actions}
} +
+
+ ); +} diff --git a/packages/ui/src/v2/ErrorState/variants.ts b/packages/ui/src/v2/ErrorState/variants.ts new file mode 100644 index 000000000..4c63010c9 --- /dev/null +++ b/packages/ui/src/v2/ErrorState/variants.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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, + }, +}); diff --git a/packages/ui/src/v2/InlineError/InlineError.stories.tsx b/packages/ui/src/v2/InlineError/InlineError.stories.tsx new file mode 100644 index 000000000..60d3f1cdb --- /dev/null +++ b/packages/ui/src/v2/InlineError/InlineError.stories.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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; + +type Story = StoryObj; + +export const Playground: Story = { + render: args => ( +
+ +
+ ), +}; + +export const Vertical: Story = { + render: () => ( +
+ {}} /> +
+ ), +}; + +export const Horizontal: Story = { + render: () => ( +
+ {}} /> +
+ ), +}; + +export const WithoutRetry: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/packages/ui/src/v2/InlineError/InlineError.tsx b/packages/ui/src/v2/InlineError/InlineError.tsx new file mode 100644 index 000000000..9cab52030 --- /dev/null +++ b/packages/ui/src/v2/InlineError/InlineError.tsx @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 + & { + 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 ( +
+ {message} + {onRetry && ( + + )} +
+ ); +} diff --git a/packages/ui/src/v2/InlineError/variants.ts b/packages/ui/src/v2/InlineError/variants.ts new file mode 100644 index 000000000..6eb32a97e --- /dev/null +++ b/packages/ui/src/v2/InlineError/variants.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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", + }, +});