Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/components/containers/Card/Card.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { LayoutDecorator } from '../../../util/storybook/LayoutDecorator.tsx';
import type { Meta, StoryObj } from '@storybook/react-vite';

import { Icon } from '../../graphics/Icon/Icon.tsx';
import { Button } from '../../actions/Button/Button.tsx';
import { Banner } from '../Banner/Banner.tsx';

import { Card } from './Card.tsx';
Expand Down Expand Up @@ -171,3 +172,34 @@ export const CardNested: Story = {
),
},
};


const suspensePromise = Promise.withResolvers();
const SuspenseDemo = () => {
React.use(suspensePromise.promise);

return 'Done';
};
export const CardSuspense: Story = {
decorators: [
Story => (
<LayoutDecorator size="small">
<Story/>
<Button kind="primary" label="Resolve" style={{ marginTop: '1lh', alignSelf: 'center' }}
onPress={() => { suspensePromise.resolve(null); }}
/>
<Button kind="primary" label="Reject" style={{ marginTop: '1lh', alignSelf: 'center' }}
onPress={() => { suspensePromise.reject(new Error('Test error')); }}
/>
</LayoutDecorator>
),
],
args: {
children: (
<>
<Card.Heading>A card</Card.Heading>
<SuspenseDemo/>
</>
),
},
};
34 changes: 33 additions & 1 deletion src/components/containers/Card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import * as React from 'react';
import { classNames as cx, type ComponentProps } from '../../../util/componentUtil.ts';
import { type ErrorFallbackProps, ErrorBoundary, ErrorLayout } from '../../util/ErrorBoundary/ErrorBoundary.tsx';

import { Spinner } from '../../graphics/Spinner/Spinner.tsx';
import { H5 } from '../../../typography/Heading/Heading.tsx';
import { Link as LinkDefault } from '../../actions/Link/Link.tsx';

Expand Down Expand Up @@ -66,6 +69,28 @@
(props: CardProps) => {
const { children, unstyled = false, flat = false, ...propsRest } = props;

const renderCard = (state: 'loading' | 'error' | 'ready', errorFallbackProps?: undefined | ErrorFallbackProps) => (
<article
{...propsRest}
className={cx(
'bk',
{ [cl['bk-card']]: !unstyled },
{ [cl['bk-card--flat']]: flat },
{ [cl['bk-card--loading']]: state === 'loading' },
{ [cl['bk-card--error']]: state === 'error' },
propsRest.className,
)}
>
{state === 'loading' && <Spinner size="large"/>}
{state === 'error' && (
<ErrorLayout fallbackProps={errorFallbackProps}>

Check failure on line 86 in src/components/containers/Card/Card.tsx

View workflow job for this annotation

GitHub Actions / build (22.x)

Type 'FallbackProps | undefined' is not assignable to type 'FallbackProps'.

Check failure on line 86 in src/components/containers/Card/Card.tsx

View workflow job for this annotation

GitHub Actions / build (24.x)

Type 'FallbackProps | undefined' is not assignable to type 'FallbackProps'.

Check failure on line 86 in src/components/containers/Card/Card.tsx

View workflow job for this annotation

GitHub Actions / build (22.x)

Type 'FallbackProps | undefined' is not assignable to type 'FallbackProps'.

Check failure on line 86 in src/components/containers/Card/Card.tsx

View workflow job for this annotation

GitHub Actions / build (24.x)

Type 'FallbackProps | undefined' is not assignable to type 'FallbackProps'.
{errorFallbackProps?.error.message}
</ErrorLayout>
)}
{state === 'ready' && children}
</article>
);

return (
<article
{...propsRest}
Expand All @@ -76,7 +101,14 @@
propsRest.className,
)}
>
{children}
<ErrorBoundary
shouldHandleError={(error, info, retryCount) => retryCount <= 2}
fallbackRender={fbProps => <ErrorLayout className={cx(cl['bk-card__state-error'])} fallbackProps={fbProps}/>}
>
<React.Suspense fallback={<Spinner size="large" className={cx(cl['bk-card__state-loading'])}/>}>
{children}
</React.Suspense>
</ErrorBoundary>
</article>
);
},
Expand Down
16 changes: 16 additions & 0 deletions src/components/util/ErrorBoundary/ErrorBoundary.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/* Copyright (c) Fortanix, Inc.
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */

@use '../../../styling/defs.scss' as bk;

@layer baklava.components {
.bk-error-layout {
@include bk.component-base(bk-error-layout);

display: grid;
grid-template-rows: 1fr 1fr;

text-align: center;
}
}
99 changes: 99 additions & 0 deletions src/components/util/ErrorBoundary/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/* Copyright (c) Fortanix, Inc.
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import * as React from 'react';
import { type ComponentProps, classNames as cx } from '../../../util/componentUtil.ts';

import {
type FallbackProps as ErrorFallbackProps,
type ErrorBoundaryProps as ReactErrorBoundaryProps,
ErrorBoundary as ReactErrorBoundary,
useErrorBoundary,
} from 'react-error-boundary';

import { Button } from '../../actions/Button/Button.tsx';

import cl from './ErrorBoundary.module.scss';


export type { ErrorFallbackProps };

const RetryButton = (props: Partial<React.ComponentProps<typeof Button>>) => {
const { resetBoundary } = useErrorBoundary();

const retry = React.useCallback(async () => {
// Add a small sleep period to give the user clear feedback that we're attemping a retry. This is in case the
// retry immediately fails again, which otherwise would provide no feedback to the user.
await new Promise(resolve => window.setTimeout(resolve, 500));

resetBoundary();
}, [resetBoundary]);

return (
<Button label="Try again" {...props} onPress={retry}/>
);
};

type ErrorLayoutProps = ComponentProps<'div'> & {
fallbackProps: ErrorFallbackProps,
//errorMessage: React.ReactNode,
actions?: undefined | React.ReactNode,
};
export const ErrorLayout = (props: ErrorLayoutProps) => {
const { children, fallbackProps, actions, ...propsRest } = props;

const renderErrorMessage = () => {
if (typeof children !== 'undefined') { return children; }

const error = fallbackProps.error;
if (error instanceof Error) { // In the future: use `Error.isError()` (once supported)
return error.message;
} else if (typeof error === 'string' || typeof error === 'number') {
return String(error);
} else {
return 'An unknown error has occurred';
}
};

return (
<div role="alert" {...propsRest} className={cx(cl['bk-error-layout'], propsRest.className)}>
<article>{renderErrorMessage()}</article>
<div>
{actions}

<RetryButton kind="primary"/>

{/* {retryCount.current >= 1 &&
<Button kind="secondary" label="Refresh" onPress={() => { window.location.reload(); }}/>
} */}
</div>
</div>
);
};

type ErrorBoundaryProps = React.PropsWithChildren<ReactErrorBoundaryProps> & {
/** Given an error, returns whether this error boundary should handle the error, or pass it through. */
shouldHandleError?: undefined | ((error: Error, info: React.ErrorInfo, retryCount: number) => boolean),
};
export const ErrorBoundary = (props: ErrorBoundaryProps) => {
const { children, shouldHandleError, ...errorBoundaryProps } = props;

// The amount of times this error boundary has been retried recently
// TODO: reset this to 0 after a certain (short) amount of time
const [retryCount, setRetryCount] = React.useState(0);

return (
<ReactErrorBoundary
{...errorBoundaryProps}
onError={(error, info) => {
if (typeof shouldHandleError === 'function' && !shouldHandleError(error, info, retryCount)) {
throw error;
}
}}
onReset={() => { setRetryCount(count => count + 1); }}
>
{children}
</ReactErrorBoundary>
);
};
Loading