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:
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