Move everything out of the old core libs, and fix up
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
---
|
||||
'@backstage/core': minor
|
||||
'@backstage/core-api': patch
|
||||
'@backstage/core-app-api': patch
|
||||
'@backstage/core-components': patch
|
||||
'@backstage/core-plugin-api': patch
|
||||
---
|
||||
|
||||
This change adds error boundary on extensions.
|
||||
This change adds automatic error boundaries around extensions.
|
||||
|
||||
This means all exposed parts of a plugin are wrapped in a general error boundary component, that is plugin aware. The default design for the error box is borrowed from @backstage/errors. To override the default "fallback" one must provide a component named `ErrorBoundaryFallback` to `createApp`, like so:
|
||||
This means that all exposed parts of a plugin are wrapped in a general error boundary component, that is plugin aware. The default design for the error box is borrowed from `@backstage/errors`. To override the default "fallback", one must provide a component named `ErrorBoundaryFallback` to `createApp`, like so:
|
||||
|
||||
```ts
|
||||
const app = createApp({
|
||||
@@ -28,6 +29,6 @@ const app = createApp({
|
||||
|
||||
The props here include:
|
||||
|
||||
- `error`. An Error object or something that inherits it that represents the error that was thrown from any inner component.
|
||||
- `resetError`. A callback that will simply mount the children of the error boundary again.
|
||||
- `plugin`. A BackstagePlugin that can be used to lookup info to be presented in the error message. For instance, you may want to keep a map of your internal plugins and team names or slack channels and present these when an error occurs. Typically, you'll do that by getting the plugin id with `plugin.getId()`.
|
||||
- `error`. An `Error` object or something that inherits it that represents the error that was thrown from any inner component.
|
||||
- `resetError`. A callback that will simply attempt to mount the children of the error boundary again.
|
||||
- `plugin`. A `BackstagePlugin` that can be used to look up info to be presented in the error message. For instance, you may want to keep a map of your internal plugins and team names or slack channels and present these when an error occurs. Typically, you'll do that by getting the plugin ID with `plugin.getId()`.
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
FlatRoutes,
|
||||
OAuthRequestDialog,
|
||||
SignInPage,
|
||||
ErrorBoundaryFallback,
|
||||
} from '@backstage/core';
|
||||
import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs';
|
||||
import {
|
||||
@@ -67,9 +66,6 @@ const app = createApp({
|
||||
alert: AlarmIcon,
|
||||
},
|
||||
components: {
|
||||
ErrorBoundaryFallback: props => {
|
||||
return <ErrorBoundaryFallback {...props} />;
|
||||
},
|
||||
SignInPage: props => {
|
||||
return (
|
||||
<SignInPage
|
||||
|
||||
@@ -161,7 +161,6 @@ describe('Integration Test', () => {
|
||||
BootErrorPage: () => null,
|
||||
Progress: () => null,
|
||||
Router: BrowserRouter,
|
||||
ErrorBoundaryFallback: () => null,
|
||||
};
|
||||
|
||||
it('runs happy paths', async () => {
|
||||
|
||||
@@ -14,14 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AppConfig } from '@backstage/config';
|
||||
import { ComponentType } from 'react';
|
||||
import { AppTheme, ProfileInfo } from '../apis/definitions';
|
||||
import { AnyApiFactory } from '../apis/system';
|
||||
import type { ErrorBoundaryFallbackProps } from '../extensions/PluginErrorBoundary';
|
||||
import { IconComponent, IconComponentMap, IconKey } from '../icons/types';
|
||||
import { AnyExternalRoutes, BackstagePlugin } from '../plugin/types';
|
||||
import { ExternalRouteRef, RouteRef, SubRouteRef } from '../routing/types';
|
||||
import { AnyApiFactory } from '../apis/system';
|
||||
import { AppTheme, ProfileInfo } from '../apis/definitions';
|
||||
import { AppConfig } from '@backstage/config';
|
||||
|
||||
export type BootErrorPageProps = {
|
||||
step: 'load-config' | 'load-chunk';
|
||||
@@ -59,11 +58,6 @@ export type AppComponents = {
|
||||
BootErrorPage: ComponentType<BootErrorPageProps>;
|
||||
Progress: ComponentType<{}>;
|
||||
Router: ComponentType<{}>;
|
||||
ErrorBoundaryFallback: ComponentType<
|
||||
ErrorBoundaryFallbackProps & {
|
||||
plugin?: BackstagePlugin;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* An optional sign-in page that will be rendered instead of the AppRouter at startup.
|
||||
|
||||
@@ -14,13 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { errorApiRef } from '../apis/definitions/ErrorApi';
|
||||
import { ApiRegistry } from '../apis/system/ApiRegistry';
|
||||
import { ApiProvider } from '../apis/system/ApiProvider';
|
||||
import { withLogCollector } from '@backstage/test-utils-core';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { AppContextProvider } from '../app/AppContext';
|
||||
import { createPlugin } from '../plugin';
|
||||
import { createRouteRef } from '../routing';
|
||||
import { getComponentData } from './componentData';
|
||||
@@ -29,9 +23,6 @@ import {
|
||||
createReactExtension,
|
||||
createRoutableExtension,
|
||||
} from './extensions';
|
||||
import { AppComponents } from '../app/types';
|
||||
import { AppContext } from '../app';
|
||||
import { MockErrorApi } from '@backstage/test-utils';
|
||||
|
||||
const plugin = createPlugin({
|
||||
id: 'my-plugin',
|
||||
@@ -82,44 +73,4 @@ describe('extensions', () => {
|
||||
expect(getComponentData(element2, 'core.plugin')).toBe(plugin);
|
||||
expect(getComponentData(element2, 'core.mountPoint')).toBe(routeRef);
|
||||
});
|
||||
|
||||
it('should wrap extended component with error boundary', async () => {
|
||||
const extension = createComponentExtension({
|
||||
component: {
|
||||
sync: () => {
|
||||
throw new Error('Test error');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const BrokenComponent = plugin.provide(extension);
|
||||
|
||||
const errorApi = new MockErrorApi();
|
||||
|
||||
const apis = ApiRegistry.from([[errorApiRef, errorApi]]);
|
||||
|
||||
const MockFallback: AppComponents['ErrorBoundaryFallback'] = props => (
|
||||
<>Error in {props.plugin?.getId()}</>
|
||||
);
|
||||
|
||||
const { error: errors } = await withLogCollector(['error'], async () => {
|
||||
render(
|
||||
<ApiProvider apis={apis}>
|
||||
<AppContextProvider
|
||||
appContext={
|
||||
({
|
||||
getComponents: () => ({
|
||||
ErrorBoundaryFallback: MockFallback,
|
||||
}),
|
||||
} as unknown) as AppContext
|
||||
}
|
||||
>
|
||||
<BrokenComponent />
|
||||
</AppContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
});
|
||||
screen.getByText('Error in my-plugin');
|
||||
expect(errors[0]).toMatch('Test error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@ import { useApp } from '../app';
|
||||
import { BackstagePlugin, Extension } from '../plugin/types';
|
||||
import { RouteRef, useRouteRef } from '../routing';
|
||||
import { attachComponentData } from './componentData';
|
||||
import { PluginErrorBoundary } from './PluginErrorBoundary';
|
||||
|
||||
type ComponentLoader<T> =
|
||||
| {
|
||||
@@ -124,28 +123,11 @@ export function createReactExtension<
|
||||
|
||||
return {
|
||||
expose(plugin: BackstagePlugin<any, any>) {
|
||||
const Result: any = (props: any) => {
|
||||
const app = useApp();
|
||||
const { ErrorBoundaryFallback } = app.getComponents();
|
||||
|
||||
return (
|
||||
<Suspense fallback="...">
|
||||
<PluginErrorBoundary
|
||||
fallbackRender={({ error, resetError }) => {
|
||||
return (
|
||||
<ErrorBoundaryFallback
|
||||
error={error}
|
||||
resetError={resetError}
|
||||
plugin={plugin}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Component {...props} />
|
||||
</PluginErrorBoundary>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
const Result: any = (props: any) => (
|
||||
<Suspense fallback="...">
|
||||
<Component {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
attachComponentData(Result, 'core.plugin', plugin);
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AppContext } from '../app';
|
||||
import { render } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import React, {
|
||||
@@ -24,7 +23,6 @@ import React, {
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { AppContextProvider } from '../app/AppContext';
|
||||
import { createRoutableExtension } from '../extensions';
|
||||
import {
|
||||
childDiscoverer,
|
||||
@@ -156,27 +154,23 @@ function withRoutingProvider(
|
||||
);
|
||||
}
|
||||
|
||||
const appContext = ({ getComponents: () => ({}) } as unknown) as AppContext;
|
||||
|
||||
describe('discovery', () => {
|
||||
it('should handle simple routeRef path creation for routeRefs used in other parts of the app', async () => {
|
||||
const root = (
|
||||
<AppContextProvider appContext={appContext}>
|
||||
<MemoryRouter initialEntries={['/foo/bar']}>
|
||||
<Routes>
|
||||
<Extension1 path="/foo">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref2} />
|
||||
<MockRouteSource name="insideExternal" routeRef={eRefA} />
|
||||
</Extension1>
|
||||
<Extension3 path="/baz" />
|
||||
</Routes>
|
||||
<MockRouteSource name="outside" routeRef={ref2} />
|
||||
<MockRouteSource name="outsideExternal1" routeRef={eRefB} />
|
||||
<MockRouteSource name="outsideExternal2" routeRef={eRefC} />
|
||||
<MockRouteSource name="outsideExternal3" routeRef={eRefD} />
|
||||
<MockRouteSource name="outsideExternal4" routeRef={eRefE} />
|
||||
</MemoryRouter>
|
||||
</AppContextProvider>
|
||||
<MemoryRouter initialEntries={['/foo/bar']}>
|
||||
<Routes>
|
||||
<Extension1 path="/foo">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref2} />
|
||||
<MockRouteSource name="insideExternal" routeRef={eRefA} />
|
||||
</Extension1>
|
||||
<Extension3 path="/baz" />
|
||||
</Routes>
|
||||
<MockRouteSource name="outside" routeRef={ref2} />
|
||||
<MockRouteSource name="outsideExternal1" routeRef={eRefB} />
|
||||
<MockRouteSource name="outsideExternal2" routeRef={eRefC} />
|
||||
<MockRouteSource name="outsideExternal3" routeRef={eRefD} />
|
||||
<MockRouteSource name="outsideExternal4" routeRef={eRefE} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const rendered = render(
|
||||
@@ -211,25 +205,23 @@ describe('discovery', () => {
|
||||
|
||||
it('should handle routeRefs with parameters', async () => {
|
||||
const root = (
|
||||
<AppContextProvider appContext={appContext}>
|
||||
<MemoryRouter initialEntries={['/foo/bar/wat']}>
|
||||
<Routes>
|
||||
<Extension1 path="/foo">
|
||||
<Extension4
|
||||
path="/bar/:id"
|
||||
name="inside"
|
||||
routeRef={ref4}
|
||||
params={{ id: 'bleb' }}
|
||||
/>
|
||||
</Extension1>
|
||||
</Routes>
|
||||
<MockRouteSource
|
||||
name="outside"
|
||||
routeRef={ref4}
|
||||
params={{ id: 'blob' }}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</AppContextProvider>
|
||||
<MemoryRouter initialEntries={['/foo/bar/wat']}>
|
||||
<Routes>
|
||||
<Extension1 path="/foo">
|
||||
<Extension4
|
||||
path="/bar/:id"
|
||||
name="inside"
|
||||
routeRef={ref4}
|
||||
params={{ id: 'bleb' }}
|
||||
/>
|
||||
</Extension1>
|
||||
</Routes>
|
||||
<MockRouteSource
|
||||
name="outside"
|
||||
routeRef={ref4}
|
||||
params={{ id: 'blob' }}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const rendered = render(withRoutingProvider(root));
|
||||
@@ -244,37 +236,8 @@ describe('discovery', () => {
|
||||
|
||||
it('should handle relative routing within parameterized routePaths', async () => {
|
||||
const root = (
|
||||
<AppContextProvider appContext={appContext}>
|
||||
<MemoryRouter initialEntries={['/foo/blob/baz']}>
|
||||
<React.Suspense fallback="loller">
|
||||
<Routes>
|
||||
<Extension5 path="/foo/:id">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref3} />
|
||||
<Extension3 path="/baz" />
|
||||
</Extension5>
|
||||
</Routes>
|
||||
<MockRouteSource name="outsideNoParams" routeRef={ref3} />
|
||||
<MockRouteSource
|
||||
name="outsideWithParams"
|
||||
routeRef={ref3}
|
||||
params={{ id: 'blob' }}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</MemoryRouter>
|
||||
</AppContextProvider>
|
||||
);
|
||||
|
||||
const rendered = render(withRoutingProvider(root));
|
||||
|
||||
await expect(
|
||||
rendered.findByText('Path at inside: /foo/blob/baz'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should throw errors for routing to other routeRefs with unsupported parameters', () => {
|
||||
const root = (
|
||||
<AppContextProvider appContext={appContext}>
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<MemoryRouter initialEntries={['/foo/blob/baz']}>
|
||||
<React.Suspense fallback="loller">
|
||||
<Routes>
|
||||
<Extension5 path="/foo/:id">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref3} />
|
||||
@@ -287,8 +250,33 @@ describe('discovery', () => {
|
||||
routeRef={ref3}
|
||||
params={{ id: 'blob' }}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</AppContextProvider>
|
||||
</React.Suspense>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const rendered = render(withRoutingProvider(root));
|
||||
|
||||
await expect(
|
||||
rendered.findByText('Path at inside: /foo/blob/baz'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should throw errors for routing to other routeRefs with unsupported parameters', () => {
|
||||
const root = (
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<Routes>
|
||||
<Extension5 path="/foo/:id">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref3} />
|
||||
<Extension3 path="/baz" />
|
||||
</Extension5>
|
||||
</Routes>
|
||||
<MockRouteSource name="outsideNoParams" routeRef={ref3} />
|
||||
<MockRouteSource
|
||||
name="outsideWithParams"
|
||||
routeRef={ref3}
|
||||
params={{ id: 'blob' }}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const rendered = render(withRoutingProvider(root));
|
||||
@@ -307,15 +295,13 @@ describe('discovery', () => {
|
||||
|
||||
it('should handle relative routing of parameterized routePaths with duplicate param names', () => {
|
||||
const root = (
|
||||
<AppContextProvider appContext={appContext}>
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Extension5 path="/foo/:id">
|
||||
<Extension4 path="/bar/:id" name="borked" routeRef={ref4} />
|
||||
</Extension5>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</AppContextProvider>
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Extension5 path="/foo/:id">
|
||||
<Extension4 path="/bar/:id" name="borked" routeRef={ref4} />
|
||||
</Extension5>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const { routePaths, routeParents } = traverseElementTree({
|
||||
|
||||
@@ -120,6 +120,7 @@ export type AppComponents = {
|
||||
BootErrorPage: ComponentType<BootErrorPageProps>;
|
||||
Progress: ComponentType<{}>;
|
||||
Router: ComponentType<{}>;
|
||||
ErrorBoundaryFallback: ComponentType<ErrorBoundaryFallbackProps>;
|
||||
SignInPage?: ComponentType<SignInPageProps>;
|
||||
};
|
||||
|
||||
@@ -220,6 +221,13 @@ export class ErrorApiForwarder implements ErrorApi {
|
||||
post(error: Error, context?: ErrorContext): void;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type ErrorBoundaryFallbackProps = {
|
||||
plugin?: BackstagePlugin;
|
||||
error: Error;
|
||||
resetError: () => void;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const FlatRoutes: (props: FlatRoutesProps) => JSX.Element | null;
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ describe('Integration Test', () => {
|
||||
BootErrorPage: () => null,
|
||||
Progress: () => null,
|
||||
Router: BrowserRouter,
|
||||
ErrorBoundaryFallback: () => null,
|
||||
};
|
||||
|
||||
it('runs happy paths', async () => {
|
||||
|
||||
@@ -14,21 +14,27 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AppConfig, JsonObject } from '@backstage/config';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { ErrorPage, ErrorPanel, Progress } from '@backstage/core-components';
|
||||
import { darkTheme, lightTheme } from '@backstage/theme';
|
||||
import DarkIcon from '@material-ui/icons/Brightness2';
|
||||
import LightIcon from '@material-ui/icons/WbSunny';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { AppOptions, BootErrorPageProps, AppConfigLoader } from './types';
|
||||
import { defaultAppIcons } from './icons';
|
||||
import {
|
||||
BrowserRouter,
|
||||
MemoryRouter,
|
||||
useInRouterContext,
|
||||
} from 'react-router-dom';
|
||||
import LightIcon from '@material-ui/icons/WbSunny';
|
||||
import DarkIcon from '@material-ui/icons/Brightness2';
|
||||
import { ErrorPage, Progress } from '@backstage/core-components';
|
||||
import { defaultApis } from './defaultApis';
|
||||
import { lightTheme, darkTheme } from '@backstage/theme';
|
||||
import { AppConfig, JsonObject } from '@backstage/config';
|
||||
import { PrivateAppImpl } from './App';
|
||||
import { defaultApis } from './defaultApis';
|
||||
import { defaultAppIcons } from './icons';
|
||||
import {
|
||||
AppConfigLoader,
|
||||
AppOptions,
|
||||
BootErrorPageProps,
|
||||
ErrorBoundaryFallbackProps,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* The default config loader, which expects that config is available at compile-time
|
||||
@@ -115,6 +121,23 @@ export function createApp(options?: AppOptions) {
|
||||
</OptionallyWrapInRouter>
|
||||
);
|
||||
};
|
||||
const DefaultErrorBoundaryFallback = ({
|
||||
error,
|
||||
resetError,
|
||||
plugin,
|
||||
}: ErrorBoundaryFallbackProps) => {
|
||||
return (
|
||||
<ErrorPanel
|
||||
title={`Error in ${plugin?.getId()}`}
|
||||
defaultExpanded
|
||||
error={error}
|
||||
>
|
||||
<Button variant="outlined" onClick={resetError}>
|
||||
Retry
|
||||
</Button>
|
||||
</ErrorPanel>
|
||||
);
|
||||
};
|
||||
|
||||
const apis = options?.apis ?? [];
|
||||
const icons = { ...defaultAppIcons, ...options?.icons };
|
||||
@@ -124,6 +147,7 @@ export function createApp(options?: AppOptions) {
|
||||
BootErrorPage: DefaultBootErrorPage,
|
||||
Progress: Progress,
|
||||
Router: BrowserRouter,
|
||||
ErrorBoundaryFallback: DefaultErrorBoundaryFallback,
|
||||
...options?.components,
|
||||
};
|
||||
const themes = options?.themes ?? [
|
||||
|
||||
@@ -59,11 +59,18 @@ export type SignInPageProps = {
|
||||
onResult(result: SignInResult): void;
|
||||
};
|
||||
|
||||
export type ErrorBoundaryFallbackProps = {
|
||||
plugin?: BackstagePlugin;
|
||||
error: Error;
|
||||
resetError: () => void;
|
||||
};
|
||||
|
||||
export type AppComponents = {
|
||||
NotFoundErrorPage: ComponentType<{}>;
|
||||
BootErrorPage: ComponentType<BootErrorPageProps>;
|
||||
Progress: ComponentType<{}>;
|
||||
Router: ComponentType<{}>;
|
||||
ErrorBoundaryFallback: ComponentType<ErrorBoundaryFallbackProps>;
|
||||
|
||||
/**
|
||||
* An optional sign-in page that will be rendered instead of the AppRouter at startup.
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { validateRoutes } from './validation';
|
||||
import { RouteResolver } from './RouteResolver';
|
||||
import { AnyRouteRef, RouteFunc } from './types';
|
||||
import { AppContextProvider } from '../app/AppContext';
|
||||
|
||||
const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => (
|
||||
<>{children}</>
|
||||
@@ -125,6 +126,12 @@ const Extension5 = plugin.provide(
|
||||
}),
|
||||
);
|
||||
|
||||
const mockContext = {
|
||||
getComponents: () => ({ Progress: () => null } as any),
|
||||
getSystemIcon: jest.fn(),
|
||||
getPlugins: jest.fn(),
|
||||
};
|
||||
|
||||
function withRoutingProvider(
|
||||
root: ReactElement,
|
||||
routeBindings: [ExternalRouteRef, RouteRef][] = [],
|
||||
@@ -154,20 +161,22 @@ function withRoutingProvider(
|
||||
describe('discovery', () => {
|
||||
it('should handle simple routeRef path creation for routeRefs used in other parts of the app', async () => {
|
||||
const root = (
|
||||
<MemoryRouter initialEntries={['/foo/bar']}>
|
||||
<Routes>
|
||||
<Extension1 path="/foo">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref2} />
|
||||
<MockRouteSource name="insideExternal" routeRef={eRefA} />
|
||||
</Extension1>
|
||||
<Extension3 path="/baz" />
|
||||
</Routes>
|
||||
<MockRouteSource name="outside" routeRef={ref2} />
|
||||
<MockRouteSource name="outsideExternal1" routeRef={eRefB} />
|
||||
<MockRouteSource name="outsideExternal2" routeRef={eRefC} />
|
||||
<MockRouteSource name="outsideExternal3" routeRef={eRefD} />
|
||||
<MockRouteSource name="outsideExternal4" routeRef={eRefE} />
|
||||
</MemoryRouter>
|
||||
<AppContextProvider appContext={mockContext}>
|
||||
<MemoryRouter initialEntries={['/foo/bar']}>
|
||||
<Routes>
|
||||
<Extension1 path="/foo">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref2} />
|
||||
<MockRouteSource name="insideExternal" routeRef={eRefA} />
|
||||
</Extension1>
|
||||
<Extension3 path="/baz" />
|
||||
</Routes>
|
||||
<MockRouteSource name="outside" routeRef={ref2} />
|
||||
<MockRouteSource name="outsideExternal1" routeRef={eRefB} />
|
||||
<MockRouteSource name="outsideExternal2" routeRef={eRefC} />
|
||||
<MockRouteSource name="outsideExternal3" routeRef={eRefD} />
|
||||
<MockRouteSource name="outsideExternal4" routeRef={eRefE} />
|
||||
</MemoryRouter>
|
||||
</AppContextProvider>
|
||||
);
|
||||
|
||||
const rendered = render(
|
||||
@@ -202,23 +211,25 @@ describe('discovery', () => {
|
||||
|
||||
it('should handle routeRefs with parameters', async () => {
|
||||
const root = (
|
||||
<MemoryRouter initialEntries={['/foo/bar/wat']}>
|
||||
<Routes>
|
||||
<Extension1 path="/foo">
|
||||
<Extension4
|
||||
path="/bar/:id"
|
||||
name="inside"
|
||||
routeRef={ref4}
|
||||
params={{ id: 'bleb' }}
|
||||
/>
|
||||
</Extension1>
|
||||
</Routes>
|
||||
<MockRouteSource
|
||||
name="outside"
|
||||
routeRef={ref4}
|
||||
params={{ id: 'blob' }}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
<AppContextProvider appContext={mockContext}>
|
||||
<MemoryRouter initialEntries={['/foo/bar/wat']}>
|
||||
<Routes>
|
||||
<Extension1 path="/foo">
|
||||
<Extension4
|
||||
path="/bar/:id"
|
||||
name="inside"
|
||||
routeRef={ref4}
|
||||
params={{ id: 'bleb' }}
|
||||
/>
|
||||
</Extension1>
|
||||
</Routes>
|
||||
<MockRouteSource
|
||||
name="outside"
|
||||
routeRef={ref4}
|
||||
params={{ id: 'blob' }}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</AppContextProvider>
|
||||
);
|
||||
|
||||
const rendered = render(withRoutingProvider(root));
|
||||
@@ -233,22 +244,24 @@ describe('discovery', () => {
|
||||
|
||||
it('should handle relative routing within parameterized routePaths', async () => {
|
||||
const root = (
|
||||
<MemoryRouter initialEntries={['/foo/blob/baz']}>
|
||||
<React.Suspense fallback="loller">
|
||||
<Routes>
|
||||
<Extension5 path="/foo/:id">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref3} />
|
||||
<Extension3 path="/baz" />
|
||||
</Extension5>
|
||||
</Routes>
|
||||
<MockRouteSource name="outsideNoParams" routeRef={ref3} />
|
||||
<MockRouteSource
|
||||
name="outsideWithParams"
|
||||
routeRef={ref3}
|
||||
params={{ id: 'blob' }}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</MemoryRouter>
|
||||
<AppContextProvider appContext={mockContext}>
|
||||
<MemoryRouter initialEntries={['/foo/blob/baz']}>
|
||||
<React.Suspense fallback="loller">
|
||||
<Routes>
|
||||
<Extension5 path="/foo/:id">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref3} />
|
||||
<Extension3 path="/baz" />
|
||||
</Extension5>
|
||||
</Routes>
|
||||
<MockRouteSource name="outsideNoParams" routeRef={ref3} />
|
||||
<MockRouteSource
|
||||
name="outsideWithParams"
|
||||
routeRef={ref3}
|
||||
params={{ id: 'blob' }}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</MemoryRouter>
|
||||
</AppContextProvider>
|
||||
);
|
||||
|
||||
const rendered = render(withRoutingProvider(root));
|
||||
|
||||
+41
-111
@@ -15,17 +15,11 @@
|
||||
*/
|
||||
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import {
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import { Divider, ListItem, ListItemText, makeStyles } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { CodeSnippet } from '../CodeSnippet';
|
||||
import { CopyTextButton } from '../CopyTextButton';
|
||||
import { WarningPanel } from '../WarningPanel';
|
||||
import { ErrorPanel, ErrorPanelProps } from '../ErrorPanel';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
text: {
|
||||
@@ -39,95 +33,25 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
type ResponseErrorListProps = {
|
||||
error: string;
|
||||
message: string;
|
||||
request?: string;
|
||||
stack?: string;
|
||||
json?: string;
|
||||
};
|
||||
|
||||
const ResponseErrorList = ({
|
||||
error,
|
||||
request,
|
||||
message,
|
||||
stack,
|
||||
json,
|
||||
}: ResponseErrorListProps) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<List dense>
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Error"
|
||||
secondary={error}
|
||||
/>
|
||||
<CopyTextButton text={error} />
|
||||
</ListItem>
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Message"
|
||||
secondary={message}
|
||||
/>
|
||||
<CopyTextButton text={message} />
|
||||
</ListItem>
|
||||
{request && (
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Request"
|
||||
secondary={request}
|
||||
/>
|
||||
<CopyTextButton text={request} />
|
||||
</ListItem>
|
||||
)}
|
||||
{stack && (
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Stack Trace"
|
||||
secondary={stack}
|
||||
/>
|
||||
<CopyTextButton text={stack} />
|
||||
</ListItem>
|
||||
)}
|
||||
{json && (
|
||||
<>
|
||||
<Divider component="li" className={classes.divider} />
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Full Error as JSON"
|
||||
secondary={<CodeSnippet language="json" text={json} />}
|
||||
/>
|
||||
<CopyTextButton text={json} />
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
error: Error;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders details about a failed server request.
|
||||
* Renders a warning panel as the effect of a failed server request.
|
||||
*
|
||||
* Has special treatment for ResponseError errors, to display rich
|
||||
* server-provided information about what happened.
|
||||
*/
|
||||
export const ResponseErrorDetails = ({ error }: Props) => {
|
||||
export const ResponseErrorPanel = ({
|
||||
title,
|
||||
error,
|
||||
defaultExpanded,
|
||||
}: ErrorPanelProps) => {
|
||||
const classes = useStyles();
|
||||
|
||||
if (error.name !== 'ResponseError') {
|
||||
return (
|
||||
<ResponseErrorList
|
||||
error={error.name}
|
||||
message={error.message}
|
||||
stack={error.stack}
|
||||
<ErrorPanel
|
||||
title={title ?? error.message}
|
||||
defaultExpanded={defaultExpanded}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -142,26 +66,32 @@ export const ResponseErrorDetails = ({ error }: Props) => {
|
||||
const jsonString = JSON.stringify(data, undefined, 2);
|
||||
|
||||
return (
|
||||
<ResponseErrorList
|
||||
error={errorString}
|
||||
message={messageString}
|
||||
request={requestString}
|
||||
stack={stackString}
|
||||
json={jsonString}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a warning panel as the effect of a failed server request.
|
||||
*
|
||||
* Has special treatment for ResponseError errors, to display rich
|
||||
* server-provided information about what happened.
|
||||
*/
|
||||
export const ResponseErrorPanel = ({ error }: Props) => {
|
||||
return (
|
||||
<WarningPanel title={error.message}>
|
||||
<ResponseErrorDetails error={error} />
|
||||
</WarningPanel>
|
||||
<ErrorPanel
|
||||
title={title ?? error.message}
|
||||
defaultExpanded={defaultExpanded}
|
||||
error={{ name: errorString, message: messageString, stack: stackString }}
|
||||
>
|
||||
{requestString && (
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Request"
|
||||
secondary={request}
|
||||
/>
|
||||
<CopyTextButton text={requestString} />
|
||||
</ListItem>
|
||||
)}
|
||||
<>
|
||||
<Divider component="li" className={classes.divider} />
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Full Error as JSON"
|
||||
secondary={<CodeSnippet language="json" text={jsonString} />}
|
||||
/>
|
||||
<CopyTextButton text={jsonString} />
|
||||
</ListItem>
|
||||
</>
|
||||
</ErrorPanel>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ResponseErrorDetails, ResponseErrorPanel } from './ResponseErrorPanel';
|
||||
export { ResponseErrorPanel } from './ResponseErrorPanel';
|
||||
|
||||
@@ -77,6 +77,7 @@ type Props = {
|
||||
title?: string;
|
||||
severity?: 'warning' | 'error' | 'info';
|
||||
message?: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -92,18 +93,21 @@ const capitalize = (s: string) => {
|
||||
* @param {string} [title] A title for the warning. If not supplied, "Warning" will be used.
|
||||
* @param {Object} [message] Optional more detailed user-friendly message elaborating on the cause of the error.
|
||||
* @param {Object} [children] Objects to provide context, such as a stack trace or detailed error reporting.
|
||||
* Will be available inside an unfolded accordion.
|
||||
* Will be available inside an unfolded accordion.
|
||||
*/
|
||||
export const WarningPanel = (props: Props) => {
|
||||
const classes = useStyles(props);
|
||||
const { severity, title, message, children } = props;
|
||||
const { severity, title, message, children, defaultExpanded } = props;
|
||||
|
||||
// If no severity or title provided, the heading will read simply "Warning"
|
||||
const subTitle =
|
||||
(severity ? capitalize(severity) : 'Warning') + (title ? `: ${title}` : '');
|
||||
|
||||
return (
|
||||
<Accordion className={classes.panel}>
|
||||
<Accordion
|
||||
defaultExpanded={defaultExpanded ?? false}
|
||||
className={classes.panel}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIconStyled />}
|
||||
className={classes.summary}
|
||||
|
||||
@@ -22,6 +22,7 @@ export * from './CopyTextButton';
|
||||
export * from './DependencyGraph';
|
||||
export * from './DismissableBanner';
|
||||
export * from './EmptyState';
|
||||
export * from './ErrorPanel';
|
||||
export * from './ResponseErrorPanel';
|
||||
export * from './FeatureDiscovery';
|
||||
export * from './HeaderIconLinkRow';
|
||||
|
||||
@@ -70,6 +70,7 @@ export type AppComponents = {
|
||||
BootErrorPage: ComponentType<BootErrorPageProps>;
|
||||
Progress: ComponentType<{}>;
|
||||
Router: ComponentType<{}>;
|
||||
ErrorBoundaryFallback: ComponentType<ErrorBoundaryFallbackProps>;
|
||||
SignInPage?: ComponentType<SignInPageProps>;
|
||||
};
|
||||
|
||||
@@ -238,6 +239,13 @@ export type ErrorApi = {
|
||||
// @public (undocumented)
|
||||
export const errorApiRef: ApiRef<ErrorApi>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type ErrorBoundaryFallbackProps = {
|
||||
plugin?: BackstagePlugin;
|
||||
error: Error;
|
||||
resetError: () => void;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ErrorContext = {
|
||||
hidden?: boolean;
|
||||
|
||||
@@ -50,11 +50,18 @@ export type SignInPageProps = {
|
||||
onResult(result: SignInResult): void;
|
||||
};
|
||||
|
||||
export type ErrorBoundaryFallbackProps = {
|
||||
plugin?: BackstagePlugin;
|
||||
error: Error;
|
||||
resetError: () => void;
|
||||
};
|
||||
|
||||
export type AppComponents = {
|
||||
NotFoundErrorPage: ComponentType<{}>;
|
||||
BootErrorPage: ComponentType<BootErrorPageProps>;
|
||||
Progress: ComponentType<{}>;
|
||||
Router: ComponentType<{}>;
|
||||
ErrorBoundaryFallback: ComponentType<ErrorBoundaryFallbackProps>;
|
||||
|
||||
/**
|
||||
* An optional sign-in page that will be rendered instead of the AppRouter at startup.
|
||||
|
||||
+15
-15
@@ -15,19 +15,15 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export type ErrorBoundaryFallbackProps = {
|
||||
error: Error;
|
||||
resetError: () => void;
|
||||
};
|
||||
|
||||
type FallbackRender = React.FunctionComponent<ErrorBoundaryFallbackProps>;
|
||||
import { AppContext } from '../app/types';
|
||||
import { BackstagePlugin } from '../plugin';
|
||||
|
||||
type Props = {
|
||||
fallbackRender: FallbackRender;
|
||||
app: AppContext;
|
||||
plugin: BackstagePlugin;
|
||||
};
|
||||
|
||||
type State = { error?: Error };
|
||||
type State = { error: Error | undefined };
|
||||
|
||||
export class PluginErrorBoundary extends React.Component<Props, State> {
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
@@ -36,19 +32,23 @@ export class PluginErrorBoundary extends React.Component<Props, State> {
|
||||
|
||||
state: State = { error: undefined };
|
||||
|
||||
resetError = () => {
|
||||
handleErrorReset = () => {
|
||||
this.setState({ error: undefined });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
const { fallbackRender } = this.props;
|
||||
const { app, plugin } = this.props;
|
||||
const { ErrorBoundaryFallback } = app.getComponents();
|
||||
|
||||
if (error) {
|
||||
return fallbackRender({
|
||||
error,
|
||||
resetError: this.resetError,
|
||||
});
|
||||
return (
|
||||
<ErrorBoundaryFallback
|
||||
error={error}
|
||||
resetError={this.handleErrorReset}
|
||||
plugin={plugin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
@@ -14,7 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { withLogCollector } from '@backstage/test-utils-core';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { useApp, ErrorBoundaryFallbackProps } from '../app';
|
||||
import { createPlugin } from '../plugin';
|
||||
import { createRouteRef } from '../routing';
|
||||
import { getComponentData } from './componentData';
|
||||
@@ -24,6 +27,10 @@ import {
|
||||
createRoutableExtension,
|
||||
} from './extensions';
|
||||
|
||||
jest.mock('../app');
|
||||
|
||||
const mocked = (f: Function) => f as jest.Mock;
|
||||
|
||||
const plugin = createPlugin({
|
||||
id: 'my-plugin',
|
||||
});
|
||||
@@ -73,4 +80,31 @@ describe('extensions', () => {
|
||||
expect(getComponentData(element2, 'core.plugin')).toBe(plugin);
|
||||
expect(getComponentData(element2, 'core.mountPoint')).toBe(routeRef);
|
||||
});
|
||||
|
||||
it('should wrap extended component with error boundary', async () => {
|
||||
const BrokenComponent = plugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
sync: () => {
|
||||
throw new Error('Test error');
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
mocked(useApp).mockReturnValue({
|
||||
getComponents: () => ({
|
||||
Progress: () => null,
|
||||
ErrorBoundaryFallback: (props: ErrorBoundaryFallbackProps) => (
|
||||
<>Error in {props.plugin?.getId()}</>
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const { error: errors } = await withLogCollector(['error'], async () => {
|
||||
render(<BrokenComponent />);
|
||||
});
|
||||
screen.getByText('Error in my-plugin');
|
||||
expect(errors[0]).toMatch('Test error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useApp } from '../app';
|
||||
import { RouteRef, useRouteRef } from '../routing';
|
||||
import { attachComponentData } from './componentData';
|
||||
import { Extension, BackstagePlugin } from '../plugin/types';
|
||||
import { PluginErrorBoundary } from './PluginErrorBoundary';
|
||||
|
||||
type ComponentLoader<T> =
|
||||
| {
|
||||
@@ -123,11 +124,18 @@ export function createReactExtension<
|
||||
|
||||
return {
|
||||
expose(plugin: BackstagePlugin<any, any>) {
|
||||
const Result: any = (props: any) => (
|
||||
<Suspense fallback="...">
|
||||
<Component {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
const Result: any = (props: any) => {
|
||||
const app = useApp();
|
||||
const { Progress } = app.getComponents();
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Progress />}>
|
||||
<PluginErrorBoundary app={app} plugin={plugin}>
|
||||
<Component {...props} />
|
||||
</PluginErrorBoundary>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
attachComponentData(Result, 'core.plugin', plugin);
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
|
||||
@@ -30,7 +30,6 @@ import LightIcon from '@material-ui/icons/WbSunny';
|
||||
import DarkIcon from '@material-ui/icons/Brightness2';
|
||||
import { ErrorPage } from '../layout/ErrorPage';
|
||||
import { Progress } from '../components/Progress';
|
||||
import { ErrorBoundaryFallback } from '../components/ErrorBoundaryFallback';
|
||||
import { defaultApis } from './defaultApis';
|
||||
import { lightTheme, darkTheme } from '@backstage/theme';
|
||||
import { AppConfig, JsonObject } from '@backstage/config';
|
||||
@@ -131,7 +130,6 @@ export function createApp(options?: AppOptions) {
|
||||
BootErrorPage: DefaultBootErrorPage,
|
||||
Progress: Progress,
|
||||
Router: BrowserRouter,
|
||||
ErrorBoundaryFallback: ErrorBoundaryFallback,
|
||||
...options?.components,
|
||||
};
|
||||
const themes = options?.themes ?? [
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { AppComponents } from '../..';
|
||||
import { ErrorPanel } from '../ErrorPanel';
|
||||
import { Button } from '@material-ui/core';
|
||||
|
||||
export const ErrorBoundaryFallback: AppComponents['ErrorBoundaryFallback'] = ({
|
||||
error,
|
||||
resetError,
|
||||
plugin,
|
||||
}) => {
|
||||
return (
|
||||
<ErrorPanel
|
||||
title={`Error in ${plugin?.getId()}`}
|
||||
defaultExpanded
|
||||
error={error}
|
||||
>
|
||||
<Button variant="outlined" onClick={resetError}>
|
||||
Retry
|
||||
</Button>
|
||||
</ErrorPanel>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ErrorBoundaryFallback } from './ErrorBoundaryFallback';
|
||||
@@ -15,11 +15,17 @@
|
||||
*/
|
||||
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import { Divider, ListItem, ListItemText, makeStyles } from '@material-ui/core';
|
||||
import {
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { CodeSnippet } from '../CodeSnippet';
|
||||
import { CopyTextButton } from '../CopyTextButton';
|
||||
import { ErrorPanel, ErrorPanelProps } from '../ErrorPanel';
|
||||
import { WarningPanel } from '../WarningPanel';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
text: {
|
||||
@@ -33,25 +39,95 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
type ResponseErrorListProps = {
|
||||
error: string;
|
||||
message: string;
|
||||
request?: string;
|
||||
stack?: string;
|
||||
json?: string;
|
||||
};
|
||||
|
||||
const ResponseErrorList = ({
|
||||
error,
|
||||
request,
|
||||
message,
|
||||
stack,
|
||||
json,
|
||||
}: ResponseErrorListProps) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<List dense>
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Error"
|
||||
secondary={error}
|
||||
/>
|
||||
<CopyTextButton text={error} />
|
||||
</ListItem>
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Message"
|
||||
secondary={message}
|
||||
/>
|
||||
<CopyTextButton text={message} />
|
||||
</ListItem>
|
||||
{request && (
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Request"
|
||||
secondary={request}
|
||||
/>
|
||||
<CopyTextButton text={request} />
|
||||
</ListItem>
|
||||
)}
|
||||
{stack && (
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Stack Trace"
|
||||
secondary={stack}
|
||||
/>
|
||||
<CopyTextButton text={stack} />
|
||||
</ListItem>
|
||||
)}
|
||||
{json && (
|
||||
<>
|
||||
<Divider component="li" className={classes.divider} />
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Full Error as JSON"
|
||||
secondary={<CodeSnippet language="json" text={json} />}
|
||||
/>
|
||||
<CopyTextButton text={json} />
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
error: Error;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a warning panel as the effect of a failed server request.
|
||||
* Renders details about a failed server request.
|
||||
*
|
||||
* Has special treatment for ResponseError errors, to display rich
|
||||
* server-provided information about what happened.
|
||||
*/
|
||||
export const ResponseErrorPanel = ({
|
||||
title,
|
||||
error,
|
||||
defaultExpanded,
|
||||
}: ErrorPanelProps) => {
|
||||
const classes = useStyles();
|
||||
|
||||
export const ResponseErrorDetails = ({ error }: Props) => {
|
||||
if (error.name !== 'ResponseError') {
|
||||
return (
|
||||
<ErrorPanel
|
||||
title={title ?? error.message}
|
||||
defaultExpanded={defaultExpanded}
|
||||
error={error}
|
||||
<ResponseErrorList
|
||||
error={error.name}
|
||||
message={error.message}
|
||||
stack={error.stack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -66,32 +142,26 @@ export const ResponseErrorPanel = ({
|
||||
const jsonString = JSON.stringify(data, undefined, 2);
|
||||
|
||||
return (
|
||||
<ErrorPanel
|
||||
title={title ?? error.message}
|
||||
defaultExpanded={defaultExpanded}
|
||||
error={{ name: errorString, message: messageString, stack: stackString }}
|
||||
>
|
||||
{requestString && (
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Request"
|
||||
secondary={request}
|
||||
/>
|
||||
<CopyTextButton text={requestString} />
|
||||
</ListItem>
|
||||
)}
|
||||
<>
|
||||
<Divider component="li" className={classes.divider} />
|
||||
<ListItem alignItems="flex-start">
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Full Error as JSON"
|
||||
secondary={<CodeSnippet language="json" text={jsonString} />}
|
||||
/>
|
||||
<CopyTextButton text={jsonString} />
|
||||
</ListItem>
|
||||
</>
|
||||
</ErrorPanel>
|
||||
<ResponseErrorList
|
||||
error={errorString}
|
||||
message={messageString}
|
||||
request={requestString}
|
||||
stack={stackString}
|
||||
json={jsonString}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a warning panel as the effect of a failed server request.
|
||||
*
|
||||
* Has special treatment for ResponseError errors, to display rich
|
||||
* server-provided information about what happened.
|
||||
*/
|
||||
export const ResponseErrorPanel = ({ error }: Props) => {
|
||||
return (
|
||||
<WarningPanel title={error.message}>
|
||||
<ResponseErrorDetails error={error} />
|
||||
</WarningPanel>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ResponseErrorPanel } from './ResponseErrorPanel';
|
||||
export { ResponseErrorDetails, ResponseErrorPanel } from './ResponseErrorPanel';
|
||||
|
||||
@@ -77,7 +77,6 @@ type Props = {
|
||||
title?: string;
|
||||
severity?: 'warning' | 'error' | 'info';
|
||||
message?: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -97,14 +96,14 @@ const capitalize = (s: string) => {
|
||||
*/
|
||||
export const WarningPanel = (props: Props) => {
|
||||
const classes = useStyles(props);
|
||||
const { severity, title, message, children, defaultExpanded } = props;
|
||||
const { severity, title, message, children } = props;
|
||||
|
||||
// If no severity or title provided, the heading will read simply "Warning"
|
||||
const subTitle =
|
||||
(severity ? capitalize(severity) : 'Warning') + (title ? `: ${title}` : '');
|
||||
|
||||
return (
|
||||
<Accordion defaultExpanded={defaultExpanded} className={classes.panel}>
|
||||
<Accordion className={classes.panel}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIconStyled />}
|
||||
className={classes.summary}
|
||||
|
||||
@@ -23,7 +23,6 @@ export * from './CopyTextButton';
|
||||
export * from './DependencyGraph';
|
||||
export * from './DismissableBanner';
|
||||
export * from './EmptyState';
|
||||
export * from './ErrorBoundaryFallback';
|
||||
export * from './ResponseErrorPanel';
|
||||
export * from './FeatureDiscovery';
|
||||
export * from './HeaderIconLinkRow';
|
||||
|
||||
@@ -96,7 +96,6 @@ export function wrapInTestApp(
|
||||
Router: ({ children }) => (
|
||||
<MemoryRouter initialEntries={routeEntries} children={children} />
|
||||
),
|
||||
ErrorBoundaryFallback: () => null,
|
||||
},
|
||||
icons: defaultSystemIcons,
|
||||
plugins: [],
|
||||
|
||||
Reference in New Issue
Block a user